Files
ujsx/docs/architecture/schema.md
glm-5.1 09f32f0c64 add architecture docs synced to current source and sdd process
Phase 1 of SDD process: syncing docs/architecture/ to reflect the
existing source code. Eight component documents describe WHAT and WHY
(not HOW) for each module: schema, element factory, reactive layer,
host config, transforms, events, pointers, and build distribution.
Three ADRs capture key decisions (HTML-agnostic core, TypeBox Module
as type registry, Preact signals-core for reactivity). Each doc
documents known reconciler gaps and references the research in
docs/research/reconciler/.

Also adds docs/sdd_process.md (process reference shared across
alkdev projects) matching the taskgraph_ts pattern.
2026-05-18 15:00:33 +00:00

9.3 KiB

status, last_updated
status last_updated
draft 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:

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 undefinedundefined 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

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[];
};
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[];
}
  • 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:

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 === "object" && "type" in node && "props" in node && "children" in node && node.type !== "root" Has type/props/children keys 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 research (docs/research/reconciler/00-KEY-FIELD-DESIGN.md) proposes adding key?: string to UElement as a first-class field. h() would extract key from props and promote 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: Research complete, not yet implemented.

Constraints

  • Props are not fully serializablePropValue 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 openadditionalProperties 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.
  • No key on UElement — see Known Gaps above. Positional reconciliation only until key is added.
  • 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 exclusiveisUElement, 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 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 design: docs/research/reconciler/00-KEY-FIELD-DESIGN.md
  • TypeBox Module as type registry: docs/architecture/decisions/002-typebox-module-as-registry.md
  • HTML-agnostic core: docs/architecture/decisions/001-html-agnostic-core.md