DSLtypical

Implementing a DSL

Five ways to make a domain language actually run: interpret the tree, compile to the host language, compile to bytecode, generate code at build time, or embed it as schema-validated data. They differ in performance, in debuggability and in who sees the error.

The question

I have a grammar and an AST for my domain language. What do I do with it?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

An AST for the domain program, and a choice about what it becomes. Interpretation keeps the tree and walks it. Compiling to the host emits host source or host AST nodes, after which the host owns everything. Compiling to bytecode produces a linear instruction stream for a small VM you write. Build-time generation produces host source as an artifact in the repository. Embedding as data means the "AST" is a parsed JSON or YAML document and the checker is a schema. Each answers a different question about where errors surface and who debugs them.

What this phase may assume or do

Every strategy must preserve the language's defined semantics — evaluation order, effect ordering, error behaviour — and each has a precondition of its own. Compiling to the host is sound only if the host's semantics for each construct match the DSL's exactly: host integer overflow, host string comparison and host evaluation order become the DSL's unless the emitted code explicitly prevents it. Generating code at build time is sound only if generation is deterministic and the output is regenerated whenever the input changes, or the artifact and the source silently diverge. Embedding as data is sound only if the schema rejects every document the domain considers invalid, since there is no other checker.

Key points

  • Five strategies: walk the tree, compile to the host, compile to bytecode, generate code at build time, or embed as schema-validated data.
  • Tree walking is the cheapest to build and the best at error attribution, because the evaluator is holding the node and its span.
  • Compiling to the host buys the host's optimizer and debugger and risks inheriting the host's semantics where they differ from yours.
  • Bytecode buys bounded execution and a sandbox, which is what makes it the right answer for untrusted programs.
  • Build-time generation moves every error to build time in your own vocabulary, at the cost of a build-graph dependency that must be declared correctly.
  • JSON or YAML with a schema is a DSL, and it is the right size exactly while the programs contain no control flow.
  • The strategies mix: a shared front end with a tree-walking reference implementation and a compiled fast path is a strong default.
  • Source spans surviving into every user-visible message is what decides whether the language is usable, and it is the part most implementations skip.

Walk the tree

The simplest thing that works: keep the AST and write an evaluator that recurses over it. A hundred lines for a small language, easy to debug because everything is a host-language call stack, and easy to extend because a new construct is a new case in one function.

It is also slow — every evaluation re-dispatches on node types and re-walks the tree — and slow is often fine. A validation rule set evaluated once per request, a configuration expression evaluated at startup, a query filter over a few hundred rows: none of these care. The rule of thumb is that tree walking is right until the program is evaluated in a loop over many items, at which point the dispatch overhead starts to dominate the actual work.

The thing tree walking gets uniquely right is error attribution. The evaluator is holding the node, and the node has a source span, so a run-time failure can point at the exact character range in the user's file. Every other strategy has to work to preserve that, and several lose it. For a language whose users are not programmers, this alone can settle the choice — see [[tree-walk-interpreter]].

Compile to the host language

Translate the DSL program into host-language source or host AST nodes, and let the host compiler do the rest. The generated code then runs at full host speed, is optimized by the host optimizer, and is debugged by the host debugger. For a domain language whose programs are hot, this is usually the highest-value strategy per unit of effort, because the entire back end is somebody else's.

The catch is semantic drift, and it is subtle. Emitting a + b for the DSL's addition inherits the host's addition: its overflow behaviour, its string-versus-number coercion, its precision. If the DSL specifies saturating arithmetic and the host wraps, the generated code is wrong in a way that appears only at the boundary. Every construct needs its semantics checked against the host's rather than assumed, and where they differ the emitted code must be explicit — a call to a helper rather than an operator.

The second catch is diagnostics. A type error in the generated code is reported by the host compiler, at a line in a generated file, in the host's vocabulary. Users see a message about a construct they never wrote in a file they have never opened. Mitigating this means either checking thoroughly enough in your own front end that the host never rejects the output — the right answer — or emitting source maps so positions map back, which is [[source-maps]] and is real work.

One rule, compiled to the host
1// DSL:
2// age: required int in 0..150
3
4// Emitted host code:
5fn check_age(doc) -> Result {
6 let v = doc.get("age")
7 if v == null { return Err(missing("age", span(1,1,1,4))) }
8 if !is_int(v) { return Err(type_error("age", "int", span(1,10,1,13))) }
9 if v < 0 || v > 150 { return Err(range("age", 0, 150, span(1,17,1,23))) }
10 Ok
11}

Every error carries a span from the DSL source, threaded through the generated code as a literal. That threading is the price of keeping the user's file at the centre of the error message rather than the generated one, and skipping it is the most common way this strategy produces an unusable language.

Compile to bytecode

Between the two: lower the AST to a linear instruction sequence for a small virtual machine you write, and interpret that. The dispatch is now a switch over opcodes on a flat array rather than a recursive walk over pointers, which is typically several times faster than tree walking for the same program, and far less work than compiling to the host.

Bytecode buys three things beyond speed. The program becomes a compact artifact that can be cached, shipped and versioned separately from its source. Execution can be bounded: a step counter in the dispatch loop gives a hard limit on how long a program can run, and a memory cap gives one on how much it can allocate — which matters enormously for any language that runs untrusted programs. And the VM is a natural sandbox, because a program can only do what the instruction set permits.

The cost is that you now own a VM, an instruction set and a debugging story for both. A bug in the compiler produces bytecode that misbehaves, and diagnosing it means reading the instruction stream. If the language is small and the performance is adequate from tree walking, this rung is skippable — see [[bytecode]] and [[stack-based-vm]] for the machinery itself.

Five strategies against what they cost and what they givetypical
StrategySpeedEffortError attributionBest when
Tree walkingSlowestLowestExcellent — the node has the spanPrograms run once per request or at startup
Compile to hostHost speedModeratePoor unless spans are threaded or source maps emittedPrograms are hot and the host is the deployment target
Compile to bytecodeSeveral times faster than tree walkingHigh — you own a VMGood if the instruction stream carries spansUntrusted programs, or a cacheable versioned artifact
Generate code at build timeHost speedModerateGood — errors surface at build time, in your own front endThe program set is known before deployment
Data plus schemaWhatever the consumer doesLowest of allGood — schema validators report a path into the documentPrograms are declarations with no control flow

Generate code at build time

typicalMainstream code generators declare the generated file as a build output depending on the specification file, so an out-of-date artifact is rebuilt rather than used. Where generation is run by hand — a script someone remembers to invoke — stale output is the standard failure, and it presents as behaviour matching an older version of the rules with no diff to explain it. Whether your build system tracks this properly is worth checking before choosing this strategy rather than after.

The same translation as compiling to the host, moved earlier: run it during the build, write host source into the repository or into a build directory, and compile it with everything else. Protocol Buffers, GraphQL client generators, ORM model generators and parser generators all work this way.

The advantage is that every error moves to build time, in your own front end, with your own messages, before anything is deployed. The generated code is ordinary host code — readable, steppable, profilable — and the host toolchain sees it as it sees everything else. For a language whose program set is known ahead of deployment, this is frequently the best of all the options.

The costs are build-system costs. The generated artifact must be regenerated whenever its input changes or the two silently diverge, which means the dependency has to be declared properly — a build-graph problem, and the usual failure is a stale artifact that compiles fine and behaves as an older version of the rules. Checking generated code into the repository makes diffs noisy and merges unpleasant; not checking it in makes the build a prerequisite for reading the code. Both are defensible and both are annoying.

Embed it as data

The cheapest strategy, and the one people do not count as implementing a language: express the programs as JSON, YAML or TOML, validate them against a schema, and interpret the resulting structure. There is no parser to write — the format has one — no grammar to design, and the schema ecosystem provides validation, editor completion and documentation generation for free.

It is worth being explicit that this *is* a DSL. A JSON document with a schema, interpreted by an engine that gives its fields meaning, is a program in a language whose grammar happens to be JSON's. Calling it configuration rather than a language does not change what it is, and it does change how carefully people think about its design — which is precisely why so many of these grow into unmaintainable systems one field at a time.

The strategy is right when the programs are genuinely declarations: no conditionals, no loops, no name binding, no reuse. It stops being right the moment any of those is needed, and the standard failure is to add them anyway, in the format, as templating or as string-encoded expressions. At that point you have a language with no parser, no types and no diagnostics, which is the most expensive point on the whole spectrum — see [[configuration-languages]] for that trajectory in detail.

  • The format provides the parser, so the schema is the entire front end.
  • Schema validators report a path into the document, which is a usable error message at zero cost.
  • Editor completion and documentation come from the schema in most mainstream editors.
  • It is a language whether or not you call it one, and it deserves the same design care.
  • The moment it needs a conditional, it has outgrown the strategy — and adding one anyway is the standard failure.

Choosing, and mixing

The strategies are not exclusive and the good implementations mix them. A common and effective shape: parse and check in a front end you own, interpret by tree walking for correctness and for the error messages, and add a compilation path later only for the programs that turn out to be hot. Because the front end is shared, both paths agree by construction, and the tree walker becomes the reference implementation the compiled path is differentially tested against — which is [[differential-testing]] applied to your own language.

The decision order that works: start with the cheapest strategy that could plausibly serve, and move only when a measurement says to. Data plus schema if the programs are declarations. Tree walking if they are not. Bytecode if execution needs bounding or the programs are evaluated in a loop. Host compilation or build-time generation if they are genuinely hot or the program set is known in advance.

And whichever is chosen, the error path is the part that determines whether the language is usable. Spans from the source file must survive into every message the user can see, at parse time, at check time and at run time. Every strategy above can do this and most implementations of each do not, and that single omission is the difference between a language people adopt and one they route around.

How it works

The steps, in the order the compiler takes them.

  • Parse to an AST with a source span on every node, because every strategy below depends on those spans for its diagnostics.
  • Check the program in a front end you own, and report every error you can there rather than letting a downstream compiler or a run-time failure report it.
  • Tree walking: write an evaluator recursing over the AST, carrying an environment, and attach the current node's span to every error it raises.
  • Host compilation: emit host source or host AST nodes, replacing any construct whose host semantics differ from the DSL's with an explicit helper call, and thread source spans into the emitted error paths.
  • Bytecode: lower the AST to a flat instruction sequence with a span table indexed by instruction offset, and write a dispatch loop with a step budget and a memory cap.
  • Build-time generation: run the compilation as a build step, declare the generated file as an output depending on the specification, and fail the build on any error rather than emitting partial output.
  • Data plus schema: define the schema as the checker, validate before interpreting, and rely on the validator's document path as the error location.
  • Whichever is chosen, keep one front end so that all execution paths share a semantics, and differentially test any fast path against the reference implementation.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A run-time error in the DSL reports a host-language stack trace through the evaluator, with nothing identifying which line of the user's file failed.
  • Compiled output inherits host semantics that differ from the language's — integer overflow wrapping where the spec said saturate, or string comparison ordering — and the discrepancy appears only for boundary inputs.
  • A user sees a compile error from the host toolchain, in a generated file they cannot open, naming constructs they did not write.
  • A generated artifact goes stale because the build dependency was never declared, and the system behaves as an older version of the rules with nothing in the diff to explain it.
  • An untrusted program in a tree-walking interpreter loops forever and pins a request thread, because nothing in the evaluator counts steps.
  • A YAML-plus-schema configuration grows templating to express a conditional, and validation becomes impossible because the document is no longer valid YAML until it is rendered.
  • A fast path is added and diverges from the tree walker on an edge case nobody tests, so the same program behaves differently depending on how hot it was.

When it helps

  • Tree walking: small languages, cold paths, non-programmer audiences where the error message matters more than the throughput.
  • Host compilation: hot programs, where the entire host optimizer becomes available for the cost of an emitter.
  • Bytecode: untrusted programs needing step and memory limits, and programs shipped as a cacheable versioned artifact.
  • Build-time generation: a program set known before deployment, where every error can be moved to build time.
  • Data plus schema: declarative programs with no control flow, where the existing schema ecosystem is the whole front end.

When it hurts

  • Tree walking, once the program is evaluated per item in a large loop and dispatch overhead exceeds the work.
  • Host compilation, when the host's semantics differ subtly from the language's and every construct must be audited rather than assumed.
  • Bytecode, for a language small enough that tree walking would have been fast enough — the VM is a permanent maintenance obligation.
  • Build-time generation, when programs must be authored or changed at run time, which the strategy cannot support at all.
  • Data plus schema, the moment the domain needs abstraction, since the usual response is to add templating and lose validation entirely.

What it costs

Every one of these is paid by something.

  • Tree walking buys the shortest path to a working language with the best error attribution, and pays per-node dispatch overhead on every evaluation — fine once per request, unacceptable per row.
  • Compiling to the host buys the host's optimizer, debugger and profiler for the cost of an emitter, and pays in semantic auditing plus diagnostics that surface in generated files unless spans are threaded or source maps emitted.
  • Bytecode buys bounded, sandboxable execution and a compact shippable artifact, and pays with an instruction set, a VM and a debugging story for both that you now own indefinitely.
  • Build-time generation buys errors at build time in your own vocabulary and ordinary host code downstream, and pays a build-graph dependency that silently produces stale behaviour when it is declared incorrectly.
  • Data plus schema buys a free parser, free validation and free editor support, and pays a hard ceiling: the first genuine need for a conditional either ends the strategy or ends the validation.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Embed an existing language — Lua, Starlark, CEL, Rego, Jsonnet — and get a specified semantics, a sandbox and a community instead of writing any of this. Usually the best answer when the domain fits one of them.
  • Compile to WebAssembly rather than to the host, which buys a portable sandboxed target with resource limits and existing tooling — see [[webassembly]].
  • Use a parser generator for the front end and hand-write only the back end, which removes the part of the work that is most mechanical — see [[parser-generators]].
  • Skip execution entirely: make the DSL a specification that generates documentation, validation and clients, with no run-time component at all. This is what schema languages do and it is a legitimate end state.

See it for yourself

The flag, dump or tool that shows you this directly.

  • Compare strategies on your own language: time a tree-walking evaluation against a compiled one on the same program, and measure before deciding the tree walker is too slow.
  • For host compilation, read the emitted source. If it is unreadable, that is what your users will see in a stack trace, and that is a design output rather than an implementation detail.
  • For generated code, delete the artifact and rebuild. If the build succeeds without regenerating it, the dependency is not declared and stale output is a matter of time.
  • For bytecode, dump the instruction stream for a small program and check that every instruction can be mapped back to a source span — python -m dis is a good model for what that dump should look like.
  • For schema-embedded DSLs, feed the validator a document that is wrong in a domain sense but structurally valid. If it passes, the schema is not the checker you think it is.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Compiling is always better than interpreting." Compiling to a host with different semantics is a correctness risk, and for a program evaluated once per request the difference is unmeasurable.
  • "A bytecode VM is overkill for a small language." It is, unless you need to bound execution — at which point it is the cheapest way to get a step limit that actually works.
  • "Generated code is an implementation detail." Users read it in stack traces and step through it in debuggers. It is an interface.
  • "JSON with a schema is not really implementing a language." It is, and treating it as configuration rather than as language design is how these systems grow into unmaintainable ones.
  • "We can add source spans later." Spans have to be threaded through every stage from the first line of the parser; retrofitting them means touching every stage — which is [[source-locations]], and it is the same lesson at a smaller scale.

Misconceptions

The claim, and what is actually true.

Implementing a DSL means writing a compiler.
Most successful small DSLs are tree-walking interpreters or schema-validated data, and never emit an instruction.
Compiling to the host language means the DSL inherits the host's correctness.
It inherits the host's semantics, which is only correctness if they match the DSL's. Where they differ, the generated code has to say so explicitly.
The execution strategy is an internal decision users do not see.
It determines where errors appear, in whose vocabulary, and whether the program can be stepped through — all of which users see immediately.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

Once you can parse your language you have to make it do something. The easy option is to walk the tree and evaluate as you go, which is slow and gives excellent error messages. The fast option is to translate it into your ordinary programming language and let that compiler handle it. In between is compiling to a small instruction set you interpret, which is what you want if the programs are untrusted and you need to cut them off after a while. And if the programs have no logic in them at all, JSON plus a schema is already a language and needs none of the above.

practical

Pick the cheapest strategy that could work and move only when you have measured. Whatever you pick, thread source spans through every stage from day one: the single thing that decides whether people can use your language is whether an error says "line 12, column 4, in your file" or produces a stack trace through your evaluator. And if you add a fast path later, keep the original as a reference implementation and test the two against each other on every program you have — divergence between two implementations of an unwritten specification is otherwise inevitable.

advanced

These five strategies are the same ladder the rest of this domain describes, compressed. Tree walking is the AST interpreter; bytecode is a compiler with a VM back end; host compilation is a transpiler; build-time generation is ahead-of-time compilation with your own front end. That means every trade in the wider domain applies here at smaller scale, including the ones people forget: an interpreter has better error attribution because it still holds the representation the error is about, a compiler is faster because it discarded that representation, and the whole discipline of source maps and debug information exists to buy back what the discarding lost. The specific decision worth thinking hardest about is bounded execution. A tree walker can count steps, but only if every recursion site checks; a bytecode VM gets it for free in the dispatch loop, which is why every system that runs untrusted user programs converges on one — and why "we will just interpret the tree and add a timeout" is a plan that does not survive its first pathological input.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

typicalThe rough performance ordering — tree walking slowest, bytecode several times faster, host-compiled fastest — holds for the small languages this lesson is about and is not a law. A well-optimized tree walker over a small AST can beat a naive bytecode VM, and the gap between bytecode and host code narrows sharply when the program spends its time in built-in operations rather than in dispatch. Measure your own language before restructuring it.
implementationWhich strategies are available depends on the host. Compiling to the host is straightforward where the host has an eval or a macro system (JavaScript, Lisp, Ruby, Rust proc macros) and awkward where it does not, in which case build-time generation is the practical form of the same idea. Sandboxing a bytecode VM is only as good as the instruction set: a VM whose instructions can call arbitrary host functions provides no isolation at all, whatever the step limit says.
specWhere a DSL's semantics are written down, the specification is what the implementations must agree on, and multiple strategies for the same language must be differentially tested against it. Where they are not written down, the reference implementation is the specification by default — which means a fast path added later is not an optimization but a second implementation of an unwritten standard, and divergence between them is a matter of time rather than of care.

If you were asked this in an interview

  • You have an AST for a rules language evaluated once per request. What execution strategy do you pick and why?
  • What changes if those rules are supplied by untrusted users?
  • What can go wrong when you compile a DSL to the host language rather than interpreting it?

Connections