Structured type information — the machine-readable counterpart to the flat
type strings (ParameterJson.type, ComponentPropJson.type, the
typeSignature fields that carry a typeInfo sibling, and returnType
beside returnTypeInfo).
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.
svelte2tsx-generated aliases don't count. A component whose props have no
name of their own (an inline $props() annotation, a JS component's JSDoc)
types them through svelte2tsx's $$ComponentProps, which nothing exports;
a legacy component with slots, through the shims'
__sveltets_2_PropsWithChildren. The tree treats those aliases as absent:
they're never a name or alias, a project alias over one
(type P = ComponentProps<typeof Button>) recovers like any alias-lost type
(below), and where nothing recovers the structure is expanded. A root whose
flat string is the generated name, bare or as an array or tuple element
($$ComponentProps[]), keeps its tree, except object and function roots of
a type alias, whose members carry them. The flat strings still print
$$ComponentProps. Generated names inside a terminal node's text
({ a: $$ComponentProps; }, or an other node past the depth cap) are
printed as the checker prints them.
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: the optional-widening undefined member is dropped from a
root union like the flat string drops it (optional: true carries it) — but
the flat string strips at the top level only, while the tree strips at every
optional-flagged position it expands, so Snippet<[a: string, b?: number]>
prints b?: number | undefined while the tuple element reports number with
optional: true. Treat typeSignature as the checker's canonical rendering
and the structured fields as normalized. At property sites the strip applies
only when the checker widened: under exactOptionalPropertyTypes an
author-written undefined on an optional property is kept (parameters and
tuple elements always strip). Separately, 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: at each annotated position, each bare type
reference 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 annotated positions: return types (per overload
too), parameters, variables, type-alias declarations, properties of type
aliases, interfaces, and classes, index signatures, accessors (the getter's
return, else a setter-only accessor's parameter), component props, and
snippet parameters. 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. Both recovery channels still fire at the depth
cap, so a capped alias-lost type emits its reference instead of elided text.
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
appears only on registry-recovered references: written-channel recoveries
name whatever the author wrote (which may not be the registry's winner), and
checker-named references and alias-carrying union/intersection nodes
carry no provenance. Consumers must handle absence.
Member order and nested aliases: union members follow the written
order wherever a written union node is recoverable: the alias declaration's
right-hand side for a non-generic aliased union (covering every use,
inferred ones included), else the annotation at the site. null/undefined
still sink last, and a member written as a named sub-union survives as its
own alias-carrying union node (ColorScheme stays nested, not flattened
literals) when the checker kept it grouped. Elsewhere — inferred unions,
keyof, mapped and conditional results, generic alias instantiations —
members follow the checker's internal order, which depends on
type-creation history and can shift with unrelated edits. Flat strings
always print in the checker's order, so tree and flat string can list the
same members in different orders (written ColorScheme | number prints
number | ColorScheme); the tree is the one to read for author intent, such
as control options. One edge: the site lookup matches by member set, so a
union takes the order of the first union written over the same members
anywhere in that annotation — two written unions in different orders
(['a' | 'b', 'b' | 'a']) both read "a" | "b", and an unwritten one there
(Exclude<…>) borrows a written one's order.