---
title: 'Domain-Specific Languages'
source: 'https://academia.sh/en/courses/rust-advanced/domain-specific-languages'
course: 'The Rust Ecosystem and Advanced Topics'
language: en
updated: '2026-09-15T04:58:43+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Builds the same routing structure with a macro-written version and a hand-written version and shows by running it that both produce the same result'
    - "Measures how many lines the macro version shortens the same job by, by counting the source's own text"
    - 'Shows with an example that a readable macro version carries no guarantee of correctness, since a logically wrong path-function binding passes type checking too'
---

# Domain-Specific Languages

The same routing structure is built separately with a macro-written version and a hand-written version, shown by running it to produce the same result, and the macro version is measured for how many lines it shortens the source by; but a macro's output being readable does not bring correctness — a logically wrong path-function binding passes type checking just as easily.

The previous two lessons showed how a generated body is checked in two different setups: once
through a textual substitution in the same pass, once through a program run in a separate
compilation step. Neither has yet asked a question they share: why is generation preferred over
writing by hand? This topic's last lesson takes up that question. A macro can build an
interface that looks, from the caller's point of view, like a small language of its own — with
its own syntax, its own shortcuts; this is called a domain-specific language. This lesson
builds the same structure with two versions, verifies by running it that both produce the same
result, counts how many lines the macro version shortens, and finally shows that this
shortening carries no guarantee of correctness.

The term domain-specific language points here not to a broad framework but to a narrow,
concrete use: whoever reads the source can write directly in the words of the problem domain (a
path, a handler), without ever seeing the `match` block, function definition, or other
mechanics underneath. This interface requires no new rule from the compiler — the source that
appears after the macro expands is, as seen in the previous two lessons, indistinguishable from
ordinary Rust source. What is gained is only the way the source is **written**; the way it is
**checked** does not change at all.

This definition sets the term domain-specific language apart from its broad use in
programming-language theory, where the term mostly points to an independent language with its
own parser and its own runtime. Here, there is no second language — there is a single language,
Rust itself, and the macro only fits that language's syntax into a shorter pattern for a
specific problem. This is why the interface built in this lesson carries no parser of its own;
parsing is already done by the compiler's own parser, as part of macro expansion.

## The Same Structure, Two Versions

A routing table — a table that binds a path string to a function — can be built either with a
macro version or by hand. The macro version lists path-handler pairs like a list; the
hand-written version pours the same table into a `match` block's arms. Both do the same job:
they turn a path string into a result.

The hand-written form requires repeating three things for every new path: the `match` block
itself, an arm's syntax, and the presence of a catch-all arm. This repetition is not a flaw —
as seen in the Pattern Matching lesson, `match` demanding exhaustiveness is what keeps the
source safe. But the repeated part carries no new information beyond the path-handler pair
itself: the `match`, `path {`, and `_ => -1` lines are identical in every routing table. The
macro writes this fixed pattern once, inside its own definition, and reproduces it on every
call; the caller writes only the part that changes — the pairs themselves.

```rust
// router.rs — the same routing table is built with a macro version and its hand-written equivalent.
macro_rules! build_router {
    ($name:ident { $($path:literal => $handler:expr),+ $(,)? }) => {
        fn $name(path: &str) -> i32 {
            match path {
                $($path => $handler,)+
                _ => -1,
            }
        }
    };
}

fn home_page() -> i32 { 200 }
fn about_page() -> i32 { 200 }

build_router!(route_with_macro {
    "/home" => home_page(),
    "/about" => about_page(),
});

fn route_by_hand(path: &str) -> i32 {
    match path {
        "/home" => home_page(),
        "/about" => about_page(),
        _ => -1,
    }
}

fn main() {
    for path in ["/home", "/about", "/unknown"] {
        let m = route_with_macro(path);
        let e = route_by_hand(path);
        println!("{:<12}macro={} hand={} same={}", path, m, e, m == e);
    }
}
```

```
/home       macro=200 hand=200 same=true
/about      macro=200 hand=200 same=true
/unknown    macro=-1 hand=-1 same=true
```

**MM7.** The macro definition's pattern is made of three parts: `$name:ident` captures the name
of the function to be generated, `$path:literal => $handler:expr` repeats the pair one or more
times (`+`), and `$(,)?` accepts an optional trailing comma after the last pair. Together, these
three parts turn the language's own syntax into a list-like notation: the caller does not write
`match`, only lines up path-handler pairs inside curly braces. All three paths (`/home`,
`/about`, `/unknown`) give exactly the same result in both versions; the `same` column verifies
this one by one.

This expressive interface was built with a declarative macro, not a procedural one — this
choice is not arbitrary. As the previous lesson showed, a procedural macro has to be compiled in
a separate crate, and using it requires a two-stage compilation. The routing table built here is
nothing more than a repeated syntax pattern; wrapping this kind of repetition needs no
hand-processing of a token stream, no separate crate, and no two-stage compilation — a
declarative macro's pattern-matching mechanism is already enough. A procedural macro is
preferred when real computation is needed on the input, not just to repeat a pattern.

The generated `route_with_macro` function and the hand-written `route_by_hand` function have the
same status in the compiler's eyes: both are an `fn`, both carry the same signature, both run
the same `match` logic. This confirms, once again, the finding of the previous two lessons — the
generated body falls into the "still holds" class — but in this lesson the real question is not
whether the body is checked, it is how much shorter **writing** it becomes.

This equivalence has a consequence: when choosing between the two versions, runtime behavior can
never be a criterion — both produce exactly the same thing. The only axis that decides the
choice is how the source is **read** and how it is **written**. As the number of paths grows,
this gap becomes more pronounced: if thirty paths had to be added instead of three, the
hand-written version would repeat the `match` arm's syntax thirty times, while the macro version
would only add thirty pairs. This lesson does not leave that gap as intuition; it measures it
with a number.

## The Source's Own Length

There is no direct way to measure at runtime how many lines a macro actually generates —
expansion happens during compilation, and only the result ever reaches the running program's
hands. What can be measured is the two versions' length **in the source**: how many lines the
macro call itself is, and how many lines the hand-written `match` block doing the same job is.
These two source texts can be kept as string constants and their line counts compared.

This is a separate measurement, independent of the run in the previous section. The previous
section showed that the two versions produce **the same result**; this section shows how much
source each one does **the same job** with. The two complement each other: without the first,
the second would be meaningless — there is no point praising a short but wrong version for being
short. This is why this lesson verified the equivalence by running it before moving to the line
count; the order was chosen deliberately, since the reverse would have created the impression
that brevity stands in for correctness.

```rust
// line_count.rs — the same routing definition is kept in two versions, and the line counts are compared.
fn main() {
    let macro_version = "build_router!(route_with_macro {\n    \"/home\" => home_page(),\n    \"/about\" => about_page(),\n});";
    let hand_version = "fn route_by_hand(path: &str) -> i32 {\n    match path {\n        \"/home\" => home_page(),\n        \"/about\" => about_page(),\n        _ => -1,\n    }\n}";

    let macro_lines = macro_version.lines().count();
    let hand_lines = hand_version.lines().count();
    println!("macro version: {} lines", macro_lines);
    println!("hand version: {} lines", hand_lines);
    println!("lines saved: {}", hand_lines - macro_lines);
}
```

```
macro version: 4 lines
hand version: 7 lines
lines saved: 3
```

**MM8.** This measurement was set up for the same reason that explained why the `--extern` path
was never printed on screen in the previous lesson: there is no way to read a macro's expansion
at runtime, so the measurement measures the one thing that can be carried into runtime — the
source's own text. The Rust's Design Goals lesson measured the rejection of an implicit type
conversion by the same method, comparing the character counts of two source lines; here the unit
is lines, not characters, but the method is the same: treating the source itself as data,
instead of running it.

The hand-written routing is seven lines: the function signature, the `match` header, two arms,
the catch-all arm, two closing braces. The macro call is four lines: the call itself, two
path-handler pairs, the closing. The three-line gap between them comes from the fact that `fn`,
`match`, and the closing braces never have to be written on the macro's side at all — these
three lines are written **once**, inside the macro's own definition, and reproduced on every
call. This ratio does not change as the number of paths grows: every new path-handler pair adds
one line to both versions, but the constant three-line gap between the two versions always stays
the same.

This fixed cost also explains why the measurement prints an absolute number, "lines saved",
rather than a ratio, "how many times shorter". For two paths the gap is three lines, and for
thirty paths the gap would still be three lines — the ratio shrinks, the absolute gap does not
change. This is why the measurement prints the difference, not the ratio: under the course's
rule about numbers, a claim must carry only the quantity that was actually measured, and what
can be measured here deterministically is the line gap between two fixed sources, not a
generalization.

## The Limiting Measurement

A readable version does not bring correctness. The macro never asks whether the path-handler
pairs the caller writes are **meaningful** — it only checks whether each pair is a string
constant and an expression that returns `i32`. This boundary adds to the measurements in the
previous two sections; it does not invalidate them: it remains true that the macro version
produces the same result and shortens the source; but these two gains carry no answer at all to
a third question — whether the table that was built is correct. The example below builds, with
the same macro, a table where a path is mistakenly bound to a function other than the one it
should be.

```rust
// wrong_but_right_type.rs — a logically wrong mapping passes type checking too.
macro_rules! build_router {
    ($name:ident { $($path:literal => $handler:expr),+ $(,)? }) => {
        fn $name(path: &str) -> i32 {
            match path {
                $($path => $handler,)+
                _ => -1,
            }
        }
    };
}

fn successful() -> i32 { 200 }

build_router!(faulty_router {
    "/home" => successful(),
    "/error" => successful(),
});

fn main() {
    println!("/error -> {}", faulty_router("/error"));
}
```

```
/error -> 200
```

**MM9.** The `/error` path, as its name suggests, is expected to represent a failure condition;
but the line that builds the table is mistakenly bound to the `successful()` function, and the
result is `200`. The macro does not reject this, because it has no information with which to
reject it: the `$handler:expr` pattern only says "an expression returning `i32` will go here",
and never asks whether that expression is consistent with the meaning of the path it is called
for. The compiler stays silent for the same reason — `successful()` really does return `i32`,
the signature holds, and type checking passes. The generated body falls into the "still holds"
class once again, because type checking really is running; but here type checking never answers
the question "is this correct", only the question "is this the right type".

In this example, the same mistake could equally well have been written by hand — binding a wrong
function to an arm inside `route_by_hand` would have compiled the same way. The macro does not
**make the mistake easier** here; it just does not **hide** it either. The difference is that
both versions carry the same weakness, but the macro version makes that weakness reachable with
fewer lines and less writing: adding a wrong pair is an act that demands less attention than
changing an arm inside a thirty-line `match` block. A shorter source makes reviewing the source
easier, but it does not replace review.

This observation does not add a fifth class to the course's four — "wrong but type-compatible"
is not a separate class, it is a detail inside the "still holds" class itself. The compiler
really is checking here too, skipping no step; it is only that what it checks is narrower than
the question a human reading the source would ask: "is this expression of the expected type",
not "is this binding correct".

This is a boundary that stands in the same class as the observations of the previous two lessons
but is shown from a different angle. The Declarative Macros lesson showed that the same macro
can be called with two semantically distant types, `i32` and `bool`, that both compile, and that
the macro cannot say which one is meaningful. Here the type is fixed (`i32`); what changes is
**which expression** a string is bound to — but the result is the same: the macro operates at
the syntax level, unable to see that two different bindings are separated **in meaning**, seeing
only that both are formally correct. A readable version only lets the writer express their
intent more briefly; whether that intent was built correctly still has to be checked by whoever
reads it.

The distinction seen in the Procedural Macros lesson still holds here too: even if a procedural
macro read its input token and performed a more complex operation on it, it would still look
only at **type fitness** — because in Rust, type checking always runs after expansion, on the
generated source, no matter which macro form is used. Even though the two macro forms
(declarative, procedural) have different generation mechanisms, the source each one arrives at
goes through the same checking process, and that process's boundary is the same in both: it
answers only the first of the two questions, is the type correct, is the meaning correct.

## Summary

- The same routing structure was built with a macro version and a hand-written version; for
  three paths, both produced exactly the same result, and this equivalence was verified by
  running it and comparing each one individually.
- The generated body falls into the still holds class in this lesson too: the function the
  macro call produces is checked by the same rules as the hand-written function.
- The macro version shortened the same job by three lines; this shortening comes from the macro
  writing its fixed header-and-closing cost once and reusing it on every call, and the absolute
  gap itself does not change as the number of paths grows.
- A readable macro version brings no guarantee of correctness: a logically wrong path-function
  binding compiles just as well, because its type checks out.
- The only thing the macro can check is form — whether an expression is of the expected type;
  there is no mechanism that checks whether that expression matches the caller's intent.
- A declarative macro was chosen to build an expressive interface; a procedural macro is
  preferred only when real computation on the input is needed, not to wrap a fixed pattern.

## Next Step

This topic's three lessons showed that generated source, across three separate setups — in the
same file, in a separate compilation step, inside an expressive interface — always fell into the
same class, still holds. The boundary that stayed constant across all three was the same: the
compiler checks the generated source's **type**, not its **meaning**. The next topic, Low-Level
and Interoperability, deepens this boundary: after these three lessons in which the compiler's
checking never stopped, the next lesson enters a block where checking **really does** stop — and
even there, checking does not shut down entirely; only a few operations open up into the "handed
to the writer" class, and everything else, exactly as here, keeps on holding.
