End-to-end pipeline
This page walks the reference generator entry point — Generator/Render.fs
in Xantham.Generator — top to bottom and explains what each step is
responsible for. The script form below mirrors the actual [<EntryPoint>]
function with commentary in between.
1. Decode the JSON input
The generator never reads .d.ts directly. It reads the JSON wire format
written by Xantham.Fable and decodes it into a navigable graph:
let file =
IO.Path.Join(__SOURCE_DIRECTORY__, "../../Xantham.Fable/output.json")
let tree = Decoder.Runtime.create file
let interner = tree.GetArenaInterner()
tree is a XanthamTree (see Xantham.Decoder);
interner is the lazy resolved object graph. Every TypeKey reference in
the source data has been replaced with Lazy<ResolvedType> and forcing a
node returns the fully shelled record.
2. Build a GeneratorContext
The context carries four caches and a customisation record. The example below pins the customisation that ships with the reference generator:
let generatorContext: GeneratorContext =
GeneratorContext.EmptyWithCustomisation (fun customiser ->
{ customiser with
Customisation.Interceptors.ResolvedTypePrelude = fun _ -> function
| ResolvedType.Interface { IsLibEs = true }
| ResolvedType.Class { IsLibEs = true }
| ResolvedType.Enum { IsLibEs = true } -> fun renderScope ->
{ renderScope with Render = Render.RefOnly renderScope.TypeRef }
| _ -> id
Customisation.Interceptors.IgnorePathRender.Source = function
| QualifiedNamePart.Normal(text)
| QualifiedNamePart.Abnormal(text, _) ->
text.Contains("babel", StringComparison.OrdinalIgnoreCase)
|| text.Contains("typescript", StringComparison.OrdinalIgnoreCase)
Customisation.Interceptors.Paths.TypePaths = fun _ typ s ->
match typ with
| Choice1Of4 { IsLibEs = true }
| Choice2Of4 { IsLibEs = true }
| Choice3Of4 { IsLibEs = true }
| Choice4Of4 { IsLibEs = true } ->
TypePath.pruneParent
(_.Name >> Name.Case.valueOrModified >> (=) "Typescript") s
| _ -> s
Customisation.Interceptors.Paths.MemberPaths = fun _ typ s ->
match typ with
| Choice1Of2 { IsLibEs = true }
| Choice2Of2 { IsLibEs = true } ->
MemberPath.pruneParent
(_.Name >> Name.Case.valueOrModified >> (=) "Typescript") s
| _ -> s })
See GeneratorContext for what each interceptor
does. In short: lib-ES types become references-only, the synthetic
Typescript module is pruned from their paths, and the babel /
typescript qualified-name sources are filtered before path construction.
3. Prelude pass
prerenderTypeAliases walks every type alias in the interner and seeds
ctx.TypeAliasRemap. After this pass, looking up an alias yields the
TypeRefRender of its underlying target — generators can dereference
chains of aliases without re-walking the graph for every hop:
ArenaInterner.prerenderTypeAliases generatorContext interner
The commented-out prerenderFromGraph line in the original entry point is
the thoroughness alternative: walk the dependency graph topologically and
seed prelude renders for every node. It is not needed when the export pass
is exhaustive but is occasionally useful for diagnostics.
4. Export pass
processExports is where the real work happens. For every entry in the
exported declaration map it:
-
Computes a concrete
TypePath/MemberPathfrom the export'sQualifiedNameandSource(withPathsinterceptors applied). -
Renders the declaration using the transient/prelude scope, registering
any nested transient types it encounters in a
RenderScopeStore. - Anchors and localises every
TypeRefRenderagainst the concrete root. -
Stores the result on
ctx.AnchorRenders(running theAnchoredRenderinterceptor on the way in).
ArenaInterner.processExports generatorContext interner
After this call, ctx.AnchorRenders is the canonical source of truth for
"what does the output look like".
5. Module collection
The anchor store is flat. To emit valid F#, types and members must be
grouped by the module they live in. RootModule.collectModules walks
ctx.AnchorRenders, partitions by the ModulePath of each anchored
render, and produces a nested RootModule / Module tree:
let renders =
RootModule.collectModules generatorContext
|> renderRoot generatorContext
renderRoot lowers the tree into a sequence of Fabulous.AST module nodes —
one for the root declarations and one for each nested module — preserving
the original module structure of the source.
6. Emit
Finally, wrap everything in an Ast.Oak (Fabulous's top-level F# document
node), run it through Gen.mkOak >> Gen.run, and print the result:
Ast.Oak() {
Ast.AnonymousModule() {
renders
}
}
|> Gen.mkOak
|> Gen.run
|> printfn "%s"
What you write vs what you get for free
The generator distinguishes the machinery from the policy. Out of the box you get:
- path construction (
TypeRefRender.Paths.fs) -
prelude / export passes (
ArenaInterner.prerenderTypeAliases,ArenaInterner.processExports) - anchor-and-localise (
Anchored.TypeRefRender.anchorAndLocalise) - module collection (
RootModule.collectModules) -
lowering to Fabulous.AST (
TypeRefRender.render,TypeRender.render*family)
What you supply:
- the JSON input file
- a
Customisationdescribing your project's conventions -
(optional) replacement passes — the reference entry point shows how to
swap or augment any step by reading from
ctx.AnchorRendersand emitting bespoke widgets before assembling the finalOak.
<summary> Lazy resolved type graph for use in generators. </summary>
<remarks><para> The wire format produced by the decoder (<c>DecodedResult</c>) represents the TypeScript type graph as a pair of flat maps keyed by <c>TypeKey</c>: one for structural types (<c>TsType</c>) and one for export declarations (<c>TsExportDeclaration</c>). References between types are expressed as <c>TypeKey</c> values that must be resolved against those maps. While correct, this representation is inconvenient for generators: maps must be threaded through every rendering function, and every type dereference requires an explicit lookup. </para><para><c>ArenaInterner</c> pre-resolves this key graph into a lazy object graph. Each <c>TypeKey</c> reference becomes a <c>Lazy<ResolvedType></c> — following a reference is simply forcing a lazy value, with no map access needed at call sites. Results are memoised in a dictionary keyed by <c>TypeKey</c>, so types that share the same key resolve to the same object instance. Combined with <c>[<ReferenceEquality>]</c> on all record types, this enables identity-based sharing detection in generators (e.g. recognising that two members reference the same <c>TypeParameter</c>) without carrying keys around. </para><para> Cycles in the type graph (e.g. recursive interfaces, self-referential type aliases) are broken naturally by the lazy boundaries around all <c>TypeKey</c> dereferences. Construction of a node never forces any of its outgoing lazy references, so re-entrant calls to <c>resolve</c> cannot occur during graph initialisation. By the time a consumer forces a lazy, the referent is already in the cache. </para><b>Prefer this representation in generators over raw <c>DecodedResult</c> maps when:</b><list type="bullet"><item>Rendering functions should not need access to global maps.</item><item>Structural sharing or identity equality between types is meaningful to the output.</item><item>The generator traverses the type graph recursively (pattern matching on <c>ResolvedType</c> is more ergonomic than repeated map lookups).</item></list><b>Prefer raw <c>DecodedResult</c> maps when:</b><list type="bullet"><item>Only a small subset of the type graph is needed — the interner allocates shells for all top-level exports at construction time.</item><item><c>TypeKey</c> identity must be preserved for output naming or cross-referencing (keys are not carried in the resolved graph).</item><item>Startup cost is a concern — <c>ArenaInterner.create</c> walks and shells the entire export map eagerly, deferring only nested type resolution.</item></list></remarks>
<summary>Performs operations on <see cref="T:System.String" /> instances that contain file or directory path information. These operations are performed in a cross-platform manner.</summary>
IO.Path.Join( paths: string array) : string
IO.Path.Join(path1: string, path2: string) : string
IO.Path.Join(path1: ReadOnlySpan<char>, path2: ReadOnlySpan<char>) : string
IO.Path.Join(path1: string, path2: string, path3: string) : string
IO.Path.Join(path1: ReadOnlySpan<char>, path2: ReadOnlySpan<char>, path3: ReadOnlySpan<char>) : string
IO.Path.Join(path1: string, path2: string, path3: string, path4: string) : string
IO.Path.Join(path1: ReadOnlySpan<char>, path2: ReadOnlySpan<char>, path3: ReadOnlySpan<char>, path4: ReadOnlySpan<char>) : string
module Decoder from Xantham.Decoder
--------------------
namespace Xantham.Decoder
<summary> Create a <see cref="T:XanthamTree" /> from the given Xantham JSON file path using default decoder settings. </summary>
<param name="fileName">Path to the input JSON file.</param>
module GeneratorContext from Xantham.Generator
--------------------
type GeneratorContext = { TypeAliasRemap: DictionaryImpl<ResolvedType,TypeRefRender> PreludeGetTypeRef: PreludeGetTypeRefFunc PreludeRenders: PreludeScopeStore AnchorRenders: AnchorScopeStore InFlight: HashSet<ResolvedType> Customisation: Customisation } override ToString: unit -> string
<summary> The resolved (lazy-graph) form of a structural TypeScript type. Each case directly references its dependencies (rather than carrying <c>TypeKey</c>s), with cycles broken by the surrounding <see cref="T:LazyResolvedType" /> wrappers. </summary>
<category index="4">Resolved Type Graph</category>
module Render from Xantham.Generator.Types.Prelude
--------------------
module Render from Xantham.Generator.Generator.RenderScope_Anchored
--------------------
module Render from Xantham.Generator.Generator
--------------------
type Render = | RefOnly of TypeRefRender | Concrete of Render | Transient of Render
module QualifiedNamePart from Xantham.Generator.NamePath
--------------------
type QualifiedNamePart = | Abnormal of part: string * diagnostic: QualifiedNamePartDiagnostic | Normal of part: string member Value: string
<summary> A single segment of a fully qualified TypeScript name. Tagged either as <c>Normal</c> (no anomalies) or <c>Abnormal</c> together with diagnostic flags indicating which anomalies the segment contains. </summary>
<category index="4">Resolved Type Graph</category>
String.Contains(value: char) : bool
String.Contains(value: string, comparisonType: StringComparison) : bool
String.Contains(value: char, comparisonType: StringComparison) : bool
<summary>Specifies the culture, case, and sort rules to be used by certain overloads of the <see cref="M:System.String.Compare(System.String,System.String)" /> and <see cref="M:System.String.Equals(System.Object)" /> methods.</summary>
<summary>Compare strings using ordinal (binary) sort rules and ignoring the case of the strings being compared.</summary>
module TypePath from Xantham.Generator.NamePath
--------------------
type TypePath = { Parent: ModulePath Name: Name<pascal> }
module Name from Xantham.Decoder
<summary></summary>
<category index="3">Names and Casing</category>
--------------------
type Name = | Modified of original: string * modified: string | Source of original: string static member Create: value: string -> Name + 1 overload static member CreateModified: original: string * modified: string -> Name member ValueOrModified: string member ValueOrSource: string
<summary> Utility type for working with names or manipulating the names of types and members while preserving the original source. </summary>
<category index="3">Names and Casing</category>
--------------------
type Name<'u> = Name
<summary>Provide static typing over the casing of a name</summary>
<category index="3">Names and Casing</category>
<summary> Provides some equivalency functions for working with Names that have measures. </summary>
union case QualifiedNamePart.MemberPath: string -> QualifiedNamePart
--------------------
module MemberPath from Xantham.Generator.NamePath
--------------------
type MemberPath = { Parent: MemberPathParent Name: Name<camel> }
module ArenaInterner from Xantham.Decoder.ArenaInterner
<summary> Functions for constructing and walking an <see cref="T:ArenaInterner" />. </summary>
--------------------
module ArenaInterner from Xantham.Decoder
<summary> Lazy resolved type graph for use in generators. </summary>
<remarks><para> The wire format produced by the decoder (<c>DecodedResult</c>) represents the TypeScript type graph as a pair of flat maps keyed by <c>TypeKey</c>: one for structural types (<c>TsType</c>) and one for export declarations (<c>TsExportDeclaration</c>). References between types are expressed as <c>TypeKey</c> values that must be resolved against those maps. While correct, this representation is inconvenient for generators: maps must be threaded through every rendering function, and every type dereference requires an explicit lookup. </para><para><c>ArenaInterner</c> pre-resolves this key graph into a lazy object graph. Each <c>TypeKey</c> reference becomes a <c>Lazy<ResolvedType></c> — following a reference is simply forcing a lazy value, with no map access needed at call sites. Results are memoised in a dictionary keyed by <c>TypeKey</c>, so types that share the same key resolve to the same object instance. Combined with <c>[<ReferenceEquality>]</c> on all record types, this enables identity-based sharing detection in generators (e.g. recognising that two members reference the same <c>TypeParameter</c>) without carrying keys around. </para><para> Cycles in the type graph (e.g. recursive interfaces, self-referential type aliases) are broken naturally by the lazy boundaries around all <c>TypeKey</c> dereferences. Construction of a node never forces any of its outgoing lazy references, so re-entrant calls to <c>resolve</c> cannot occur during graph initialisation. By the time a consumer forces a lazy, the referent is already in the cache. </para><b>Prefer this representation in generators over raw <c>DecodedResult</c> maps when:</b><list type="bullet"><item>Rendering functions should not need access to global maps.</item><item>Structural sharing or identity equality between types is meaningful to the output.</item><item>The generator traverses the type graph recursively (pattern matching on <c>ResolvedType</c> is more ergonomic than repeated map lookups).</item></list><b>Prefer raw <c>DecodedResult</c> maps when:</b><list type="bullet"><item>Only a small subset of the type graph is needed — the interner allocates shells for all top-level exports at construction time.</item><item><c>TypeKey</c> identity must be preserved for output naming or cross-referencing (keys are not carried in the resolved graph).</item><item>Startup cost is a concern — <c>ArenaInterner.create</c> walks and shells the entire export map eagerly, deferring only nested type resolution.</item></list></remarks>
--------------------
module ArenaInterner from Xantham.Generator.Generator.RenderScope_Prelude
--------------------
module ArenaInterner from Xantham.Generator.Generator.RenderScope_Anchored
--------------------
type ArenaInterner = { ResolveType: (TypeKey -> ResolvedType) ResolveExport: (TypeKey -> Result<ResolvedExport,ResolvedType>) ResolvedTypes: Dictionary<TypeKey,ResolvedType> ResolvedExports: Dictionary<TypeKey,ResolvedExport> ExportMap: Map<string,ResolvedExport list> Graph: Lazy<Graph> } override ToString: unit -> string
<summary> Lazily-resolved object graph view of a <c>DecodedResult</c>. Following a reference forces a <c>Lazy<ResolvedType></c>, materialising a node on demand. Cycles are broken by lazy boundaries (construction never forces outgoing lazies). Exports are shelled eagerly; nested types are deferred. </summary>
<category index="4">Resolved Type Graph</category>
module RootModule from Xantham.Generator.Generator.Render_Collection
--------------------
type RootModule = { Types: Dictionary<string,TypeRender> Members: Dictionary<string,Choice<TypedNameRender,FunctionLikeRender>> Modules: Dictionary<string,Module> }