Files
ujsx/docs/research/typebox-module-type-registry.md

5.3 KiB

TypeBox Module Research: UJSX Schema Implications

Key Finding: Type.Module IS the Type Registry

Type.Module creates a TModule object whose $defs property is a live string → TSchema map. This is exactly the "type registry" pattern needed for UJSX.

Core Operations

const UJSX = Type.Module({
  Props: Type.Object({ id: Type.Optional(Type.String()) }, { additionalProperties: Type.Unknown() }),
  Element: Type.Object({
    type: Type.String(),
    props: Type.Ref("Props"),
    children: Type.Array(Type.Ref("Node")),
  }),
  Node: Type.Union([Type.String(), Type.Number(), Type.Null(), Type.Ref("Element")]),
})

Reading schemas by name

ValuePointer.Get(UJSX, "$defs/Props")  // → TSchema with $id: "Props"
ValuePointer.Get(UJSX, "$defs/Element") // → TSchema with $id: "Element"

Adding schemas at runtime

ValuePointer.Set(UJSX, "$defs/Heading", Type.Object({
  type: Type.Literal("heading"),
  depth: Type.Number(),
  children: Type.Array(Type.Ref("Node")),
}))
// Now UJSX.$defs.Heading exists and can be referenced by Type.Ref("Heading")

Importing (resolves $ref, creates self-contained schema for validation)

const Element = UJSX.Import("Element")
// Element = { [Kind]: 'Import', $defs: { Props: {...}, Element: {...}, Node: {...} }, $ref: "Element" }
Value.Check(Element, someData)  // Works! All $refs resolved via inlined $defs

Static type inference

type UE = Static<typeof Element>
// UE = { type: string; props: { id?: string; [k: string]: unknown }; children: UE[] }

How Import Works (from source)

The Import method on TModule (module.ts:70-72):

public Import<Key extends keyof ComputedModuleProperties>(key: Key, options?: SchemaOptions): TImport<ComputedModuleProperties, Key> {
  const $defs = { ...this.$defs, [key]: CreateType(this.$defs[key], options) }
  return CreateType({ [Kind]: 'Import', $defs, $ref: key }) as never
}

It creates a TImport schema with:

  • [Kind]: 'Import' — uniquely identifies this as a module import
  • $defs — ALL module definitions copied in (so $refs resolve)
  • $ref: key — which definition to use as the root

How ComputeModuleProperties Works (from compute.ts)

The constructor calls ComputeModuleProperties which walks every property and:

  1. Resolves Type.Ref("X") → dereferences to the actual type at moduleProperties[X]
  2. Recursively processes: Type.Array(Type.Ref("Node"))Type.Array(ResolvedNode)
  3. Handles all TypeBox combinators: Object, Union, Intersect, Function, Record, etc.
  4. Adds $id to each definition via WithIdentifiers

This means you can use Type.Ref() inside Type.Module() and they get resolved to direct references. The $ref JSON pointer stays in the output for JSON Schema compatibility, but TypeBox's internal resolution handles them.

TInferFromModuleKey — Type-Level Inference

The infer.ts file provides TInferFromModuleKey<ModuleProperties, Key> which is the type-level path of resolving refs. This is what makes Static<typeof ImportedElement> produce a proper recursive type:

type UE = Static<typeof Element>
// Resolves to:
// { type: string; props: { id?: string; [k: string]: unknown }; children: (string | number | null | UE)[] }

This is a recursive type inferred from the module. No Type.Recursive needed because the Module's type-level inference handles cycles through TRef.

Function Types in Modules

Type.Function([...params], returnType) creates a JavaScript-extended schema type:

ComponentFn: Type.Function([Type.Ref("Props")], Type.Ref("Node"))

This produces a JSON Schema with { type: "Function", parameters: [...], returns: {...} } which is NOT standard JSON Schema but IS valid TypeBox. Value.Check validates that the value is a function at runtime. This means we can represent component functions in the schema.

Implications for UJSX v2

  1. No separate type registry needed. TModule IS the registry. It's a map of string → TSchema with computed resolution and $id assignment built in.

  2. Schemas ARE types AND ARE tool parameter schemas. One definition creates the TypeScript type, the runtime validator, and the JSON Schema for tool definitions.

  3. Bi-directional transforms can be schema-driven. Transform rules can match on Value.Check(ruleSchema, node) instead of string tag matching.

  4. Node definitions can be added at runtime. ValuePointer.Set(module, "$defs/CustomNode", ...) or simply defining new entries in the Module. This means plugins can extend the IR.

  5. Function components are first-class in the schema. Type.Function([Type.Ref("Props")], Type.Ref("Node")) validates that type is a function at runtime. For serialization, we'd resolve function components to string tags before going to mdast/other targets.

  6. Import creates self-contained schemas. Perfect for sending to tool definitions or validation contexts where the full module isn't available.

  7. The ComponentFn pattern means we can separate runtime and serializable representations. At runtime, type can be a function. Before serialization (JSON, mdast), we resolve function components to string identifiers and emit { type: "Component", componentId: "TaskSection", ... }.