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)
9.7 KiB
status, last_updated
| status | last_updated |
|---|---|
| stable | 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:
- Runtime validation and JSON Schema export via a TypeBox
Module— consumed byValue.Check()and exportable for consumers that need schema-based contracts. - Static TypeScript types for clean compiler inference — defined directly, not derived from the TypeBox schema, because
ComponentFnand function-typedPropValueentries 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:
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
UPrimitiveisstring | number | boolean | null. Noundefined—undefinedis JavaScript's "absent" and should not appear as a tree node value.PropValueincludesType.Function(...). This means aPropValuecan 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.UniversalPropsis an open object (additionalPropertiesallowed). Different hosts need different prop shapes. A DOM host needsclassName; a workflow host needsoperationId. 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.typeisType.String(), not a union of known element types. Element types are host-defined — UJSX does not maintain a registry of valid type strings.URootusesType.Literal("root")as itstypefield. This makesURoota discriminated union member, distinguishable fromUElementat runtime and in TypeBox validation.UNodeis the unionUPrimitive | UElement | URoot. This is the fundamental tree node type — every value in a UJSX tree is aUNode.
Re-export
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).
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
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[];
}
ComponentFnis the type of a function that accepts props (with optional children) and returns aUNode. This is the universal component contract.UTypeis the union of string element types and component functions — used as the first argument toh().UComponentadds optionaldisplayName(for debugging) andtargets(for host-specific component routing). It extendsComponentFnwith 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:
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 and ADR-004) 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 —
PropValueincludes 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 —
additionalPropertiesallows 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 includeComponentFnand function-typedPropValueentries that the schema cannot express cleanly. keynot yet onUElement—key?: stringis planned (ADR-004) but not yet implemented. Until then, reconciliation is positional-only.- No
keyonURoot— roots are identified byprops.id, not akeyfield. This is by design; roots are never children of another element. - Type guards are mutually exclusive —
isUElement,isURoot, andisUPrimitivepartition theUNodespace. EveryUNodematches exactly one guard.
Open Questions
- Should
keyaccept numbers? React coerces number keys to strings. The current proposal enforcesstringonly — simpler, no implicit coercion. Users can wrap inString(). See ADR-004 and research: 00-KEY-FIELD-DESIGN.md. - Should
UPrimitiveincludeundefined? Currentlynullrepresents an explicitly empty value.undefinedmeans "absent" and should not appear as a tree node. This is consistent with how JSX treatsundefinedchildren (rendered to nothing), but some hosts might benefit from an explicit "missing" sentinel. No current use case justifies this. - Should
UniversalPropsconstrain value types per-host? The open schema allows anyPropValuefor any key. A host that wants stricter prop contracts (e.g.,onClickmust be a function,classNamemust 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
- Reconciler architecture: 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
- HTML-agnostic core: decisions/001-html-agnostic-core.md