Logo Xantham

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:

  1. Computes a concrete TypePath / MemberPath from the export's QualifiedName and Source (with Paths interceptors applied).
  2. Renders the declaration using the transient/prelude scope, registering any nested transient types it encounters in a RenderScopeStore.
  3. Anchors and localises every TypeRefRender against the concrete root.
  4. Stores the result on ctx.AnchorRenders (running the AnchoredRender interceptor 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:

What you supply:

module Pipeline from Pipeline
namespace System
namespace Fabulous
namespace Fabulous.AST
namespace Xantham
namespace Xantham.Generator
namespace Xantham.Generator.Generator
module NamePath from Xantham.Generator
namespace Xantham.Decoder
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&lt;ResolvedType&gt;</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>[&lt;ReferenceEquality&gt;]</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>
namespace Xantham.Generator.Types
val main: unit -> int
val file: string
namespace System.IO
type Path = static member ChangeExtension: path: string * extension: string -> string static member Combine: path1: string * path2: string -> string + 4 overloads static member EndsInDirectorySeparator: path: ReadOnlySpan<char> -> bool + 1 overload static member Exists: path: string -> bool static member GetDirectoryName: path: ReadOnlySpan<char> -> ReadOnlySpan<char> + 1 overload static member GetExtension: path: ReadOnlySpan<char> -> ReadOnlySpan<char> + 1 overload static member GetFileName: path: ReadOnlySpan<char> -> ReadOnlySpan<char> + 1 overload static member GetFileNameWithoutExtension: path: ReadOnlySpan<char> -> ReadOnlySpan<char> + 1 overload static member GetFullPath: path: string -> string + 1 overload static member GetInvalidFileNameChars: unit -> char array ...
<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: ReadOnlySpan<string>) : string
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
val tree: Runtime.XanthamTree
Multiple items
module Decoder from Xantham.Decoder

--------------------
namespace Xantham.Decoder
module Runtime from Xantham.Decoder
val create: fileName: string -> Runtime.XanthamTree
<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>
val interner: ArenaInterner
member Runtime.XanthamTree.GetArenaInterner: unit -> ArenaInterner
val generatorContext: GeneratorContext
Multiple items
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
static member GeneratorContext.EmptyWithCustomisation: customisation: (Customisation -> Customisation) -> GeneratorContext
val customiser: Customisation
type Customisation = { Interceptors: Interceptors } static member Create: fn: (Customisation -> Customisation) -> Customisation static member Default: Customisation
type ResolvedType = | GlobalThis | Conditional of ConditionalType | Interface of Interface | Class of Class | Primitive of TypeKindPrimitive | Union of Union | Intersection of Intersection | Literal of TsLiteral | IndexedAccess of IndexAccessType | Index of Index ...
<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>
union case ResolvedType.Interface: Interface -> ResolvedType
union case ResolvedType.Class: Class -> ResolvedType
union case ResolvedType.Enum: EnumType -> ResolvedType
val renderScope: RenderScope
Multiple items
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
union case Render.RefOnly: TypeRefRender -> Render
RenderScope.TypeRef: TypeRefRender
val id: x: 'T -> 'T
Multiple items
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>
union case QualifiedNamePart.Normal: part: string -> QualifiedNamePart
val text: string
union case QualifiedNamePart.Abnormal: part: string * diagnostic: QualifiedNamePartDiagnostic -> QualifiedNamePart
String.Contains(value: string) : bool
String.Contains(value: char) : bool
String.Contains(value: string, comparisonType: StringComparison) : bool
String.Contains(value: char, comparisonType: StringComparison) : bool
type StringComparison = | CurrentCulture = 0 | CurrentCultureIgnoreCase = 1 | InvariantCulture = 2 | InvariantCultureIgnoreCase = 3 | Ordinal = 4 | OrdinalIgnoreCase = 5
<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>
field StringComparison.OrdinalIgnoreCase: StringComparison = 5
<summary>Compare strings using ordinal (binary) sort rules and ignoring the case of the strings being compared.</summary>
val typ: Choice<Interface,EnumType,Class,TypeAlias>
val s: TypePath
union case Choice.Choice1Of4: 'T1 -> Choice<'T1,'T2,'T3,'T4>
union case Choice.Choice2Of4: 'T2 -> Choice<'T1,'T2,'T3,'T4>
union case Choice.Choice3Of4: 'T3 -> Choice<'T1,'T2,'T3,'T4>
union case Choice.Choice4Of4: 'T4 -> Choice<'T1,'T2,'T3,'T4>
Multiple items
module TypePath from Xantham.Generator.NamePath

--------------------
type TypePath = { Parent: ModulePath Name: Name<pascal> }
val pruneParent: predicate: (ModulePath -> bool) -> typePath: TypePath -> TypePath
Multiple items
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>
module Case from Xantham.Decoder.NameModule
<summary> Provides some equivalency functions for working with Names that have measures. </summary>
val valueOrModified: name: Name<'u> -> string
val typ: Choice<Variable,Function>
val s: MemberPath
union case Choice.Choice1Of2: 'T1 -> Choice<'T1,'T2>
union case Choice.Choice2Of2: 'T2 -> Choice<'T1,'T2>
Multiple items
union case QualifiedNamePart.MemberPath: string -> QualifiedNamePart

--------------------
module MemberPath from Xantham.Generator.NamePath

--------------------
type MemberPath = { Parent: MemberPathParent Name: Name<camel> }
val pruneParent: predicate: (ModulePath -> bool) -> memberPath: MemberPath -> MemberPath
Multiple items
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&lt;ResolvedType&gt;</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>[&lt;ReferenceEquality&gt;]</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&lt;ResolvedType&gt;</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>
val prerenderTypeAliases: ctx: GeneratorContext -> arena: ArenaInterner -> unit
val processExports: ctx: GeneratorContext -> interner: ArenaInterner -> unit
val renders: WidgetBuilder<Fantomas.Core.SyntaxOak.ModuleOrNamespaceNode>
Multiple items
module RootModule from Xantham.Generator.Generator.Render_Collection

--------------------
type RootModule = { Types: Dictionary<string,TypeRender> Members: Dictionary<string,Choice<TypedNameRender,FunctionLikeRender>> Modules: Dictionary<string,Module> }
val collectModules: ctx: GeneratorContext -> RootModule
val renderRoot: ctx: GeneratorContext -> root: RootModule -> WidgetBuilder<Fantomas.Core.SyntaxOak.ModuleOrNamespaceNode>
type Ast = class end
static member Ast.Oak: unit -> CollectionBuilder<Fantomas.Core.SyntaxOak.Oak,'marker>
static member Ast.AnonymousModule: unit -> CollectionBuilder<Fantomas.Core.SyntaxOak.ModuleOrNamespaceNode,Fantomas.Core.SyntaxOak.ModuleDecl>
module Gen from Fabulous.AST
val mkOak: root: WidgetBuilder<'node> -> 'node
val run: oak: Fantomas.Core.SyntaxOak.Oak -> string
val printfn: format: Printf.TextWriterFormat<'T> -> 'T

Type something to start searching.