--- status: stable last_updated: 2026-05-18 --- # Schema TypeBox Module, TypeScript types, type guards, and the design decisions behind them. ## Overview The UJSX schema defines the shape of the universal tree: `UNode`, `UElement`, `URoot`, and `UPrimitive`. It serves two purposes: 1. **Runtime validation and JSON Schema export** via a TypeBox `Module` — consumed by `Value.Check()` and exportable for consumers that need schema-based contracts. 2. **Static TypeScript types** for clean compiler inference — defined directly, not derived from the TypeBox schema, because `ComponentFn` and function-typed `PropValue` entries are runtime-only and not serializable. This dual-source approach is intentional. The TypeBox Module handles validation and serialization. The TypeScript types handle ergonomics. They stay in sync through tests, not through derivation. ## TypeBox Module The `UJSX` constant is a `Type.Module` that defines six interrelated schemas: ```typescript export const UJSX = Type.Module({ UPrimitive: Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]), PropValue: Type.Union([ Type.String(), Type.Number(), Type.Boolean(), Type.Null(), Type.Array(Type.Unknown()), Type.Ref("UNode"), Type.Record(Type.String(), Type.Unknown()), Type.Function([...Type.Rest(Type.Array(Type.Unknown()))], Type.Unknown()), ]), UniversalProps: Type.Object( {}, { additionalProperties: Type.Union([Type.Ref("PropValue"), Type.Undefined()]) }, ), UElement: Type.Object({ type: Type.String(), props: Type.Ref("UniversalProps"), children: Type.Array(Type.Ref("UNode")), }), URoot: Type.Object({ type: Type.Literal("root"), props: Type.Ref("UniversalProps"), children: Type.Array(Type.Ref("UNode")), }), UNode: Type.Union([Type.Ref("UPrimitive"), Type.Ref("UElement"), Type.Ref("URoot")]), }); ``` ### Design decisions in the Module - **`UPrimitive`** is `string | number | boolean | null`. No `undefined` — `undefined` is JavaScript's "absent" and should not appear as a tree node value. - **`PropValue`** includes `Type.Function(...)`. This means a `PropValue` can be a function (event handlers, component references). The trade-off: props are **not fully serializable**. This is by design — hosts that need serialization strip functions at their boundary. - **`UniversalProps`** is an open object (`additionalProperties` allowed). Different hosts need different prop shapes. A DOM host needs `className`; a workflow host needs `operationId`. Constraining props to a closed schema would force hosts to extend UJSX's schema, inverting the dependency. The open schema lets hosts define their own prop contracts. - **`UElement.type`** is `Type.String()`, not a union of known element types. Element types are host-defined — UJSX does not maintain a registry of valid type strings. - **`URoot`** uses `Type.Literal("root")` as its `type` field. This makes `URoot` a discriminated union member, distinguishable from `UElement` at runtime and in TypeBox validation. - **`UNode`** is the union `UPrimitive | UElement | URoot`. This is the fundamental tree node type — every value in a UJSX tree is a `UNode`. ### Re-export ```typescript export { UJSX as schema }; ``` The Module is re-exported as `schema` for consumer convenience. Call sites use `UJSX.Import("UElement")` etc. with `Value.Check` for runtime validation. ## TypeScript Types The TypeScript types are defined directly, not via `Static`, because `ComponentFn` is a runtime function type that doesn't serialize. Deriving from the TypeBox Module would either omit functions (breaking the type) or include non-serializable types in the schema (breaking validation). ```typescript export type UPrimitive = string | number | boolean | null; export type PropValue = | string | number | boolean | null | unknown[] | UNode | Record | ((...args: unknown[]) => unknown); export type UniversalProps = Record; export type UElement = { type: string; props: UniversalProps; children: UNode[]; key?: string; // TODO: Not yet implemented. See ADR-004 and reconciler.md }; export type URoot = { type: "root"; props: UniversalProps; children: UNode[]; }; export type UNode = UPrimitive | UElement | URoot; ``` ### Component types ```typescript export type ComponentFn = (props: UniversalProps & { children?: UNode[] }) => UNode; export type UType = string | ComponentFn; export interface UComponent

{ (props: P & { children?: UNode[] }): UNode; displayName?: string; targets?: string[]; } ``` - **`ComponentFn`** is the type of a function that accepts props (with optional children) and returns a `UNode`. This is the universal component contract. - **`UType`** is the union of string element types and component functions — used as the first argument to `h()`. - **`UComponent`** adds optional `displayName` (for debugging) and `targets` (for host-specific component routing). It extends `ComponentFn` with metadata. `ComponentFn` and `UType` have no TypeBox representation. They are TypeScript-only. This is why the types are hand-written rather than derived from the schema. ## Type Guards Three type guards narrow `UNode` at runtime: ```typescript function isUElement(node: UNode): node is UElement function isURoot(node: UNode): node is URoot function isUPrimitive(node: UNode): node is UPrimitive ``` ### Discriminators | Guard | Logic | Discriminator | |-------|-------|---------------| | `isUElement` | `typeof node === "object" && node !== null && "type" in node && "props" in node && "children" in node && node.type !== "root"` | Has `type`/`props`/`children` keys, is not null, and `type` is not `"root"` | | `isURoot` | `typeof === "object" && "type" in node && node.type === "root"` | `type === "root"` | | `isUPrimitive` | `typeof === "string" \|\| typeof === "number" \|\| typeof === "boolean" \|\| node === null` | Not an object | The `isUElement` guard excludes `URoot` by checking `type !== "root"`. Without this exclusion, `URoot` nodes would match `isUElement` because they have the same structural fields (`type`, `props`, `children`). The `"root"` literal type discriminates them. ## Known Gaps ### `key` field on `UElement` `UElement` currently has no `key` field. The reconciler needs an identity mechanism to match old children to new children across re-renders. Without `key`, reconciliation is positional-only — the Nth child of the old tree maps to the Nth child of the new tree, which breaks when children are reordered, inserted, or removed. The reconciler architecture (see [reconciler.md](reconciler.md) and [ADR-004](decisions/004-key-as-first-class-field.md)) specifies adding `key?: string` to `UElement` as a first-class field. `h()` extracts `key` from props and promotes it to the element level, so component functions never receive it. `URoot` does not get `key` — roots are unique per `createRoot()` call and never need reconciliation identity. **Status**: Architecture specified, not yet implemented. ## Constraints - **Props are not fully serializable** — `PropValue` includes functions. Hosts that need serialization must strip function-valued props at their boundary. This is by design: event handlers and component references are first-class prop values. - **UniversalProps is open** — `additionalProperties` allows any key. This prevents UJSX from being a prop gatekeeper and lets hosts define their own contracts without extending UJSX's schema. - **TypeScript types are authoritative for type inference** — the TypeBox Module is for runtime validation and JSON Schema export only. Do not use `Static` as the source of truth for TypeScript types; the hand-written types include `ComponentFn` and function-typed `PropValue` entries that the schema cannot express cleanly. - **`key` not yet on `UElement`** — `key?: string` is planned (ADR-004) but not yet implemented. Until then, reconciliation is positional-only. - **No `key` on `URoot`** — roots are identified by `props.id`, not a `key` field. This is by design; roots are never children of another element. - **Type guards are mutually exclusive** — `isUElement`, `isURoot`, and `isUPrimitive` partition the `UNode` space. Every `UNode` matches exactly one guard. ## Open Questions 1. **Should `key` accept numbers?** React coerces number keys to strings. The current proposal enforces `string` only — simpler, no implicit coercion. Users can wrap in `String()`. See [ADR-004](decisions/004-key-as-first-class-field.md) and research: [00-KEY-FIELD-DESIGN.md](../research/reconciler/00-KEY-FIELD-DESIGN.md). 2. **Should `UPrimitive` include `undefined`?** Currently `null` represents an explicitly empty value. `undefined` means "absent" and should not appear as a tree node. This is consistent with how JSX treats `undefined` children (rendered to nothing), but some hosts might benefit from an explicit "missing" sentinel. No current use case justifies this. 3. **Should `UniversalProps` constrain value types per-host?** The open schema allows any `PropValue` for any key. A host that wants stricter prop contracts (e.g., `onClick` must be a function, `className` must be a string) must validate at its own boundary. A future host-typed props system could be layered on top without changing the base schema. ## References - Source: `src/core/schema.ts` - Key field ADR: [decisions/004-key-as-first-class-field.md](decisions/004-key-as-first-class-field.md) - Reconciler architecture: [reconciler.md](reconciler.md) - Key field design research: `../research/reconciler/00-KEY-FIELD-DESIGN.md` - TypeBox Module as type registry: [decisions/002-typebox-module-as-registry.md](decisions/002-typebox-module-as-registry.md) - HTML-agnostic core: [decisions/001-html-agnostic-core.md](decisions/001-html-agnostic-core.md)