a2ui-project / a2ui-project/a2ui
[BUG]: GenericBinder generates an uncallable setter for a binding-only property
- Vorherrschende Sprache
- TypeScript
- Sterne
- 16.4k
- Forks
- 1.3k
- Ø Merge
- 2 T. 13 Std.
- Gemergte PRs (30 T.)
- 134
Beschreibung
- [x] I have searched the existing issues to make sure this bug has not already been reported.
## Describe the Bug
### Background
`@a2ui/web_core`'s `GenericBinder` (`renderers/web_core/src/v0_9/rendering/generic-binder.ts`) is
the engine that turns a component's raw A2UI JSON config plus its Zod schema into the resolved,
strongly typed props a renderer hands to a component implementation. The React and Lit renderers
both build their component props on it (Angular does not, since it rolls its own binding).
Two exported utility types in that file describe the result:
- `ResolveA2uiProp` types what a component **reads**: an `Action` becomes `() => void`, a
`DynamicString` becomes `string`, and so on.
- `GenerateSetters` types what a component **writes**: for a `value: DynamicString` property
it generates a `setValue(v: string)` method for two-way binding.
Both start from the same idea. A dynamic property is declared as a union of literal branches plus
the binding shapes, for example `DynamicString = string | DataBinding | FunctionCall`. To find the
resolved type, both subtract the binding shapes: `Exclude`.
### The bug
A property can be declared with **only** binding branches and no literal one, for example
`z.union([DataBindingSchema, FunctionCallSchema])`. For such a property the subtraction removes
everything and leaves nothing, and the two types disagree about what to do next.
`ResolveA2uiProp` guards that case and falls back to `any` (lines 175-177):
```ts
: Exclude extends never
? any
: Exclude;
```
`GenerateSetters` has no equivalent guard (same file, line 185):
```ts
value: Exclude, DynamicTypes>,
```
So for a property declared as `DataBinding | FunctionCall` the read side falls back to `any`,
while the write side resolves to `(value: never) => void`. `never` is uninhabited, so no value
can be passed and the generated setter cannot be called at all. The asymmetry is the whole
defect: the read path has a fallback and the write path was never given one.
No component in the basic catalog declares such a property, and `DataBinding` is absent from
`COMMON_TYPE_SCHEMAS` in `src/v0_9/catalog/schema_loader.ts`, so JSON-defined catalogs cannot
declare one either. The case is reachable from hand-written Zod component schemas, which is
presumably why it has gone unnoticed.
## Steps to Reproduce
1. Declare a component whose schema has a property with only binding branches.
2. Read the generated setter's parameter type.
```ts
import {z} from 'zod';
import {DataBindingSchema, FunctionCallSchema} from '@a2ui/web_core/v0_9';
import type {ResolveA2uiProps} from '@a2ui/web_core/v0_9';
const BindingOnly = z.object({sort: z.union([DataBindingSchema, FunctionCallSchema])});
declare const props: ResolveA2uiProps>;
props.sort; // any - the read-side fallback applies
props.setSort; // (value: never) => void - uncallable
props.setSort({path: '/sortOrder'});
```
3. Run `tsc --noEmit`. The call on the last line is rejected.
## Expected Behavior
The generated setter is callable. The read side already decides what to do when the subtraction
leaves nothing; the write side should make the same decision rather than producing a parameter
no value can satisfy.
## Screenshots / Video / Logs
```
error TS2345: Argument of type '{ path: string; }' is not assignable to parameter of type 'never'.
```
## Environment Details
- **OS**: macOS 15
- **Browser/Platform**: Node.js, TypeScript 5.9.3 (compile-time only, no runtime behaviour involved)
- **SDK/Package Name & Version**: `@a2ui/web_core` 0.10.7 (reproduced against `main` @ `0dc4a30f`)
- **Protocol Version**: v0.9, v0.9.1
- **Agent Framework & LLM Model**: n/a
## Additional Context
**Suggested fix** - give the write side the fallback the read side already has:
```ts
value: [Exclude, DynamicTypes>] extends [never]
? unknown
: Exclude, DynamicTypes>,
```
`unknown` rather than `any`: it accepts every argument while keeping the value checked at the
call site, and does not add to the `any` count tracked in #1296.
This is non-breaking. A parameter of type `never` admits no argument today, so widening it can
only permit calls that previously failed to compile; no existing call site can change meaning.
Beitragsleitfaden
Bewertung
Dieses Issue wurde noch nicht bewertet.