Part 03 — The trip and the guardrails: prior art engaged, constraints pinned
Articles 01 and 02 bounded the problem. An IDE is a twenty-service bundle, thirteen of them riding on LSP; a DSL needs five of those services to be usable at all. Between the supply and the demand sits the meta-question: how do we build the machinery that turns a DSL spec into the extension that ships those five services? The honest first move is to look at how others have answered the same question — not as a survey of the field, but as a way to find out what is already proved, what is not, and where our bet differs.
This article engages the prior art at length. Four precedents get multi-paragraph treatment because they share structural DNA with the approach developed here: MPS (the projectional-editor lineage), Roslyn source generators (the compile-time-codegen lineage the meta-DSL is most directly analogous to), the TypeScript compiler infrastructure (the host ecosystem's toolkit, with its honest gap versus Roslyn), and Langium (the closest contemporary competitor). Three more get shorter paragraphs to round out the map — Xtext, Monaco-plus-handwritten, and a brief note on tsserver plugins as a road not taken. At the end, the six guardrails the series commits to.
MPS — the projectional alternative, and what we borrow from it
JetBrains' Meta Programming System (MPS, open-sourced in 2006) is the canonical language workbench in the sense Martin Fowler named it in 2005: a tool whose explicit purpose is to help people build domain-specific languages, with structured editing, typing, and code generation baked into one environment. MPS has shipped real languages for decades — mbeddr (an embedded-C workbench used in industrial firmware), the JetBrains Compose internal DSL tooling, and a string of academic and industrial rule and form languages at companies like Deutsche Bank and Siemens. It is not a toy; it is the most mature language-workbench product in existence.
The defining choice is projectional editing. In MPS, you are not editing a text file. You are editing a structured model — an abstract syntax tree in a typed concept hierarchy — and what appears on screen is a projection of that model in a notation the language designer chose. Projections can look like code, look like math, look like tables, look like forms; they can intermix. The user never types a source-level character that does not correspond to a model operation. The editor is the model's window, not the model's persistence format.
This choice has consequences. On one hand, projectional editing makes certain language features trivial that are painful in textual DSLs: arbitrary notation (you can render a fraction as a fraction bar, not as a slash), mixed languages in one file without grammar conflicts (you can embed a table inside a decision tree inside a C function body), and refactorings that are sound by construction because they operate on the model directly. On the other hand, projectional editing breaks the entire Unix and Git stack. The files MPS writes to disk are not textual; they are XML serialisations of the model. Diffs are incomprehensible. Grep does not work. The VCS workflow every professional developer has internalised collapses.
MPS is also JVM-based and ships its own IDE. You cannot use MPS from VSCode or Neovim or the terminal; you use MPS from MPS. This is the second defining choice — a single editor for a single model — and it is the reason, more than anything technical, that MPS has stayed a niche tool despite being architecturally ahead of its competitors: the cost of adopting it is adopting a new editor, a new VCS story, and a new set of muscle memories, for every developer on the team. The monorepo we are working in is text-file-based and Git-native; that alone rules MPS out as a target.
What we borrow, nonetheless, is the posture. MPS' core insight is that a language is better described by a structured model of concepts than by a grammar file. The meta-DSL we are designing follows that insight: spec.ts is the structured model (just expressed in TypeScript's class-and-decorator vocabulary instead of MPS' concept-and-aspect vocabulary), and the emitters project that model into grammar JSON, snippets JSON, LSP handlers, and task providers. We are textual where MPS is projectional — the storage format of a DSL file is still plain text — but the authoring format of the language, in our approach, is a typed model. That is the lineage claim: textual storage, projectional authoring of the language definition itself.
Roslyn source generators — the cousin from the .NET side
The cleanest conceptual cousin of the meta-DSL lives in the .NET ecosystem: Roslyn source generators. Introduced in C# 9 (November 2020), a source generator is a class implementing IIncrementalGenerator that Roslyn invokes during compilation. The generator reads the compilation's syntax trees and semantic model, produces additional C# source, and hands it back to the compiler — which incorporates the generated source into the same compilation as if the user had written it. One invocation of dotnet build, one compilation unit, with the user's code and the generator's output type-checked together.
The user surface is striking. A generator is registered via assembly attribute; the user declares partial classes or marks members with attributes the generator recognises; the generator emits the completion. No build-tool configuration, no pre-build step, no separate CLI. From the user's perspective, writing [MyAttribute] on a property is the way to get a hundred lines of generated validation, serialization, or routing code. The generator runs inside the IDE's language service too — Visual Studio and JetBrains Rider consume generator output live, so IntelliSense sees the generated members as the user types. The user-visible loop is tight: mark a class, save, see the generated members show up in completion immediately.
Stéphane uses this pattern in the CMF project (project_cmf_requirements_dsl) — the C# Requirements DSL there relies on source generators to emit compliance reporters, validator sealed classes, and the Feature registry from attribute annotations on user classes. The pattern shows up in the blog at content/blog/entity-dsl/02-source-generators-101.md, which explains the mechanism for an entity-DSL use case. The @frenchexdev/requirements TypeScript package the present series dog-foods is, in the large, the TypeScript analogue of what that C# package does with source generators. The meta-DSL's relationship to VSCode is the TypeScript-ecosystem mirror of the C# source-generator relationship to Visual Studio: surface the business on the host-language's own vocabulary (attributes or decorators on classes), hide the plumbing at compile time, let the IDE see everything live.
The asymmetry that shapes our design is that the TypeScript ecosystem has no native equivalent to Roslyn's [Generator]. That asymmetry is the subject of the next section.
The TypeScript compiler infrastructure — what helps, what is missing
A fair comparison with Roslyn requires going through the TypeScript toolbelt one tool at a time and naming what each does, so the honest gap — and the workaround — are visible.
Decorators. TypeScript ships two decorator systems: the legacy experimental decorators (experimentalDecorators: true in tsconfig.json), available since TS 1.5, and the standard TC39 stage-3 decorators landed in TS 5.0 (March 2023). The two are syntactically similar and semantically different — legacy decorators can emit design-time type metadata (the emitDecoratorMetadata option, consumed at runtime via reflect-metadata), standard decorators cannot. Both give us the business-visible surface on the user's side: the marks on classes, properties, and methods that declare intent. Both the requirements DSL (@Feature, @Satisfies, @Verifies) and the typed-fsm DSL (@FiniteStateMachine) already lean on decorators for their user-facing API. What decorators do not do, in either flavour, is emit new source files at compile time. Decorators install runtime metadata or run at class-construction; they are not a code-generation mechanism.
The TypeScript Compiler API. The typescript npm package exposes the full compiler — ts.createProgram, ts.SourceFile, ts.TypeChecker, ts.forEachChild, the entire AST and the resolved semantic model. Anything the tsc CLI does internally can be done programmatically. This is the engine every TypeScript tool that inspects or transforms code is built on (ESLint's TS rules, Prettier's TS plugin, ts-morph, the language service itself). The Compiler API is how the meta-DSL will read a spec.ts, walk its AST, find decorator calls, resolve their argument types, and extract a LanguageIR. It is a powerful, stable, well-documented surface — the foundation that makes the rest tractable.
ts-morph. ts-morph is an ergonomic wrapper over the Compiler API — a Project-and-SourceFile object model with typed getters for Node, printers that re-emit source with whitespace preserved, and mutation helpers that make AST edits feel like working with a DOM. The monorepo already uses it in packages/typed-fsm/src/analysis/state-machine-extractor.ts, which is how the typed-fsm package derives its state-machine IR from decorated user code. Ts-morph is the most plausible primary dependency for the meta-DSL's extractor: it is the cheapest way to traverse a spec.ts and build the LanguageIR the later articles will define.
Custom transformers. Roslyn-shaped wishful thinking might hope TypeScript exposes a "plug a transformer into the compile pipeline" hook. It does, via ts.TransformerFactory — but with two caveats that matter. First, vanilla tsc has no public CLI flag to register custom transformers; you need ts-patch or ttypescript to patch the compiler's entry point so that transformer plugins declared in tsconfig.json get invoked. Second, and more fundamentally, transformers rewrite existing sources — they take an AST and produce a modified AST — but they do not inject new files into the compilation unit. A Roslyn source generator can say "here is a new source file; compile it alongside the user's code"; a TS transformer cannot. The closest a transformer can do is rewrite a decorator call site to emit inline the code the decorator logically implies, which is not the same thing.
Language Service plugins. A different road, worth naming to rule out. The TypeScript Language Service (tsserver) that powers IDE features in VSCode, JetBrains, and any tsserver-based editor supports plugins — a plugin implements createLanguageService and augments completion, diagnostics, and quick-fixes in-process. This is how, for instance, the Vue 3 Volar plugin works, or how styled-components plugins inject highlighting for tagged template literals. The attraction is real: a tsserver plugin could surface DSL-aware diagnostics and completion for an internal DSL (requirements, typed-fsm) without shipping a VSCode extension at all. We considered this path and do not take it in this series, for one reason: it only helps when the DSL is TypeScript. External DSLs (Catala, Mermaid, Bicep) have their own file extensions and their own parsers; a tsserver plugin is invisible to them. The meta-DSL targets the larger surface — both internal and external DSLs — and that surface is the VSCode extension.
The honest gap. Vanilla tsc cannot, during a single compile, analyse your spec.ts and inject new .ts source files into the same compilation unit. The closest the ecosystem offers are transformers (rewrite-only, opt-in via patches) and build plugins (webpack/esbuild/swc loaders that run before tsc). Compared to Roslyn's single-pass [Generator], the TypeScript ecosystem asks you to run code generation in a separate phase, before tsc sees the code. This is a real limitation, and it shapes the architecture we commit to.
Our workaround. The meta-DSL will run as a separate build step, provisionally named ide-forge:
ide-forge build spec.ts --out dist/<extension-id>/ide-forge build spec.ts --out dist/<extension-id>/Under the hood: ts-morph reads spec.ts, the extractor builds a LanguageIR, a fan of emitters writes a complete sibling project — package.json, syntaxes/<id>.tmLanguage.json, snippets/<id>.json, src/server.ts, src/extension.ts, language-configuration.json — and then npm run compile inside the sibling project invokes tsc normally on the generated TypeScript. Two phases, two compilations, one source artefact on the user side (spec.ts) and one delivery artefact on the other side (the extension folder).
The trade-off versus Roslyn is honest. We lose the single-pass live feedback: a user editing spec.ts does not see generated types appear in their host project's IntelliSense, because the generated types live in a sibling project. We gain something real in exchange: the output is a clean, self-contained extension folder, free of compiler-patching dependencies, that builds with stock tsc and packages with stock vsce. For a tool whose output is already an artefact shipped separately (a .vsix you install in VSCode), two phases is not a defect; it matches the delivery model.
The forward-looking nuance. Source-generator-shaped hooks for TypeScript have been proposed periodically on the TypeScript issue tracker (see microsoft/TypeScript#31571 and its successors). If one ever lands, the ide-forge pipeline would collapse into a single pass, the way Roslyn's already does. Until then, two-phase is the realistic architecture, and this series commits to it.
Langium — the closest contemporary competitor
If MPS is the projectional grandparent and Roslyn source generators are the .NET cousin, Langium is the same-generation sibling. Langium is a TypeScript-first language-workbench framework maintained by TypeFox, the consultancy behind Theia and historically behind Xtext. It targets Node.js and the browser, ships an LSP implementation out of the box, and is opinionated about producing VSCode extensions and web editors from a single language definition.
The defining choice in Langium is grammar-first. The user writes a grammar file in Langium's own DSL — a variant of Xtext's grammar DSL, adapted to TypeScript — and Langium generates from that grammar both the parser and the scaffolding for the LSP, the AST types, and the default validations. The grammar DSL is powerful: EBNF-style rules, cross-reference resolution, scoping primitives, default-value declarations, all in a notation Xtext users will find familiar.
The loop, for a Langium user, is: write the grammar, run the generator, get an extension skeleton, fill in the custom validators and the custom code generators in TypeScript, package with vsce. It is a genuinely productive pipeline for someone willing to learn the grammar DSL. The resulting extensions are competent — the Langium documentation walks through producing an extension with highlighting, diagnostics, completion, go-to-definition in a few hundred lines of grammar plus a few hundred lines of TypeScript, which compares well to the thousands of lines a from-scratch extension requires.
Where Langium differs from the meta-DSL we are designing is exactly in the authoring surface. Langium asks the DSL author to learn a grammar DSL before they can express their own DSL — a second notation sitting between the author and their domain. Our bet, for the kind of small, TypeScript-internal DSLs this monorepo already produces (requirements, typed-fsm), is that the grammar DSL is the wrong abstraction to sit on. The DSL author is already in TypeScript; their DSL is expressed as decorated classes; asking them to re-declare the same structures in a grammar DSL is redundant. We hide the grammar behind @Token and @Rule decorators on the same class that carries the rest of the DSL's description.
This is not a criticism of Langium — Langium is the right tool for a team that starts from a grammar (a new external DSL with nontrivial parsing, say, or the kind of rule language Catala is). It is the wrong tool for a team whose DSL is already an internal TypeScript DSL whose grammar is trivial (decorators on classes, method bodies in the host language). The two tools address different starting points; they are not substitutes, they are alternatives with different costs.
Xtext and the Monaco-plus-handwritten route
Two more precedents deserve short treatment for completeness.
Xtext (Eclipse Foundation, first release 2008) is Langium's Java-hosted ancestor. Same grammar-first philosophy, same codegen-from-grammar pipeline, targets Eclipse and (via LSP exposure) other editors. Xtext is mature, production-hardened, and used in serious industrial settings (Siemens' SysML2 tooling, for instance). Its ecosystem cost — the JVM, Eclipse, the learning curve of its grammar dialect — put it in the same category as MPS for a TypeScript-monorepo project: architecturally respectable, ecosystem-mismatched.
Monaco plus handwritten. The path some teams take is to skip frameworks entirely: embed the Monaco editor (VSCode's editor core, published as a standalone package), write a TextMate grammar by hand, write the LSP handlers by hand, write the extension manifest by hand. It works, for a single language, with a motivated team. The cost is the cost: there is no leverage across DSLs. Each new DSL starts from zero, or from a copy-paste of the previous one that drifts over time. The distributed-task DSL in the C# half of the monorepo (content/blog/distributed-task/) lives on exactly this tension in its own ecosystem, and the meta-DSL we are designing is the structural answer for the TypeScript half.
Comparison at a glance
After the paragraphs, a table — the kind that is a useful map rather than a replacement for the engagement above.
| Tool | Approach | Language | Target | Business surface |
|---|---|---|---|---|
| Langium | Grammar-first + codegen | TypeScript | VSCode + web | Grammar DSL (a second notation) |
| Xtext | Grammar + DSL + tooling | Java | Eclipse / LSP clients | JVM / Eclipse |
| MPS | Projectional, structured model | Java | Standalone MPS IDE | Concept hierarchy |
| Roslyn source generators | Codegen at compile time | C# | Visual Studio / dotnet | Host-language attributes |
| TS Compiler API + transformers | AST rewrite at compile | TypeScript | Any TS toolchain | No native source generation |
| Monaco + handwritten | Manual wiring | TypeScript | Web / VSCode | Each author rebuilds |
| This proposal (ide-forge) | Decorator-first, two-phase codegen | TypeScript | VSCode (first) | Host-language decorators |
The rightmost column is the one that matters for our thesis. The meta-DSL sits in the same cell as Roslyn source generators — the business surface is the host language — because that is the cell where the DSL author does not have to learn a second notation. The cost for sitting there on the TypeScript side of the fence is the two-phase pipeline; the benefit is that every TypeScript developer already knows how to write the spec.
Business visible, plumbing hidden — restated as a design constraint
Part 01 used the phrase as a closing posture. Having engaged the prior art, we can now restate it as a design constraint that materially differentiates the meta-DSL from Langium, from Monaco-plus-handwritten, and from a hypothetical "just write a VSCode extension generator template" tool.
The business-visible surface is the set of decorators on the user's spec.ts — @Language, @Token, @Rule, @Snippet, @LspFeature, @Executor. These are terms a domain author recognises because they correspond to concepts in their domain — a token is a lexical unit of their language, a snippet is a canonical form their users type often, an executor is the command their users want to run. The plumbing that never appears in spec.ts is the VSCode extension API itself: no contributes.languages literal, no tmLanguage.json hand-written, no vscode-languageclient options object, no debug on package.json activation events. If any of that surfaces in a DSL author's editor, the meta-DSL has leaked.
The requirements-ide.spec.ts artefact is the object of evidence. It declares one DSL and nothing else — no manifest literal, no grammar JSON, no LSP server setup. The 210 lines of type-checked code in that file cover every structural decision a DSL author has to make; the ten thousand lines or so of plumbing that will eventually ship with a generated extension are the forge's responsibility, not the author's.
The six guardrails
The series holds to six guardrails, set before writing any line of ide-forge code. Four of them are the five pillars already named in the series index — SOLID, DRY, unit testing, dog-fooding, requirements DSL as pivot. Two of them are methodological commitments this article has earned the right to name.
Guardrail 1 — SOLID, derived from monorepo code, not from textbook examples. Every SOLID claim in article 04 will cite a file in packages/requirements/ or packages/typed-fsm/. The reason is quality-control: principles argued abstractly drift toward whichever abstraction the author already favoured; principles demonstrated on code that exists and works are constrained by reality.
Guardrail 2 — DRY, against a real baseline of VSCode manifest repetition. Article 05 opens with a teardown of an actual package.json — field by field, which parts are derivable from a spec, which must stay explicit. The claim that the LanguageIR is a useful single source of truth is only interesting if we can show what it deduplicates; we will show it.
Guardrail 3 — Port-driven testability from the beginning. Every emitter takes a FileSystem port and a Logger port in its constructor; tests inject InMemoryFileSystem and CapturingLogger; no emitter writes directly to node:fs. The pattern is already the monorepo's default (packages/requirements/src/ports/FileSystem.ts, packages/ssg-site/src/build/static.ts); the meta-DSL inherits it.
Guardrail 4 — Dog-food the requirements DSL through the series itself. The seven articles' acceptance criteria are declared in assets/features.ts as seven @Feature classes. An article is considered accepted when its prose verifies every one of its AC methods. The series uses the very DSL it designs for.
Guardrail 5 — Design in public, with > Proposal labels on every speculative passage. This guardrail exists because the series is written before the implementation. Any claim about what the meta-DSL will do, as opposed to what the requirements-ide.spec.ts artefact demonstrates, is a proposal in the literal sense. Mislabelling would make the series dishonest as documentation and misleading as a design record.
Guardrail 6 — No capability in scope that the artefact does not demonstrate. If requirements-ide.spec.ts does not exercise a feature of the meta-DSL, the series does not commit to it. Semantic tokens, rename, inlay hints, code actions — all of them are valid future additions; none of them are claimed here. The artefact is the scope; the scope is the artefact. This is the single commitment that keeps the series from ballooning into a wishlist disguised as a design.
Those six are the posture. Part 04 applies them: we take the SOLID guardrail seriously and derive, from existing monorepo files, the principles the meta-DSL's architecture will rest on. The shape of the forge starts to emerge — not from the ceiling down, but from the code that already works.
Build counterpart
The six guardrails above are taken up one by one in the companion build series, Ide.Dsl — Build. The TC39-standard-decorators guardrail is instantiated by Build 01 — Bootstrapping @frenchexdev/ide-dsl; the "activation events derived from IR, never onStartupFinished" corollary of the business-visible posture is enforced by Build 08 — Extension host + client. The port-driven-testability guardrail is honoured from Build 02 — The extractor onward, where every emitter takes a FileSystem port in its constructor.