T.recursive
A structure: a schema that references itself — a tree, a linked structure, or a generic JSON value.
const CategorySchema = T.recursive((self) =>
T.object({
name: T.string.whereby({ length: { min: 3, max: 20 } }),
children: T.array(self).whereby({ length: { max: 3 } }),
}),
).whereby({ depth: { max: 3 } });body is handed a self placeholder standing for "recurse here." The value type — children: CategorySchema[], in the example above — is inferred with no annotation, to whatever depth is actually reached.
.whereby({ depth }) is required, unlike most primitives' .whereby() — a recursive schema with no bound would never terminate. At depth.max, fabrication swaps in a terminal schema in place of body. That terminal is derived from body when you omit it: every self behind a kind that can stop recursing is rewritten into a stop — an empty array or record, the remaining non-self arms of a T.choice, T.null / T.undefined for T.nullable / T.undefinable / T.nullish, or an omitted key for T.omittable / T.optional. A required self (an object field, a tuple slot, or the body itself) cannot be derived — .whereby() throws, and terminal is the way out.
terminal is optional, and when given it replaces that derivation wholesale — a custom leaf value, or a JSON choice narrowed to one arm — rather than patching self sites inside body.
depth reads { max } only, not { min, max } — deliberately. T.array / T.record / T.string actually draw a count, so a min is a floor on that draw. Recursive expansion does not: depth.max is a ceiling on an emergent quantity. How deep any given fabricate() goes is decided by whichever kinds sit between self occurrences declining to recurse — an array or record rolling a count of 0, a choice picking a non-self option, an omittable / optional / undefinable resolving absent. The recursive node has no draw of its own to floor. Forcing body while depth is below some min is already what happens below max. A genuine floor would mean coercing those intervening kinds' draws, which is a different feature than a second number here. The derived terminal may empty a collection whose body said length.min > 0: the ceiling has to stop somehow.
A doubly-recursive body (two fields, each self) costs 2^depth in the worst case — keep depth.max small, 3–4, unless you have a specific reason not to.
