Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions renderers/web_core/src/v0_9/rendering/generic-binder-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { describe, it } from 'node:test';
import * as assert from 'node:assert';
import { InferredComponentApiSchemaType } from '../catalog/types.js';
import { ResolveA2uiProps } from './generic-binder.js';
import {
TextApi,
ChoicePickerApi,
ColumnApi,
ButtonApi
} from '../basic_catalog/components/basic_components.js';

/**
* Type testing utilities
* This creates a strict equality check that doesn't collapse on unions or `any`.
*/
export type IsEqual<T, U> =
(<G>() => G extends T ? 1 : 2) extends
(<G>() => G extends U ? 1 : 2)
? true
: false;

export type Assert<T extends true> = T;

// 1. Test Text Component (Primitive resolution and setters)
type TextProps = ResolveA2uiProps<InferredComponentApiSchemaType<typeof TextApi>>;

// The 'text' property is a DynamicString, so it should resolve strictly to `string`
export type _TestTextProp = Assert<IsEqual<TextProps['text'], string>>;

// A setter 'setText' should be auto-generated because 'text' is Dynamic
export type _TestSetText = Assert<IsEqual<TextProps['setText'], (value: string) => void>>;

// 'variant' is static, so it should NOT generate a setter, and should retain its literal union
export type _TestVariantProp = Assert<IsEqual<TextProps['variant'], "h1" | "h2" | "h3" | "h4" | "h5" | "caption" | "body" | undefined>>;

// @ts-expect-error - 'setVariant' should not exist
export type _TestNoSetVariant = TextProps['setVariant'];


// 2. Test ChoicePicker Component (Deep array resolution)
type ChoicePickerProps = ResolveA2uiProps<InferredComponentApiSchemaType<typeof ChoicePickerApi>>;

// 'value' is a DynamicStringList, it should resolve to `string[]`
export type _TestChoiceValue = Assert<IsEqual<ChoicePickerProps['value'], string[]>>;
export type _TestSetChoiceValue = Assert<IsEqual<ChoicePickerProps['setValue'], (value: string[]) => void>>;

// 'options' is an array of objects. Its nested 'label' is dynamic, 'value' is static.
export type _TestOptionsLabel = Assert<IsEqual<ChoicePickerProps['options'][0]['label'], string>>;
export type _TestOptionsSetLabel = Assert<IsEqual<ChoicePickerProps['options'][0]['setLabel'], (value: string) => void>>;
export type _TestOptionsValue = Assert<IsEqual<ChoicePickerProps['options'][0]['value'], string>>;
// @ts-expect-error - 'value' inside options is static, so it shouldn't generate 'setValue'
export type _TestOptionsNoSetValue = ChoicePickerProps['options'][0]['setValue'];


// 3. Test Column Component (Structural ChildList resolution)
type ColumnProps = ResolveA2uiProps<InferredComponentApiSchemaType<typeof ColumnApi>>;

// 'children' is a ChildList, resolving to ResolvedChildNode[]
export type _TestColumnChildren = Assert<IsEqual<ColumnProps['children'], { id: string; basePath?: string }[]>>;

// @ts-expect-error - 'children' is not dynamic in the sense of two-way data binding, so no setter
export type _TestNoSetChildren = ColumnProps['setChildren'];


// 4. Test Button Component (Action resolution)
type ButtonProps = ResolveA2uiProps<InferredComponentApiSchemaType<typeof ButtonApi>>;

// 'action' is an Action, resolving to a callable function
export type _TestButtonAction = Assert<IsEqual<ButtonProps['action'], () => void>>;

// 5. Test @ts-expect-error Negative Cases directly
export function runtimeTypeCheckTests() {
const textProps = {} as TextProps;

// @ts-expect-error - text should strictly be a string, not a number
const invalidAssignment: number = textProps.text;

// @ts-expect-error - action must be a function
const invalidAction: string = ({} as ButtonProps).action;

return [invalidAssignment, invalidAction];
}

describe('Generic Binder Types', () => {
it('should pass type-level compilation checks', () => {
// If tsc compiles this file, all type assertions (`Assert<IsEqual<...>>`) are valid
// and all `@ts-expect-error` directives successfully caught the intended failures.
assert.ok(true, "Type assertions passed");
});
});
36 changes: 20 additions & 16 deletions renderers/web_core/src/v0_9/rendering/generic-binder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,26 +135,31 @@ function getFieldBehavior(
// --- Generic Binder ---

type DynamicTypes = DataBinding | FunctionCall;
type IsDynamic<T> = DataBinding extends NonNullable<T> ? true : false;
type ResolvedChildNode = { id: string; basePath?: string };

type IsAction<T> = [NonNullable<T>] extends [Action] ? true : false;
type IsChildList<T> = (<G>() => G extends ChildList ? 1 : 2) extends (<G>() => G extends NonNullable<T> ? 1 : 2) ? true : false;

/**
* Maps raw Zod inferred types to their resolved runtime equivalents.
* For example, an `Action` object becomes a callable `() => void` function.
*/
export type ResolveA2uiProp<T> = [NonNullable<T>] extends [Action]
? (() => void) | Extract<T, undefined>
: [NonNullable<T>] extends [ChildList]
? any | Extract<T, undefined>
: Exclude<T, DynamicTypes> extends never
? 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)

: 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 +147 to +155
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>;


/**
* Automatically generates two-way binding setters for dynamic properties.
* If a schema has a `value: DynamicString`, this type generates a `setValue(val: string)` method.
*/
export type GenerateSetters<T> = {
[K in keyof T as IsDynamic<T[K]> extends true
[K in keyof T as DataBinding extends NonNullable<T[K]>
? `set${Capitalize<string & K>}`
: never]-?: (value: Exclude<NonNullable<T[K]>, DynamicTypes>) => void;
};
Expand All @@ -163,15 +168,14 @@ export type GenerateSetters<T> = {
* The final output type of the Generic Binder, providing fully resolved, ready-to-use props.
* This is what framework-specific adapters (like `createReactComponent`) pass to the developer's view logic.
*/
export type ResolveA2uiProps<T> = (T extends object
export type ResolveA2uiProps<T> = T extends object
? {
[K in keyof T]: ResolveA2uiProp<T[K]>;
}
: T) &
GenerateSetters<T> & {
isValid?: boolean;
validationErrors?: string[];
};
} & GenerateSetters<T> & (T extends {checks: any} ? {
isValid?: boolean;
validationErrors?: string[];
} : {})
: T;
Comment on lines +171 to +178
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;


/**
* The Generic Binder is a framework-agnostic engine that transforms raw A2UI JSON payload
Expand Down
Loading