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:
- Resolves
Type.Ref("X")→ dereferences to the actual type atmoduleProperties[X] - Recursively processes:
Type.Array(Type.Ref("Node"))→Type.Array(ResolvedNode) - Handles all TypeBox combinators: Object, Union, Intersect, Function, Record, etc.
- Adds
$idto each definition viaWithIdentifiers
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
-
No separate type registry needed.
TModuleIS the registry. It's a map ofstring → TSchemawith computed resolution and$idassignment built in. -
Schemas ARE types AND ARE tool parameter schemas. One definition creates the TypeScript type, the runtime validator, and the JSON Schema for tool definitions.
-
Bi-directional transforms can be schema-driven. Transform rules can match on
Value.Check(ruleSchema, node)instead of string tag matching. -
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. -
Function components are first-class in the schema.
Type.Function([Type.Ref("Props")], Type.Ref("Node"))validates thattypeis a function at runtime. For serialization, we'd resolve function components to string tags before going to mdast/other targets. -
Importcreates self-contained schemas. Perfect for sending to tool definitions or validation contexts where the full module isn't available. -
The
ComponentFnpattern means we can separate runtime and serializable representations. At runtime,typecan be a function. Before serialization (JSON, mdast), we resolve function components to string identifiers and emit{ type: "Component", componentId: "TaskSection", ... }.