fix(web_core): Deep recursive type resolution for GenericBinder#1170
fix(web_core): Deep recursive type resolution for GenericBinder#1170jacobsimionato wants to merge 3 commits intogoogle:mainfrom
Conversation
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
| type IsChildList<T> = [NonNullable<T>] extends [ChildList] ? true : false; | |
| type IsChildList<T> = [{componentId: string; path: string}] extends [NonNullable<T>] ? true : false; |
| 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>; |
There was a problem hiding this comment.
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.
| 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>; |
| 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; |
There was a problem hiding this comment.
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.
| 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> |
There was a problem hiding this comment.
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)
Description of Changes
This PR modifies the
ResolveA2uiPropsandResolveA2uiProputility types ingeneric-binder.tsto 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.tsto enforce that these generic types never inadvertently degrade toanyduring future development.Rationale
Previously, the
GenericBindertypes inweb_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 theoptionsarray inChoicePicker) remained fully unresolved (defaulting to their rawDynamicStringorDynamicValueunion payloads likestring | DataBinding | FunctionCall).Similarly, the
ChildListrouting structure originally fell back toanyrather 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:
renderers/web_core.npm run build. The build invokestsc, which will processgeneric-binder-types.test.ts. This file uses native@ts-expect-errordirectives andAssert<IsEqual<...>>constraints. If the compiler succeeds, the types are mathematically sound and have not fallen back toany.renderers/react.npm run typecheckto verify that the generatedResolveA2uiPropsmappings accurately propagate through the React implementation without throwing mismatched assignment errors.