a2ui-project / a2ui-project/a2ui
[FEATURE]: Abstract dependency on Zod on web_core.
- Lingua principale
- TypeScript
- Stelle
- 16.4k
- Fork
- 1.3k
- Merge medio
- 2g 13h
- PR unite (30g)
- 134
Descrizione
# A2UI Schema Abstraction & Zod Decoupling Plan
- [x] I have searched the existing issues to make sure this feature has not already been requested.
## Is your feature request related to a problem? Please describe.
Currently, `@a2ui/web_core` depends on `zod: ^3.25.X`. This is a problem because Zod 3 is in _maintenance mode_/EOL, and ideally it should be using zod 4 at least, *however* Google does *not* have zod v4 available internally yet, so we still need to support zod3.
Similarly to what we did with `@a2ui/markdown-it` (where internal google wants a different rendering library), we should abstract our dependency on Zod by some interface that can be implemented using different libraries; zod3 (to preserve compatibility with existing code), zod4 (for future-proofing), and others... ideally simultaneously to allow for different catalog implementers to select their own.
---
## Describe the Proposed Solution
### Core interface design (`A2uiSchema`)
We replace `z.ZodTypeAny` across `@a2ui/web_core` with `A2uiSchema`.
#### Types
```typescript
/**
* Result of a validation operation, modeled as a TypeScript discriminated union.
* - When `success` is true, `.data` is strongly typed as `T`.
* - When `success` is false, `.errors` contains validation error messages or issues.
*/
export type ValidationResult =
| { success: true; data: T }
| { success: false; errors: string[] | unknown };
/**
* Framework-agnostic schema interface for validating data and generating JSON schemas.
*/
export interface A2uiSchema {
/** Phantom type marker for compile-time TypeScript inference. */
readonly _type?: T;
/**
* Human-readable description of the schema.
* Used by `MessageProcessor.generateInlineCatalog()` to populate LLM function descriptions
* in `InlineCatalog` client capability reports sent to the server.
*/
readonly description?: string;
/** Validates data without throwing an exception. */
validate(data: unknown): ValidationResult;
/** Converts this schema into a standard JSON Schema object for InlineCatalog reporting. */
toJsonSchema(): Record;
}
/** Extracts the inferred TypeScript type from an A2uiSchema at compile time. */
export type InferSchemaType =
S extends A2uiSchema ? T : never;
```
#### Catalog definitions
```typescript
/**
* Definition of a UI component's API contract in an A2UI catalog.
* Defines the component's name and property schema independent of any rendering implementation.
*
* @template Schema - The A2uiSchema type describing the component's properties.
*/
export interface ComponentApi {
/** The unique name of the component as it appears in A2UI JSON (e.g., 'Button'). */
name: string;
/** The schema describing and validating this component's properties. */
readonly schema: Schema;
}
/**
* Type helper that extracts the static TypeScript property interface from a ComponentApi.
* Used by renderers and catalogs to access component property types with compile-time safety.
*/
export type InferredComponentApiSchemaType =
Api extends ComponentApi ? InferSchemaType : never;
/**
* Definition of a callable function's API contract in an A2UI catalog.
*/
export interface FunctionApi {
/** The unique name of the function (e.g., 'openUrl'). */
readonly name: string;
/** The expected return type category ('string', 'number', 'boolean', 'object', etc.). */
readonly returnType: A2uiReturnType;
/** The schema describing and validating the function's parameter arguments. */
readonly schema: A2uiSchema;
}
```
---
### How `@a2ui/web_core` connects to renderers: Static props typing & runtime dataflow
Client renderers (`@a2ui/angular`, `@a2ui/react`, `@a2ui/lit`) connect to `@a2ui/web_core` at two distinct levels: **compile-time static props inference** and **runtime validated property dataflow**.
#### 1. Compile-time static props inference (`InferredComponentApiSchemaType`)
All three client renderers deduce the static TypeScript interface of a component's properties using `InferredComponentApiSchemaType` from `@a2ui/web_core`.
Under the new architecture, `InferredComponentApiSchemaType` inspects `Api['schema']['_type']` at compile time and evaluates to the underlying TypeScript interface `T`:
- **React (`@a2ui/react`):**
React components define their prop types using `ResolveA2uiProps>`. With `A2uiSchema`, this automatically resolves to `T` with zero code changes:
```tsx
// adapter.tsx in React renderer
type Props = ResolveA2uiProps>;
```
- **Lit (`@a2ui/lit`):**
Lit components define their controller and host element types using `InferredComponentApiSchemaType`. With `A2uiSchema`, `this.props` is automatically typed as `T`:
```typescript
// a2ui-controller.ts in Lit renderer
public props: ResolveA2uiProps>;
```
- **Angular (`@a2ui/angular`):**
Angular's `ComponentApiToProps` utility currently uses `z.infer`. We update Angular's `core/types.ts` to import `InferredComponentApiSchemaType` from `@a2ui/web_core`:
```diff
-export type ComponentApiToProps = InferredInterfaceToProps<
- ExtendedProps>
->;
+export type ComponentApiToProps = InferredInterfaceToProps<
+ ExtendedProps>
+>;
```
With this one-line diff, every Angular component (`TextComponent`, `ButtonComponent`, etc.) extending `BasicCatalogComponent` receives `this.props()` typed as `ComponentApiToProps`, where each property is wrapped in an Angular Signal (`BoundProperty`) with **100% static compile-time safety** and zero Zod imports.
#### 2. Runtime validated property dataflow
At runtime, properties flow from the server to the actual renderer render methods in a 4-step pipeline:
```mermaid
flowchart LR
A[Server JSON Message] --> B[MessageProcessor]
B -->|api.schema.validate| C[ComponentModel.properties]
C --> D[Renderer Binders]
D -->|Props / Signals| E[UI Render Methods]
```
1. **Payload Arrival (`MessageProcessor`):**
An `UpdateComponentsMessage` arrives in `@a2ui/web_core`'s `MessageProcessor` containing unvalidated JSON properties (`rawProperties`).
2. **Schema Validation (`A2uiSchema.validate`):**
`MessageProcessor` calls `componentApi.schema.validate(rawProperties)`. Whether the schema under the hood is Zod 3, Zod 4, Valibot, or static JSON Schema, it validates the data and returns `ValidationResult`.
3. **Model Update (`ComponentModel`):**
`MessageProcessor` assigns `validationResult.data` (now safely typed as `T`) to `ComponentModel.properties`.
4. **Renderer Binding & Rendering:**
- **React:** `GenericBinder` reads `ComponentModel.properties` (type `T`), resolves data-binding expressions against `DataContext`, and passes the resolved `props` object into ``.
- **Lit:** `A2uiController` reads `ComponentModel.properties` (type `T`), resolves data-bindings via `GenericBinder`, and triggers Lit's reactive render cycle with `this.props`.
- **Angular:** `ComponentBinder` reads `ComponentModel.properties` (type `T`), updates the corresponding reactive Angular Signals (`BoundProperty`), and triggers `OnPush` change detection in `CatalogComponent`.
---
### Runtime validation & multi-catalog interoperability
Catalogs using different schema libraries (Zod 3, Zod 4, Valibot, or static JSON Schema) can interoperate within the same application:
```tsx
```
#### Validating mixed-catalog responses in `MessageProcessor`
When an `UpdateComponentsMessage` arrives containing components from multiple catalogs, `MessageProcessor` validates each component independently against its respective catalog schema:
```typescript
for (const instance of message.components) {
const [componentType, rawProperties] = Object.entries(instance.component)[0];
const componentApi = surface.catalog.components.get(componentType);
if (!componentApi) {
throw new A2uiValidationError(`Unknown component type '${componentType}'`);
}
// Validate against the component's specific schema
const validationResult = componentApi.schema.validate(rawProperties);
// (Optionally) Throw on error when parsing
if (!validationResult.success) {
throw new A2uiValidationError(
`Validation failed for '${componentType}'`,
validationResult.errors,
);
}
// ValidationResult.data is of known type T
surfaceModel.setComponentProperties(instance.id, componentType, validationResult.data);
}
```
---
### Basic catalog schemas
Currently, the basic catalog uses zod3. In order to keep `web_core` free of dependencies on `zod`, we can:
* Separate the `basic_catalog` to a separate package with the dependencies that it needs (preferred)
* Implement a JSON Schema by hand with a simple validation
#### (Optional) Static JSON-Schema basic catalog
`basic_catalog` components could use static `A2uiSchema` objects backed by prebuilt JSON Schema definitions and a noop validator:
```typescript
export function createStaticSchema(
jsonSchema: Record,
validator?: (data: unknown) => ValidationResult,
): A2uiSchema {
return {
validate: validator ?? ((data) => ({ success: true, data: data as T })),
toJsonSchema: () => jsonSchema,
};
}
```
---
### Adapter packages (`@a2ui/schema-zod3` & future `@a2ui/schema-zod4`)
Converting `ComponentApi.schema` from `z.ZodTypeAny` to `A2uiSchema` is a breaking change for custom catalog authors.
- **`@a2ui/schema-zod3` MUST be vended simultaneously** with the core release so existing Zod 3 users have an immediate drop-in migration path (`zod3Schema(...)`).
- **`@a2ui/schema-zod4` is optional** and can be added in a later release as Zod 4 adoption grows, or as a sample for others' to implement their own schema adapters.
#### Zod 3 adapter (`@a2ui/schema-zod3`)
Implements A2uiSchema using `zod/v3` and `zod-to-json-schema`.
```typescript
import type {z} from 'zod/v3';
import {zodToJsonSchema} from 'zod-to-json-schema';
import type {A2uiSchema, ValidationResult} from '@a2ui/web_core';
export class Zod3Schema implements A2uiSchema {
constructor(private readonly zodSchema: z.ZodType) {}
get description(): string | undefined {
return this.zodSchema.description;
}
validate(data: unknown): ValidationResult {
const result = this.zodSchema.safeParse(data);
if (result.success) {
return {success: true, data: result.data};
}
return {
success: false,
errors: result.error.errors.map(e => `${e.path.join('.')}: ${e.message}`),
};
}
toJsonSchema(): Record {
return zodToJsonSchema(this.zodSchema, {
target: 'jsonSchema2019-09',
}) as Record;
}
}
/** Wraps a Zod 3 schema into an A2uiSchema. */
export function zod3Schema(schema: z.ZodType): A2uiSchema {
return new Zod3Schema(schema);
}
```
#### Custom catalog authoring example
This is how a component API would use the `zod3Schema` builder:
```typescript
import {z} from 'zod';
import {zod3Schema} from '@a2ui/schema-zod3';
import type {ComponentApi} from '@a2ui/web_core';
export const CustomCardApi: ComponentApi = {
name: 'CustomCard',
schema: zod3Schema(
z.object({
title: z.string(),
elevated: z.boolean().optional(),
}),
),
};
```
---
## Describe Alternatives Considered
I've tried supporting both zod3 and zod4 by broadening the dependency that we have on zod, but we end up needing some _unsavory_ code to differentiate between zod3 and 4 at runtime.
* See: #2135
Guida per i contributori
Apri la guida per i contributori
Valutazione
Questa issue non è ancora stata valutata.