Files
ujsx/docs/architecture/schema.md
glm-5.1 0d5b9d5ea8 stabilize architecture docs: address review findings and advance to stable
Critical fixes:
- Restructure pointers.md: move setNode prop-key writes section under
  its own heading (was incorrectly nested under selectNode)
- Add Context/Density/Direction/RenderContext documentation section
  to host-config.md (was only a brief constraint bullet)
- Advance all 5 ADRs from Status: Proposed → Accepted and frontmatter
  from status: draft → status: stable (decisions are driving implementation)
- Add error handling philosophy section to README

Warning/suggestion fixes:
- Add isUElement null check (node !== null) to schema.md discriminator table
- Add UjsxEnvelope convenience type documentation to events.md
- Add Direction Unicode arrow naming note to transforms.md
- Standardize all cross-references from absolute docs/research/ paths
  to relative ../research/ paths across all architecture docs
- Fix schema.md ADR references to use relative paths
- Reduce redundancy between transforms.md and host-config.md Direction notes
- Update all architecture doc frontmatter from draft → stable

Deferred:
- Performance model section (reconciler not yet built)
- Concepts/glossary document (low ROI at current scale)
- Line counts in source references (would date quickly)
2026-05-18 16:10:24 +00:00

165 lines
9.7 KiB
Markdown

---
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<typeof UJSX>`, 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<string, unknown>
| ((...args: unknown[]) => unknown);
export type UniversalProps = Record<string, PropValue | undefined>;
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<P extends UniversalProps = UniversalProps> {
(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<typeof UJSX>` 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)