Skip to content

fix(web_core): Deep recursive type resolution for GenericBinder#1170

Open
jacobsimionato wants to merge 3 commits intogoogle:mainfrom
jacobsimionato:react-type-safety
Open

fix(web_core): Deep recursive type resolution for GenericBinder#1170
jacobsimionato wants to merge 3 commits intogoogle:mainfrom
jacobsimionato:react-type-safety

Conversation

@jacobsimionato
Copy link
Copy Markdown
Collaborator

Description of Changes

This PR modifies the ResolveA2uiProps and ResolveA2uiProp utility types in generic-binder.ts to perform a deep recursive resolution of component schemas rather than just a shallow top-level pass.

Additionally, it adds a suite of strict compile-time type tests in generic-binder-types.test.ts to enforce that these generic types never inadvertently degrade to any during future development.

Rationale

Previously, the GenericBinder types in web_core (which drive the strongly-typed Developer Experience in the React and Angular renderers) only resolved the outermost properties of a component's Zod schema. Because of this, deeply nested arrays of objects (like the options array in ChoicePicker) remained fully unresolved (defaulting to their raw DynamicString or DynamicValue union payloads like string | DataBinding | FunctionCall).

Similarly, the ChildList routing structure originally fell back to any rather than strictly defining the output as an array of {id: string, basePath?: string} nodes.

By migrating to recursive mappings, components requiring complex structural loops or nested properties now receive perfect type inference, improving IDE autocomplete and strictness downstream.

Testing / Running Instructions

To test these changes locally:

  1. Clone or checkout the branch.
  2. Navigate to renderers/web_core.
  3. Run npm run build. The build invokes tsc, which will process generic-binder-types.test.ts. This file uses native @ts-expect-error directives and Assert<IsEqual<...>> constraints. If the compiler succeeds, the types are mathematically sound and have not fallen back to any.
  4. Navigate to renderers/react.
  5. Run npm run typecheck to verify that the generated ResolveA2uiProps mappings accurately propagate through the React implementation without throwing mismatched assignment errors.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive type-testing suite for the Generic Binder and significantly improves the ResolveA2uiProp and ResolveA2uiProps TypeScript types to handle recursive structures, arrays, and child lists more accurately. The review feedback identifies three critical improvements: refining the IsChildList check to avoid false positives with plain string arrays, ensuring null values are preserved during recursive resolution to match runtime behavior, and conditionally applying validation properties (isValid, validationErrors) only to objects that actually support validation checks.

type ResolvedChildNode = { id: string; basePath?: string };

type IsAction<T> = [NonNullable<T>] extends [Action] ? true : false;
type IsChildList<T> = [NonNullable<T>] extends [ChildList] ? true : false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of IsChildList returns true for any string[] because string[] is a valid member of the ChildList union. This causes plain string array properties (e.g., a list of tags) to be incorrectly resolved as ResolvedChildNode[].

Furthermore, the GenericBinder runtime returns the raw string[] for static child lists (line 321), creating a type mismatch if the type is forced to ResolvedChildNode[]. Changing the check to verify the presence of the dynamic child list structure ensures that only actual ChildList unions are resolved this way, while static lists and plain arrays correctly fall through to the standard array resolution.

Suggested change
type IsChildList<T> = [NonNullable<T>] extends [ChildList] ? true : false;
type IsChildList<T> = [{componentId: string; path: string}] extends [NonNullable<T>] ? true : false;

Comment on lines +147 to +155
export type ResolveA2uiProp<T> = IsAction<T> extends true
? (() => void) | Extract<T, undefined>
: [NonNullable<T>] extends [ChildList]
? any | Extract<T, undefined>
: Exclude<T, DynamicTypes> extends never
? any
: Exclude<T, DynamicTypes>;
: IsChildList<T> extends true
? ResolvedChildNode[] | Extract<T, undefined>
: Exclude<NonNullable<T>, DynamicTypes> extends Array<infer U>
? Array<ResolveA2uiProp<U>> | Extract<T, undefined>
: Exclude<NonNullable<T>, DynamicTypes> extends object
? ResolveA2uiProps<Exclude<NonNullable<T>, DynamicTypes>> | Extract<T, undefined>
: Exclude<T, DynamicTypes>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The recursive resolution logic for actions, child lists, arrays, and objects fails to preserve null values. While Extract<T, undefined> handles optional properties, NonNullable<T> removes both null and undefined. Since the runtime binder explicitly supports null values (line 251), the type resolution should preserve them to avoid type errors when dealing with nullable schema fields.

Suggested change
export type ResolveA2uiProp<T> = IsAction<T> extends true
? (() => void) | Extract<T, undefined>
: [NonNullable<T>] extends [ChildList]
? any | Extract<T, undefined>
: Exclude<T, DynamicTypes> extends never
? any
: Exclude<T, DynamicTypes>;
: IsChildList<T> extends true
? ResolvedChildNode[] | Extract<T, undefined>
: Exclude<NonNullable<T>, DynamicTypes> extends Array<infer U>
? Array<ResolveA2uiProp<U>> | Extract<T, undefined>
: Exclude<NonNullable<T>, DynamicTypes> extends object
? ResolveA2uiProps<Exclude<NonNullable<T>, DynamicTypes>> | Extract<T, undefined>
: Exclude<T, DynamicTypes>;
export type ResolveA2uiProp<T> = IsAction<T> extends true
? (() => void) | Extract<T, undefined | null>
: IsChildList<T> extends true
? ResolvedChildNode[] | Extract<T, undefined | null>
: Exclude<NonNullable<T>, DynamicTypes> extends Array<infer U>
? Array<ResolveA2uiProp<U>> | Extract<T, undefined | null>
: Exclude<NonNullable<T>, DynamicTypes> extends object
? ResolveA2uiProps<Exclude<NonNullable<T>, DynamicTypes>> | Extract<T, undefined | null>
: Exclude<T, DynamicTypes>;

Comment on lines +171 to +178
export type ResolveA2uiProps<T> = T extends object
? {
[K in keyof T]: ResolveA2uiProp<T[K]>;
} & GenerateSetters<T> & {
isValid?: boolean;
validationErrors?: string[];
}
: T) &
GenerateSetters<T> & {
isValid?: boolean;
validationErrors?: string[];
};
: T;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The isValid and validationErrors properties are currently added to every resolved object. However, the GenericBinder only injects these properties if the object contains a checks field (the CHECKABLE behavior). Adding them to all objects (like accessibility or nested configuration nodes) is misleading and pollutes the type space for components that don't support validation. It's better to make these properties conditional on the presence of the checks field in the schema.

Suggested change
export type ResolveA2uiProps<T> = T extends object
? {
[K in keyof T]: ResolveA2uiProp<T[K]>;
} & GenerateSetters<T> & {
isValid?: boolean;
validationErrors?: string[];
}
: T) &
GenerateSetters<T> & {
isValid?: boolean;
validationErrors?: string[];
};
: T;
export type ResolveA2uiProps<T> = T extends object
? {
[K in keyof T]: ResolveA2uiProp<T[K]>;
} & GenerateSetters<T> & (T extends {checks: any} ? {
isValid?: boolean;
validationErrors?: string[];
} : {})
: T;

? any
: Exclude<T, DynamicTypes>;
export type ResolveA2uiProp<T> = IsAction<T> extends true
? (() => void) | Extract<T, undefined | null>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, test this in antigravity/vscode. I think I tried something similar to this and at some point the typescript inference was giving up attempting to resolve zod stuff (look at props.something in a component implementation, for example)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

2 participants