Part 12 — Lessons from Roslyn, and where this leads: Ide.Dsl-TS
The previous eleven articles walked the engine, the example, and the testing strategy. This article steps back. With the running example fully unpacked, the question becomes precisely scopable: what did @frenchexdev/ts-codegen-pipeline borrow from Roslyn, and what did it deliberately leave behind? The answer is not a list of features — it is a small set of design judgements that determined what the engine had to be and what it did not need to become. The forward look — Ide.Dsl-TS, ontology-parameterized DSLs, the kernel-and-microDSL composition pattern — falls out of those judgements as the next consumer.
This article is the capstone. It does not introduce new mechanics. It reads the engine and the example backwards, asks what justified each design choice, and sketches the next twelve months of work that builds on the foundation. After reading, the consumer holds a defensible mental model of why this library exists in this shape and what they should expect from it next.
What was borrowed from Roslyn — four design judgements
The five-row analogue table in Part 01 matched API surface area. The deeper alignment is at the level of design judgement — four decisions made by the Roslyn team between 2014 and 2020 that the TS port reproduces because the underlying problems are the same.
The first judgement is multi-pass with feedback as the primitive shape. Roslyn's ISourceGenerator does not run once and exit; it runs as a phase of the compiler, and the compiler iterates that phase until convergence. The reason is that one generator's output can be another generator's input — a class emitted by generator A, decorated for generator B to pick up, becomes the input to a third pass. Without iteration, the consumer would have to hand-thread the dependency between generators; with iteration, the engine handles it. The TS port inherits this: runFixpoint iterates until anyEmitted === false, with the same default circuit-breaker (16 iterations) Roslyn uses.
The second judgement is strict additivity as the safety floor. Roslyn deliberately split generators (which add code) from analyzers + code-fixers (which can transform code with explicit user consent). The split is what lets generators iterate to a fixpoint without infinite oscillation: a generator that could mutate code that another generator reads could ping-pong forever. The TS port inherits the split — addSource can write under outDir, and there is no other mutation API on the engine. A consumer who needs mutation builds a separate codemod CLI (Part 04). The Roslyn lesson — that generation and transformation are different roles with different safety contracts — is reproduced exactly.
The third judgement is determinism as a contract, not a hope. Early Roslyn generators leaked timestamps, hostnames, run-ids; the result was non-deterministic builds, broken cache layers, mysterious test failures. The community's painful learning — that determinism is the precondition for caching, and caching is the precondition for fast incremental builds — produced the current IIncrementalGenerator model with its EquatableArray<...>-based input hashing. The TS port inherits the determinism contract (every banner is a content hash, no timestamps, no run-ids, byte-identical reruns asserted by fiveRunsByteIdentical() — see Part 05) but does not yet need the caching machinery (see "What was deliberately not borrowed" below). The contract holds; the implementation is lighter.
The fourth judgement is AddSource as the only emission primitive. Roslyn's GeneratorExecutionContext.AddSource(hintName, sourceText) is one method. There is no RemoveSource, no ReplaceSource, no RenameSource. The narrowness is the contract — this is the only way generators interact with the compilation. A consumer who tried to do something more elaborate would have to fork the API. The TS port carries the same constraint: virtFs.addSource(file) is the only mutation operation; absence of delete and replace is the contract. The narrow API is what makes the safety properties hold.
These four together — multi-pass, strict additivity, determinism, narrow emission API — are what the TS port faithfully reproduces. The five-row analogue table is the surface; the four judgements are the depth.
What was deliberately not borrowed — three explicit non-goals
Three further design decisions were rejected, each for a precise reason. A reader who knows Roslyn well will recognise these as load-bearing and might assume the TS port simply hadn't gotten to them yet. The honest answer is that they are deliberate omissions; the design space includes them, the library does not pursue them.
Non-goal 1: no IIncrementalGenerator-style input caching. Roslyn's IIncrementalGenerator is a sophisticated layer that hashes the inputs to each generator step and caches the output, so that a re-run on similar-but-not-identical inputs only re-runs the generators whose inputs actually changed. The win is enormous for large C# solutions where running every generator on every keystroke would be unacceptable.
The TS port does not implement this. Three reasons.
The first is scale. The canonical example runs in well under a second on real hardware. A pipeline with a hundred entities and twenty stages would still complete in single-digit seconds. The break-even point where input caching pays for its own complexity is somewhere — but for the current consumer space, the engine is fast enough without it. Premature optimisation is the right framing; building the cache before the workloads need it would mean shipping unused machinery.
The second is ts-morph cost. The single most expensive operation in the pipeline is loading the project into ts-morph — parsing the TypeScript files, resolving the type graph, building the symbol table. Once the project is loaded, subsequent generator runs on the same project handle are cheap. The cache that would matter most is therefore project-level (across CLI invocations), not generator-level (within a single invocation). A future revision might add a project-level warm cache; the per-generator cache that IIncrementalGenerator provides is less impactful in this configuration.
The third is API simplicity. IIncrementalGenerator requires consumers to express their input dependencies as IncrementalValueProvider<T> chains, which is a substantial conceptual surcharge. The TS port's SourceGenerator.execute(ctx) API is one method with one parameter; a consumer can write a generator in fifty lines without learning a new value-provider abstraction. The trade-off is between what the engine knows and what the consumer has to express; the TS port favours the simpler consumer API.
Non-goal 2: no syntax-tree visitor framework. Roslyn provides CSharpSyntaxVisitor<T> and CSharpSyntaxRewriter as first-class abstractions for traversing and (in the analyzer/code-fix track) transforming syntax trees. Generators that need to inspect AST shape extend these abstract classes and override per-node hooks.
The TS port does not need this because ts-morph already provides it. Every ts-morph Node has a typed forEachChild, a getKind, a getDescendants, an explicit symbol resolution path. The library's findDecorated helper and the example's three scanners (Part 07) demonstrate that consumer-side AST traversal is a hundred lines of straightforward ts-morph code. Adding a parallel visitor abstraction layer on top would duplicate ts-morph's existing API for no gain.
The decision matters strategically: the library's external dependency is ts-morph, not its own AST framework. Upgrading to a future TypeScript major version is then a question of ts-morph's upgrade path, not the library's. The narrowness keeps the maintenance surface small.
Non-goal 3: no two-tier SG/IIG split. Roslyn ships ISourceGenerator (the original, simpler API) and IIncrementalGenerator (the more sophisticated cache-aware API) side by side, with the recommendation that new code use IIncrementalGenerator. The two-tier story is a transition artefact — the community shipped the simpler API first, learned its limitations, designed the second tier, kept the first for backward compatibility.
The TS port does not have this transition story to tell. There is one generator API: SourceGenerator { id; execute(ctx); }. If a future need arises for a richer cache-aware API, that will be a new interface; but it will be a deliberate addition, not an inherited transition cost. Starting clean is one of the few advantages of a green-field port.
Three corollaries of the design
Three properties fall out of the four borrowings and three non-goals as corollaries. Each is worth naming because consumers tend to discover them experientially rather than read about them upfront.
Corollary 1: the engine is small enough to read. The runner is fifty lines (run-fixpoint.ts). The virtFS is a hundred and fifty (virtfs/internal.ts). The banner system is seventy (emit/banner.ts). The verify driver is eighty-five (emit/verify.ts). The commit phase is one hundred and thirty (virtfs/commit.ts). A consumer who wants to understand exactly what the engine does on a Monday morning can read every load-bearing file before lunch. The non-goals are why this is possible.
Corollary 2: the consumer surface is one decision per generator. A consumer writing a generator answers four questions: what's my id? (decides lex order), what do I scan for? (decides the input), do I need any virtFS dependencies? (decides early-return logic), what do I emit? (decides the body). Every generator in the example is structured around those four answers. There is no inheritance chain to learn, no abstract base to extend, no lifecycle hooks to register. The consumer surface is shallow because the engine's contract is narrow.
Corollary 3: the example is the documentation. A consumer who skims the engine's README and reads the ten generator files in packages/sourcegen-example/src/generators/ has a working mental model. The example is not additionally documentation; it is the documentation by virtue of dog-fooding every engine primitive. New documentation requirements (a tutorial, a recipe book) can borrow snippets from the example rather than invent fresh ones. The series itself is partly an extended commentary on the example.
Where this leads — Ide.Dsl-TS
The engine is the foundation for the next consumer in the constellation. Ide.Dsl-TS — the TypeScript counterpart to the C# Ide.Dsl corpus — will dog-food the engine end to end. The shape of that consumer is sketched across meta-ide-dsl (which proved a small TS meta-DSL could compile decorated spec.ts files into a VSCode extension) and microdsls-and-kernel (which decomposed that meta-DSL into a kernel and fourteen micro-DSLs). The engine introduced in this series is the codegen substrate both presuppose.
Three properties of Ide.Dsl-TS fall out of the engine's design.
Property 1: ontology-parameterized DSLs. A typical templating-engine DSL is closed-world — it has a fixed set of templates, applied to a fixed set of types. The engine's multi-stage feedback property allows a different shape: one generator emits an ontology (a typed enumeration of the consumer's domain concepts), subsequent generators parameterize their output by that ontology. A consumer who adds a new domain concept doesn't write a new template; they extend the ontology, and downstream generators emit the corresponding artefacts automatically. The pattern is open-world by construction: every concept gets a repository, a DTO, a mapper, a validator, a registry entry, a barrel export, without any per-concept ceremony.
The example demonstrates this in miniature. The entity-registry.generated.ts file emitted by stage 0 is the ontology; the RepositoryFor<K> and KeyFor<K> conditional types parameterize the consumer's type-level access by that ontology. A consumer adding a fourth entity (Invoice) gets a fourth row in EntityKind, a fourth conditional branch in RepositoryFor<K>, four new generated files (Repo + DTO + Mapper + Validator), zero hand-written changes to anything except the entity itself.
Ide.Dsl-TS will scale this pattern up. The ontology will be language concepts (@Token, @Rule, @Snippet, @LspFeature); the downstream generators will emit VSCode contributions, LSP handlers, syntax highlighting JSON, hover providers, and so on. A consumer adding a new language feature extends the ontology; the engine emits the corresponding artefacts. The invariants this series argued — multi-iteration feedback, atomic commit, banner determinism, additive-only — are exactly what makes this scale-up safe.
Property 2: kernel and microDSL composition. The microdsls-and-kernel series argued that a single monolithic spec class doesn't scale across a real engineering organisation: release cadence, ownership, ecosystem reuse all push for decomposition. The decomposition is into a kernel (a small stable layer of @Concept, @Property, @ChildLink, @ReferenceLink decorators, plus a runtime PatchBus / AST / Banner / EditLog) and microDSLs (one per concern, each a separately publishable VSCode extension).
The engine's strictly-additive contract is what makes that decomposition safe. Each microDSL emits files into its own outDir; the kernel emits into a shared kernel outDir. Strict additivity means no microDSL can mutate the kernel's emissions, and no microDSL can interfere with another microDSL's emissions. The atomic commit means each microDSL's pipeline is independently safe to run. The banner-and-hash determinism means each microDSL can be cached independently. The decomposition is the consumer-side architecture; the engine's invariants are what make it tractable.
Property 3: dog-fooding all the way down. The example's tests dog-food the requirements framework. The engine's tests dog-food the engine itself (PackageBarrelGenerator and TestStubGenerator are built-in generators that the engine uses on its own monorepo). Ide.Dsl-TS will continue the pattern: its specs will be .spec.ts files dog-fooded by its own pipeline, its tests will use the requirements framework, and its CI will compose sourcegen verify + tsc --noEmit on outDir + vitest run exactly as Part 11 described.
The dog-fooding is not vanity. It is the discipline that catches design errors before consumers do. A library that does not use itself is a library that has not been stress-tested. Every primitive introduced in this series is one the engine has had to live with on its own monorepo before being committed to in a public API.
What this series did not address — and where to go next
Three threads intentionally left for follow-up series. A reader who has reached this article and wants more should know where to look.
Thread 1: the C# counterpart. This series did not walk Roslyn itself. A reader who wants to understand the C# source generator world from first principles can start with the Roslyn source generator overview and Andrew Lock's Source Generators essay series. The distributed-task series on this site walks a working C# source generator end-to-end, and its shape is directly comparable to the example walked here — the analogue table in Part 01 holds when read against either codebase.
Thread 2: the build-system integration. This series treated sourcegen run as a CLI command. A consumer who wants the pipeline to be part of npm run build, or to run on file-watcher events, or to integrate with tsc --build, has options that this series did not enumerate. The engine's sourcegen watch mode (mentioned in the README) is a starting point. A future series could walk the build-system integration story in detail; it sits adjacent to this series, not replacing it.
Thread 3: the meta-IDE story. meta-ide-dsl and microdsls-and-kernel are the upstream architectural thinking. Ide.Dsl-TS will be the build-in-public series that walks the engine consumer end-to-end, stage by stage, against a real meta-IDE workload. It is in scoping at the time of writing and will be adjacent to this series — the engine here is the substrate; the meta-IDE there is the consumer.
A closing observation about register
The TypeScript ecosystem's general posture toward Roslyn is one of polite distance. The C# world is large and influential, but its tooling decisions have rarely felt directly applicable to TS for two reasons: TS lacks a unified compiler-as-platform comparable to Roslyn, and the TS community has built its codegen story around bundlers (esbuild, vite, swc) rather than the compiler. The default assumption is that Roslyn's lessons live in C# land.
This series argued the opposite. The lessons of Roslyn — multi-pass with feedback, strict additivity, determinism, narrow emission API — are language-agnostic design judgements that the TS ecosystem can pick up directly. @frenchexdev/ts-codegen-pipeline is one demonstration that they translate, the example is one demonstration that they scale, the testing apparatus is one demonstration that they verify. The decisions to not borrow IIncrementalGenerator, syntax visitors, and the SG/IIG split are equally Roslyn-derived — they come from understanding why Roslyn made those choices and asking whether the same pressures apply to TS at this scale. Sometimes they do not, and the right move is to leave them behind.
The closing claim of this series is the one stated in Part 01: the TS ecosystem has a Roslyn-shaped gap, and @frenchexdev/ts-codegen-pipeline fills it. Twelve articles later, with the engine, the example, and the testing strategy on the table, the claim is no longer aspirational. It is grounded against three thousand lines of running code, twenty-two acceptance criteria, and a multi-iteration fixpoint convergence that this series watched land in three iterations exactly. The next consumer — Ide.Dsl-TS — will inherit all of it.
The Feature for this article is FEAT-TSGEN-12 in assets/features.ts. Acceptance criteria: Roslyn borrowings enumerated; IIncrementalGenerator omission justified; syntax-tree visitor omission justified via ts-morph; Ide.Dsl-TS roadmap position stated; ontology-parameterized DSL pattern connected. Each section above maps to one of those ACs.