Diagnostic Quality
`syntax error` versus `Expected ')' after function arguments. Found '{' instead.` The difference is not politeness — it is a span, a named expectation, and a note about the thing that caused it, each of which the compiler had to be built to keep.
What separates an error message that helps from one that is technically correct?
A diagnostic as a structured value, not a string: a severity, a message, a primary span, zero or more labelled secondary spans, notes, helps, suggested edits and a stable error code. Rendering to a terminal, to JSON, or to an LSP Diagnostic are then three views of the same value. A compiler that builds strings instead cannot add a secondary label later, cannot emit machine-applicable fixes, and cannot serve an IDE without a second implementation.
A diagnostic must point at source the user actually wrote, or explicitly say that it does not. Every span in it must index the original buffer exactly, every secondary label must be reachable from the primary one by a reader, and any span on compiler-generated code must be marked as such rather than presented as the user's. Later stages are entitled to assume that a diagnostic they emit will be rendered against the unmodified source — which is why nothing in the pipeline may normalise the buffer after positions were assigned.
Key points
- A useful message locates, names what was expected, names what was found, and explains the enclosing context.
- The vocabulary must be the language's, not the grammar's:
')', neverRPARENand never a token number. - A diagnostic should be a structured value — severity, spans, labels, notes, helps, code — so terminal, JSON and LSP output are three views of one thing.
- Rendering with a caret over the exact range requires spans to be ranges into the unmodified buffer, which is a decision made in the lexer.
- Secondary labels are what turn "type mismatch" into a message naming both sides and where each came from.
- Notes explain, helps propose an action, and keeping them distinct lets a reader skip to the actionable line.
- Stable error codes decouple a terse message from long-form explanation, which lets the message stay terse.
- The expensive part is not the renderer; it is the context every earlier phase had to retain, and that cannot be retrofitted cheaply.
Four things a message has to do
Compare the two extremes. syntax error tells the user that something is wrong and nothing else; every remaining question — where, what was expected, what was found, what to do — is left to them. Expected ')' after function arguments. Found '{' instead. answers three of the four in one line, and the answer to the fourth is usually obvious once the first three are known.
The structure that produces the second message generalises. Locate: a span the user recognises, pointing at the construct rather than at the token where the parser gave up. Name the expectation: what the compiler wanted here, in the vocabulary of the language rather than of the grammar — ), not RPAREN, and certainly not expected token 47. Name what was found: the contrast is what makes the message actionable, and it is free, because the parser is holding the token. Explain the context: which construct was being parsed, and where it started, which is what turns "unexpected {" into "the argument list that opened on line 12 was never closed".
AtlasLang's parser emits exactly this shape, and it is worth noticing how little machinery it takes. expect builds the message from the token kind it wanted and the context string the caller passed, and the help from the token it actually found. The context string — 'after function arguments' — is a parameter at the call site, which is the entire difference between a generic message and a specific one.
1function expect(kind: TokenKind, context: string): Token | null {2 if (at(kind)) {3 panicking = false4 return advance()5 }6 report(7 `Expected ${describe(kind)} ${context}.`, // what was wanted, and where8 peek(), // the span9 `Found ${describe(peek().kind)} instead.`, // the contrast, as a help10 )11 return null12}13 14// At the call site the context is what makes it specific:15expect('RPAREN', 'after function arguments')16expect('LBRACE', 'to start the function body')describe maps a token kind to how a user would say it — RPAREN becomes ')' — so the vocabulary of the message is the language's rather than the parser's. That mapping is twenty lines and it is the difference between a message a user can act on and one they have to decode.
Rendering: the caret is the message
A rendered diagnostic is mostly typography, and the typography is doing real work. The line of source reproduced verbatim proves the compiler is talking about the code the user has open. The caret or underline shows exactly which characters, at exactly the width of the construct — which requires the span to be a range rather than a position. The gutter with the line number and the file path makes it navigable. A secondary label on a different line connects two places, which is what turns "type mismatch" into "this returns int, and this signature says str".
Two additional layers separate good from excellent. A note explains why the rule exists or where the conflicting information came from — "the expected type comes from the annotation here". A help proposes an action, in the imperative — "consider adding a semicolon", ideally with the exact text to insert, which is where [[suggested-fixes]] takes over. rustc's convention of distinguishing note: from help: by whether the line is explanatory or actionable is worth copying, because it lets a reader skip to the actionable line.
The other quiet feature is a stable error code. E0308 is searchable, it is stable across releases, and it can be looked up for a long-form explanation with rustc --explain E0308. That decouples the terse message from the tutorial, which means the terse message can stay terse.
Elm is the other reference point and it made a different bet: prose. Its messages are written like a colleague explaining the problem, with hypotheses about what you meant and pointers to documentation. It is slower to read for an expert and dramatically better for a newcomer, and it demonstrated that diagnostic quality was a product decision rather than a compiler-engineering constraint. Most of what rustc did afterwards is visibly downstream of it.
1. Minimal
error: syntax error
2. Located
main.at:12:24: error: syntax error
3. Structured
main.at:12:24: error: Expected ')' after function arguments. Found '{' instead.
4. Rendered, with a secondary label
error[E0061]: expected ')' after function arguments
--> main.at:12:24
|
12 | fn scale(x: int, y: int {
| - ^ expected ')' here, found '{'
| |
| unclosed delimiter opened here
|
= note: a parameter list must be closed before the function body begins
= help: insert ')' before '{'What it costs to build, and what makes it impossible to retrofit
--> locator, the gutter, primary and secondary labels, note: for explanation and help: for action, plus E-prefixed codes with --explain. Clang uses a similar caret style with note: diagnostics attached to a primary one but no stable code registry; Elm renders long-form prose with no gutter at all; MSVC emits a single line with a Cxxxx code. All four are defensible and their differences are product decisions, not technical limits.The rendering is the cheap part — a few hundred lines, written once. What is expensive is everything upstream that has to be true before it can run, and every one of those things is a decision made early.
Spans must be ranges into the unmodified buffer, on every node, including the extra spans that let a message underline the operator rather than the expression. Diagnostics must be structured values rather than strings. Constructs must record where they *started*, not merely where they failed, or every message points one line late. Compiler-generated nodes must be distinguishable from user-written ones, or a desugared construct produces a message about code the user never wrote. And name resolution and type checking must keep enough information to say *why* — which declaration, which annotation, which inference step — rather than only *that*.
None of that can be added afterwards without touching every construction site in the frontend, which is why diagnostic quality correlates so strongly with when a project started caring. It is also why the same compiler can have excellent messages in one area and terrible ones in another: the areas differ in how much context their phase happened to retain.
The measurable payoff is not the message; it is the round trip. A message that names the expected token, the found token and the construct usually ends the investigation immediately. A syntax error sends the user to read the surrounding twenty lines, and on a slow build each unhelpful message costs a full compile cycle. That is the argument that gets diagnostic work funded, and it is a better one than aesthetics.
| Element of the message | Requires | Why it cannot be added later |
|---|---|---|
| A location at all | Spans recorded in the lexer | A frontend that dropped positions cannot recover them |
| An underline of the right width | A range, not a position | Halving a span to a point is lossy |
| Pointing at the construct, not the failure | The construct's start span threaded down the parser | Every production must pass it; that is a signature change |
| Naming the expected token | A token-kind-to-user-text mapping | Cheap — this one genuinely can be added later |
| A secondary label elsewhere | A second span, kept by the phase that found the conflict | The declaration's span must have survived name resolution |
| An explanatory note | The reason recorded at the point the rule was applied | By the time the error is raised, the reason has usually been forgotten |
| A machine-applicable fix | A zero-width-capable span and a replacement string | Needs half-open ranges — see [[spans-and-ranges]] |
| A stable error code | A registry and a compatibility policy | Codes assigned late are unstable, which defeats their purpose |
How it works
The steps, in the order the compiler takes them.
- A phase detects a problem and constructs a diagnostic value with a severity, a message and a primary span.
- It attaches secondary spans for the other places involved — the declaration, the annotation, the opening delimiter — each with its own short label.
- It attaches notes explaining where a conflicting fact came from, and helps proposing an action, with a suggested edit where one is confidently known.
- An error code is assigned from a registry so the message can be searched and explained separately.
- The emitter deduplicates, applies suppression for cascading diagnostics, and enforces any configured limit.
- At render time each span is converted from a byte offset to (line, column) in the unit the consumer needs, and the source line is sliced from the original buffer.
- The renderer draws the gutter, the source line, the caret or underline at the span's width, and the labels beneath, ordering multi-line diagnostics so the primary span reads first.
- The same structured value is serialised to JSON for tooling and converted to an LSP
DiagnosticwithrelatedInformationfor the secondary labels.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- The message says
syntax errorand the user reads twenty lines of their own code to find out where. - The error points at the line after the mistake, because the span used was the unexpected token's rather than the construct's start.
- The message names an internal token kind, and the user searches the web for
RPARENrather than for the missing parenthesis. - A type error names the mismatch but not either source, so the user cannot tell which side to change.
- The underline is one character too wide on lines containing accented characters, because the width was computed from a byte span.
- The IDE shows a plainer version of the message than the terminal, because the LSP path re-renders a string instead of converting the structured value.
- A diagnostic about a desugared construct points at code the user did not write, with no indication that it was generated.
- A team adds error codes late and renumbers them in the next release, so every search result on the internet now points at the wrong error.
When it helps
- Any compiler with human users, where every unhelpful message costs at least one edit-compile round trip.
- Teaching languages and onboarding, where the message is frequently the only documentation a newcomer reads.
- Language servers, where the same diagnostic must render in a terminal, an editor gutter, a hover and a problems panel from one source of truth.
- Justifying frontend work to a team: round trips per error is a measurable number, and it is what diagnostic quality actually moves.
When it hurts
- Verbosity for its own sake. A five-line explanation attached to a trivial typo is skipped, and skipping trains users to skip the ones that mattered.
- Guessing at intent without evidence. A confidently wrong explanation is worse than a terse correct one, because it sends the user to change the wrong thing.
What it costs
Every one of these is paid by something.
- Structured diagnostics buy multiple renderings, machine-applicable fixes and testable output, and pay a type that every phase must construct rather than a string it can format inline.
- Multiple spans per diagnostic buy messages that name both sides of a conflict, and pay the memory and the discipline of keeping the second span alive through the phases between where it was created and where the error is raised.
- Long-form prose in the Elm style buys comprehension for newcomers and pays reading time for experts, plus a large ongoing writing effort that is nobody's specialty by default.
- Stable error codes buy searchability and long-form explanation, and pay a compatibility obligation: once published, a code cannot be reused or renumbered without invalidating everything written about it.
- Threading construct-start spans through every production buys messages that point at the right place and pays a signature change across the entire parser, which is why it is done at the start or not at all.
What else you could do
What a different compiler or language does instead, and when that is better.
- Emit strings and format at the call site. Fastest to write, and it forecloses IDE integration, machine-applicable fixes and snapshot testing of the rendered output.
- Prose-first messages in the Elm style, optimised for a reader who does not yet know the language, at the cost of density for one who does.
- A separate explanation database keyed by error code, as rustc does with
--explain, which keeps the inline message short while making the long form available. - Diagnostics as data only, leaving rendering entirely to the client — which is effectively what a language server does, and what a compiler with a JSON output mode offers to CI tooling.
- Lint-style diagnostics with configurable severity and per-rule documentation, which is the direction linters took and which compilers borrow for their warning sets —
[[linters]].
See it for yourself
The flag, dump or tool that shows you this directly.
- rustc:
--error-format=jsonshows the structured value behind a rendered message, including every secondary span and suggestion, which is the clearest demonstration that the string is a view. rustc --explain E0308prints the long-form explanation for a code, which is the decoupling in action.- Clang:
-fcaret-diagnostics,-fdiagnostics-show-note-include-stackand-fno-elide-typechange how much of the structure is rendered; comparing them on one error shows what the value contained all along. - Compile the same deliberately broken program with
rustc,clang,tscandelmand read the four messages side by side. The differences are product decisions and they are visible in one screen. - Our
/compilers/pipelineshows the AtlasLang diagnostics list with spans, helps and thecascadingflag, so the structured form is visible before any rendering.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Good error messages are a matter of wording." Wording is the last ten per cent. The rest is spans, secondary labels and context that earlier phases had to keep.
- "The compiler cannot know what I meant." It frequently can, from the enclosing construct and the token it found, and the message improves enormously just by saying what it wanted and what it got.
- "More detail is better." A message people skip conveys nothing. Structure — primary line terse, notes and helps beneath — beats length.
- "This is a polish task for later." Almost every element requires information that earlier phases must have retained, so "later" usually means "never, without a frontend rewrite".
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
A good compiler error tells you where the problem is, what it wanted there, what it found instead, and what construct it was in the middle of. A bad one tells you that something is wrong. The difference is not the wording — it is that the compiler kept enough information to say all four, and rendering it with the source line and a caret underneath is what makes it readable.
practical
Make diagnostics a structured type on day one: severity, primary span, labelled secondary spans, notes, helps, suggestions. Map token kinds to how a user would say them. Pass a context string at every expect call site — that one parameter is the difference between a generic message and a specific one. Point at where the construct started rather than where the parser gave up. And snapshot-test the rendered output, because span and layout regressions are invisible to behavioural tests and obvious in a diff of the printed text.
advanced
The deeper pattern is that a diagnostic's quality is bounded by how much *reasoning* the phase that raised it retained, and phases are usually built to forget. A type checker that unifies types and reports a mismatch can say what mismatched; one that keeps the constraint's provenance can say which annotation and which inference step produced each side, and that is a different message entirely. rustc's work on inference-error diagnostics is largely provenance plumbing rather than wording. The same is true for macro expansion, for trait resolution and for lifetime errors: in every case the improvement came from carrying a reason alongside a fact, at a cost paid on every fact whether or not it is ever reported. That cost is why diagnostic quality reads as a compiler-architecture property rather than a presentation one — and why the compilers with the best messages are the ones that decided to pay it before they had users.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
note:/help: split and stable E-prefixed codes with --explain are rustc's design at its current version; Clang renders carets with attached notes and no stable code registry, Elm renders prose with no gutter, and MSVC emits one line plus a Cxxxx code. The elements generalise, the layout does not, and every one of these has changed its rendering across releases.If you were asked this in an interview
- Take
syntax errorand improve it, naming each thing you add and what the compiler needed to keep to say it. - Why can a compiler that formats diagnostics as strings not serve a language server well?
- A type error says "expected int, found str". What would you add, and where would the information come from?
Connections
- Testing & Reliability Engineering — Snapshot testing of user-facing outputDiagnostic regressions — a shifted caret, a lost secondary label, a reordered note — are invisible to tests that check only whether compilation failed. The defence is snapshotting the exact rendered text, a general technique owned there and applied here as
[[golden-tests]].