Structured type information — the machine-readable counterpart to the flat
type strings (ParameterJson.type, ComponentPropJson.type, and the
typeSignature fields that carry a typeInfo sibling).
Absence contract: the field holding a TypeJson is absent when the flat
string is the whole story — the type is terminal at the root (an intrinsic,
a plain object literal, a function type, a bare named reference). Present
only when the node carries structure the string can't: union/intersection
members, reference type arguments, enumerable literals; an array or tuple
qualifies when an element does (readonly alone doesn't — the flat string
carries it, except at alias roots where the relaxation emits the tree
regardless). A reference over the *empty* tuple is the one instantiation
that doesn't qualify: Snippet<[]> prints itself in full, so the tree would
wrap nothing (Snippet<[a: string]> still qualifies — its tuple has
elements). Nested nodes inside
a present tree are always populated (they have no flat-string sibling).
One exception, on TypeDeclarationJson for type aliases: the checker prints
an aliased type as its bare alias name, so type StrArr = string[] has
typeSignature: "StrArr" — no flat sibling to defer to. There the tree is
emitted whatever its shape, except for object and function roots, whose
content the declaration's own members already carries. Interned types
(intrinsics, literals) don't carry an alias symbol and print structurally,
so type Str = string is still absent.
Expansion policy: unions and intersections recurse into members; named
references keep name plus recursive typeArgs; arrays carry their
element; tuples carry elements (label, ?/... markers, recursive
type); both mark readonly when written so (readonly Tome[],
ReadonlyArray<Tome>, readonly [a, b]). Everything else is terminal:
object literals, function types, and
unclassified types (type parameters, conditional types, module objects)
carry only
text. Object property maps are deliberately not expanded. Recursion is
depth-capped; nodes past the cap degrade to {kind: 'other', text}.
Alias policy: alias is emitted whenever the checker reports an alias
symbol, root or nested — for a root node it may duplicate the flat string
(type: "ColorScheme" beside alias: "ColorScheme"), which is accepted for
uniformity: nested nodes have no flat sibling, and consumers get one lookup
key (alias on composites, name on references) at any depth. This covers
the composite kinds only. An aliased object type becomes a reference under
its alias name.
Callable classification: callability is the load-bearing renderer
signal, so anything with a call signature is a function node — with one
narrow exception: a *named generic instantiation* (checker
Reference-flagged, symbol-named, carrying type arguments) classifies as a
reference even when callable, so Snippet<[a: string]> is a reference
whose tuple typeArg carries real elements, and an alias over one, nested,
references by the alias name (type MySnippet = Snippet<[string]> in a
union is {kind: 'reference', name: 'MySnippet'}; at its own declaration
root the self-alias skip applies and the Snippet reference shows
through). Bare signatures (() => void), aliased function types, and
anonymous or hybrid callables — non-generic callable interfaces included —
stay function, so their name survives only inside text
(type Handler = (e: Event) => void nested in a union is
{kind: 'function', text: 'Handler'}, which a whole-string type-link lookup
still resolves, but a structural walk can't distinguish from a printed
signature).
Normalization: mirrors the flat strings — the optional-widening
undefined member is dropped from a root union (optional: true carries
it, and an optional tuple element strips the same widening from its type),
and a true | false literal pair collapses to the boolean intrinsic
(the checker expands boolean inside unions). A union reduced to one member
by either rule becomes that member directly, never a 1-member union.
Terminal text fields are printed with NoTruncation up to a 1000-char
budget, past which the checker's own elided rendering is used — so text is
always a well-formed type string, and one node can't grow without bound the
way the depth cap prevents for the tree. The flat strings keep the checker's
default ~160-char truncation throughout (they are the checker's canonical
rendering; see getTypeSignature). The budget bites only on types whose
alias TypeScript dropped — an alias over an indexed access or conditional
(z.infer<typeof S>, valibot's InferOutput) carries no alias symbol, so
the checker expands its whole structure at every use.
Written-name recovery: where a written annotation exists (return types —
per overload included — parameters, variables, type-alias declarations and
their properties, index signatures, getter-backed accessors, component
props, snippet parameters), each bare
type reference in it is resolved by checker type identity, and a type the
checker has no name for — the alias-dropped shapes above — emits
{kind: 'reference', name} instead of expanding, alias-lost unions and
intersections included. The name resolves through import aliases to the
importable one (import {Original as Renamed} recovers Original); a name
the checker has is never overridden; typeof queries, import types, inline
type literals, and argument-carrying references (z.infer<typeof S> itself,
Extract<D, {kind: K}> — whose bare symbol name would misrepresent the
instantiation) never recover. A recovered bare reference is emitted even at
the root, relaxing the absence contract the way alias roots do: a
checker-named bare reference defers to a flat sibling printing the same
name, while a recovered one stands against the anonymous expansion, so the
name exists only in the tree.
Registry recovery: behind the written channel, unannotated positions
recover through the analyzed set's alias registry — any exported,
non-@nodocs, non-generic lost alias of an emitted module, matched by
checker type identity. A registry-recovered reference additionally carries
module, the declaring module's ModuleJson.path — provenance for
collision-exact linking, and always an emitted module (gated modules never
register), so a consumer lookup by (module, name) can't dangle. module
is registry-only, deliberately: a written-channel recovery names whatever
the author wrote at the site (which may not be the registry's winner), and
checker-named references never carry it — consumers must handle absence.
Member order and nested aliases: union members follow the flat string's
printed order — null/undefined sink last, and the checker's origin
(the same internal field the flat strings are printed from) lists plain
members before named sub-unions, so written ColorScheme | number reads
number | ColorScheme in both — and a member written as a named sub-union
survives as its own alias-carrying union node (ColorScheme stays nested,
not flattened literals). When no usable origin exists the walk degrades,
bounded, to the checker's normalized list: flattened members in
checker-internal order (nullish still sunk last), written sub-aliases lost.