rtti/ts — TypeScript type inference for RTTI schemas

Ts<T> (module.f.mjs) maps an RTTI schema Type to its TypeScript type at compile time. It works by walking the schema's structural shape with a chain of conditional types and infer.

The problem: TS2589

TypeScript imposes a hard recursion depth on conditional-type evaluation. Ts<T> is inherently recursive — Ts<array(T)> expands to readonly Ts<T>[], which recurses into T. For acyclic schemas this terminates. Two cases make it overflow:

  • Ts<any> distribution. When an internal generic widens to any (common at module boundaries and in visitor dispatch), TypeScript distributes the conditional over every branch simultaneously, exhausting the limit immediately.

  • Ts<Type> dispatch. Calling validate(r) or parse(t) with r/t typed as Type forces TypeScript to evaluate Ts<Type>, which distributes across all branches and triggers TS2589 the same way.

Both cases produce error TS2589: Type instantiation is excessively deep and possibly infinite.

A fast path for any

export type Ts<T extends Type> =
    unknown extends T ? Unknown :   // ← the fast path
    ...

unknown extends any is true, so when T is any the conditional short-circuits to Unknown before distribution begins. This prevents the largest class of accidental overflows without touching the rest of Ts<T>.

A WithOut phantom output type

For recursive schemas (e.g. the JSON Schema unknown type in fjs/media/json/schema/module.f.mjs) even the non-any walk overflows because the schema references itself. The fix is to annotate the schema with its output type once, at construction time, and have Ts<T> read that annotation directly — one indexed-access, no structural walk.

declare const withOutKey: unique symbol

export type WithOut<S, Out> = S & { readonly [withOutKey]?: Out }

export type Ts<T extends Type> =
    unknown extends T ? Unknown :
    T extends { readonly [withOutKey]?: infer O } ? Exclude<O, undefined> :  // ← the annotation
    ...

withOutKey is a unique symbol, not a string. This is intentional: struct schemas have a string index signature ({ readonly [K in string]: Type }), and a string key $out would have to extend Type. A symbol key is excluded from string index signatures, so WithOut<S, Out> is valid for any Out regardless of whether Out extends Type.

Usage (abbreviated from fjs/media/json/schema/module.f.mjs):

const unknownThunk = () => ['const', unknownConst] as const
export const unknown: WithOut<typeof unknownThunk, UnknownConst> = unknownThunk

// unknownConst is defined after `unknown` so it can reference `unknown` recursively.
// The thunk defers evaluation, breaking the circular reference at runtime.
const unknownConst = {
    not: or(option, unknown),
    anyOf: or(option, array(unknown)),
    // ...
} as const

// UnknownConst derives each field type from the schema — no hand-written duplicates.
// The `?` markers are required: TypeScript distinguishes "field absent" from
// "field present but undefined", and JSON Schema objects only include the fields
// they need.
type UnknownConst = {
    readonly not?: Ts<typeof unknownConst.not>
    readonly anyOf?: Ts<typeof unknownConst.anyOf>
    // ...
}

Ts<typeof unknown> reads withOutKey, returns UnknownConst, never recurses into unknownConst's body.

Why named aliases failed

Splitting Ts<T> into named aliases (InfoTs<I>, OrTs<A>, …) was attempted on the theory that each alias acts as a memoization point for the compiler (the ArkType approach). It made things worse. The extra indirection (Ts → InfoTs → Info1Ts → ArrayTs → Ts) adds levels to the recursive chain, so TypeScript hits its depth limit sooner. The inline form is flatter and lets the compiler short-circuit earlier. Named-alias memoization only helps for acyclic type graphs; it does not help when the recursion is inherent in the schema structure. The split was reverted.

How RestTs renders a rest

RestTs renders rest(c, r)'s tuple tail as ...(Ts<r> | undefined)[], and an empty rest as no tail, since rest(c, or()) is the bare c: one set, so one rendering (open containers).

What counts as empty is a semantic question, a toData conversion compared with the bare container's by equivalent in data/module.f.mjs, which types.ts cannot invoke. So RestTs recognizes the one directly spellable empty rest, or(), and keeps the tail whenever it cannot tell. A kept tail is wider than the schema but sound, which is the direction a success cast needs. The runtime printer goes through the data form and recognizes the rest semantically, so the two differ on rest([42], [or()]): the printer drops the tail there and Ts<> does not.

Remaining open problems

The as any casts in parse/module.f.mjs cannot be removed without language features TypeScript does not yet have:

Problem A — visitor rank-2 erasure. Visitor<R> requires a uniform R across all handlers, but each handler has a precise generic type (e.g. <I extends Type>(item: I): Validate<Info1<K,I>>). TypeScript has no rank-2 polymorphism, so the visitor record must be assembled with as unknown as Visitor<...> and the top-level validate/parse return cast as any. Would require inlining dispatch (like toJsonSchema does) to eliminate.

Problem B — container validation narrowing. After verifying every element of a container, ok(value) is returned where value: Container<K>. TypeScript cannot narrow the container's element types through a validation loop; it lacks dependent types or refinement for mutable iteration. Removing the ok(value) as any cast would require constructing a fresh typed copy (extra allocation, contradicts validate's return-original-value contract).

Problem C — Ts<Type> in or/record dispatch. Calling validate(r) or parse(t) with r/t typed as Type forces TypeScript to instantiate Ts<Type> and trigger TS2589. The visitor architecture currently widens the element type to Type; keeping it as a concrete generic would require a fundamentally different dispatch strategy. Cast is (i as any)(value) / (parse as any)(t).

One day, TypeScript may have more powerful type system features (rank-2 polymorphism, dependent types, or deeper memoization) that would make these casts unnecessary.

Emergent Testing

Idle. Press Run to start the suite.

  • ./proof.f.mjs