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.
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 toany(common at module boundaries and in visitor dispatch), TypeScript distributes the conditional over every branch simultaneously, exhausting the limit immediately. -
Ts<Type>dispatch. Callingvalidate(r)orparse(t)withr/ttyped asTypeforces TypeScript to evaluateTs<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.
export type Ts<T extends Type> =
unknown extends T ? Unknown : // ← option 1
...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>.
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> : // ← option 3
...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.
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. Option 2 was reverted.
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.