Skip to content

Programming Language Development: Language Design, Lexing and Parsing, Names and Modules, and Type Systems

Part 1 of 5 of the Programming Language Development reference (plugin programming-language-development), covering §0–§4. Sibling skills: language-irs-optimization-and-backends (§5–§7), language-runtimes-interpreters-and-jits (§8–§9), language-diagnostics-tooling-and-evolution (§10–§14), language-development-reference (§15–§20). Section numbers are shared across the set; a reference written as §N → skill points into that sibling skill.

Currency: Verified August 2026. See §17 → language-development-reference for the currency snapshot and what goes stale first.

How to read this. Reference, not tutorial. Sections are independent. Three markers: - [DURABLE] — theory, algorithms, or a lesson every language implementation has independently learned. Does not expire. - [VERSIONED] — depends on a specific toolchain, standard, or project's current state. Verify against its docs. - [CONTESTED] — competent language designers disagree, publicly and permanently. Both cases given.

⚠️ GOTCHA boxes mark the design mistakes that are cheap to make on day one and impossible to undo on day one thousand.

The framing that organizes everything below: a language is a set of promises you can never take back. Syntax you can deprecate. Semantics you cannot. Every hard decision in this document is really the question "what am I willing to be permanently responsible for?" — and the languages people love and the languages people resent are distinguished mostly by how carefully their designers answered it early.


§0. Routing

0.1 The pipeline

SOURCE TEXT
  │  LEXER ─────────── tokens (+ trivia, spans)                            §2
  │  PARSER ────────── CST/AST (+ error recovery)                          §2
FRONT END
  │  NAME RESOLUTION ─ bind identifiers to declarations; modules, scopes   §3
  │  TYPE CHECKING ─── inference, coercion, trait/class resolution         §4
  │  SEMANTIC ANALYSIS ─ borrow/effect/exhaustiveness/definite-assignment  §4.7
MIDDLE END
  │  LOWER to IR ───── SSA / CPS / ANF; desugar; monomorphize              §5
  │  OPTIMIZE ──────── inline, constant-fold, DCE, GVN, LICM, vectorize    §6
BACK END
  │  INSTRUCTION SELECTION ─ IR → target instructions                      §7
  │  REGISTER ALLOCATION ─── virtual → physical registers                  §7.3
  │  SCHEDULING / EMISSION ─ object code, debug info                       §7
RUNTIME + LINK ───── GC, scheduler, FFI, unwinding, dynamic loading        §8

[DURABLE] The two most consequential structural decisions are made before you write a line of the pipeline: 1. What is your IR, and how many do you have? (§5 → language-irs-optimization-and-backends) Every serious compiler ends up with at least three levels. Deciding this late means rewriting everything. 2. Is the front end reusable by an IDE? (§11 → language-diagnostics-tooling-and-evolution) A compiler designed as a batch source-to-binary pipeline cannot be turned into a responsive language server without substantial rearchitecture. Design for incrementality from day one or accept that you will do it twice.

0.2 The question router

Asked about... Go to
Should this be a language at all? Design philosophy, trade-offs §1
Lexing, parsing, grammars, error recovery, syntax design §2
Scopes, modules, imports, name resolution §3
Type systems, inference, generics, traits, effects, ownership §4
IR design — SSA, CPS, ANF, MLIR, lowering §5 → language-irs-optimization-and-backends
Optimization passes and where they belong §6 → language-irs-optimization-and-backends
Code generation, instruction selection, register allocation, backends §7 → language-irs-optimization-and-backends
Runtime: memory management, GC, concurrency, FFI, exceptions §8 → language-runtimes-interpreters-and-jits
Interpreters, bytecode VMs, JIT §9 → language-runtimes-interpreters-and-jits
Error messages and diagnostics §10 → language-diagnostics-tooling-and-evolution
Incremental compilation, IDE, LSP §11 → language-diagnostics-tooling-and-evolution
Testing, fuzzing, formal verification §12 → language-diagnostics-tooling-and-evolution
Standard library, ecosystem, tooling §13 → language-diagnostics-tooling-and-evolution
Evolution, versioning, governance, deprecation §14 → language-diagnostics-tooling-and-evolution
"Don't do this" §15 → language-development-reference
"Which approach is better?" §16 → language-development-reference (contested)
"Is this still current?" §17 → language-development-reference
Books, papers, people §18 → language-development-reference

§1. Language Design

1.1 The questions that determine everything else

Answer these before implementation; each one propagates through the whole pipeline.

Question Options Downstream consequences
Static or dynamic typing? Static, dynamic, gradual Determines whether the type checker is a phase or a runtime
Memory management? Manual, RAII/ownership, refcount, tracing GC, region/arena The single largest runtime-design driver (§8.1 → language-runtimes-interpreters-and-jits)
Compiled or interpreted? AOT, JIT, bytecode VM, tree-walk, transpile Determines the whole back end
Generics: monomorphize or erase? Monomorphize (C++, Rust), erase (Java, Go pre-1.18), dictionary-pass (Haskell, Swift) Code size vs. compile time vs. runtime cost (§4.5)
Concurrency model? Threads+locks, async/await, actors, CSP, STM, structured concurrency Colours your entire function type system (§8.3 → language-runtimes-interpreters-and-jits)
Mutability default? Mutable, immutable, controlled Affects optimization opportunity enormously
Nullability? Nullable-by-default, option types, non-null-by-default Tony Hoare's "billion-dollar mistake." Option types are the settled answer for new languages
Error handling? Exceptions, result types, error returns, panics Interacts with every ABI and FFI decision (§8.4 → language-runtimes-interpreters-and-jits)
Metaprogramming? None, macros (hygienic or not), reflection, compile-time execution, templates Determines whether your compiler is also an interpreter (§4.8)

1.2 The design principles that hold up

  • [DURABLE] Simplicity is a budget, not a virtue. You get a fixed complexity budget; every feature spends it. The question is never "is this feature good?" but "is this feature worth what it costs in interaction with everything else?" Feature interactions are superlinear, which is why languages get harder to learn faster than they get bigger.
  • Orthogonality: features should compose without special cases. Every special case is a thing to learn, a thing to implement, and a source of bugs at the seams.
  • The principle of least astonishment applies to the population you're targeting, not to language theorists.
  • Make the right thing easy and the wrong thing hard. Rust's success is mostly this principle applied to memory.
  • Errors should be impossible, then caught at compile time, then caught at runtime, then documented — in that order of preference.
  • [CONTESTED] "Worse is Better" (Richard Gabriel, 1989) — the New Jersey school (simplicity of implementation beats completeness; ship it) versus the MIT/Stanford school (correctness and completeness first). C and Unix won with the first; the second produced better artifacts that fewer people used. This tension is unresolved and shows up in every language committee.
  • Hyrum's Law: with enough users, every observable behaviour of your implementation becomes a promise, whether or not you specified it. Specify aggressively, and deliberately randomize what you refuse to promise (Go randomizes map iteration order precisely to prevent people depending on it — a technique worth stealing).

1.3 Syntax

[DURABLE] Syntax is the least important part of a language and the part people argue about most. That said, it is the interface, and interfaces matter: - Familiarity has enormous value. Jakob's Law applies to languages: users arrive with expectations from every other language they know. Novel syntax is a tax paid on every reader forever, and it should buy something real. - Readability > writability. Code is read far more than written. Perl and APL optimized the wrong one. - Prefer unambiguous grammars. If your grammar needs unbounded lookahead or a "lexer hack," your tooling — formatters, highlighters, IDEs, other implementations — will suffer forever. C's (a)*b ambiguity (cast or multiply? depends on whether a is a type) is the canonical example. - Design for tooling from the start: a formatter, a syntax highlighter, and an IDE all want a lossless CST with trivia preserved (§2.4).

⚠️ GOTCHA — significant whitespace and tabs. If you choose indentation-sensitive syntax, you must specify the tab/space interaction exactly, at the lexer level, in version one. Python took until Python 3 to make mixing an error, and it caused real bugs for a decade.

⚠️ GOTCHA — reserve keywords generously. Adding a keyword later breaks every program using it as an identifier. Languages handle this with contextual keywords (complexity), editions (Rust — §14.2 → language-diagnostics-tooling-and-evolution), or just breaking people. Reserve more than you need on day one.


§2. Lexing and Parsing

2.1 Lexing

Convert characters to tokens. The parts people underestimate: - Unicode. Identifiers (UAX #31), normalization (NFC — decide and enforce it, or é and é are different identifiers), and bidirectional-override characters, which are a security issue: the "Trojan Source" attack uses them to make source read differently to a human than to the compiler. Reject or warn on bidi control characters in source. - Spans/locations on every token. Byte offsets plus a line-index side table is the standard efficient design — computing line/column lazily from a precomputed line table beats tracking it per character. - Trivia (whitespace, comments) — either preserve them attached to tokens or you cannot build a formatter or a lossless refactoring tool later (§2.4). - Interpolated strings, raw strings, and nested comments are where hand-written lexers earn their keep; regex-based lexer generators struggle with them.

2.2 Parsing — choosing an approach

Approach Notes
Recursive descent + Pratt The overwhelming choice of production compilers (GCC, Clang, rustc, Go, TypeScript, V8). Hand-written, readable, arbitrary lookahead, and — decisively — excellent error recovery and error messages
LL(k) generators (ANTLR) Good tooling, good for DSLs; less control over errors
LALR generators (yacc/bison) Compact, fast; conflicts are famously painful to debug; poor error messages
GLR / Earley Handle ambiguous grammars; slower; useful for language research and for parsing C++
PEG / packrat Unambiguous by construction (ordered choice), linear with memoization; ordered choice silently hides ambiguity, which is a footgun
Combinators Elegant, great for prototypes; error messages and performance need work

[DURABLE] Essentially every widely-used production compiler uses hand-written recursive descent, and the reason is error recovery. A generated parser gives up or produces a generic message; a hand-written one can say "you forgot a semicolon here" and continue. Since the parser's output feeds an IDE that sees broken code 100% of the time, this is not a minor consideration.

Pratt parsing (top-down operator precedence) is the right technique for expressions: each token gets a binding power, and precedence and associativity fall out of comparing them. It's about 50 lines and handles prefix, infix, postfix, and mixfix cleanly.

2.3 Error recovery

[DURABLE] The parser's job is not to reject bad programs. It is to produce a usable tree from bad programs, because that is the input it receives most of the time. - Error nodes / poison nodes: represent "something was here but it was wrong" in the tree so later phases can continue and produce their errors too. - Panic-mode recovery with synchronization tokens: on error, skip to the next ;, }, or start-of-declaration and resume. - Insertion/deletion repair: hypothesize a missing token and continue. - Never cascade. One missing brace producing 400 errors is the classic failure. Suppress errors within a short distance of a previous one, and suppress errors derived from poison nodes.

2.4 CST vs. AST

  • CST (concrete syntax tree): every token, every space, every comment. Required for formatters, refactoring tools, and IDEs. Rust-analyzer's rowan and the Roslyn "red-green tree" design are the reference implementations: a persistent, immutable "green" tree of shared nodes plus a lightweight "red" layer providing parent pointers and absolute positions.
  • AST: semantic structure, trivia discarded.
  • [DURABLE] Build the CST and derive the AST from it. Going the other direction is impossible. This is the single most common regret in compiler front-end design, because the IDE requirement always arrives later than the compiler requirement.

§3. Names, Scopes, and Modules

3.1 Name resolution

Bind every identifier to a declaration. Sounds simple; is not. - Scoping: lexical (almost always correct) vs. dynamic (almost always a mistake). - Shadowing: allowed, warned, or forbidden. Rust allows and uses it heavily; many languages warn. - Forward references: can a function call something declared later? At top level, usually yes (requiring a separate declaration-collection pass); inside a block, usually no. This decision determines whether name resolution is one pass or two. - Namespaces: are types, values, macros, and labels in the same namespace? (C has separate struct/ordinary namespaces; Rust separates types and values; Lisp-2 vs. Lisp-1 is the oldest version of this argument.) - Overloading multiplies resolution complexity by type checking — now name resolution and type checking are mutually dependent, and you may need to interleave them.

3.2 Modules

[DURABLE] Module systems are where languages accrete the most regret, because module semantics touch compilation units, linking, versioning, packaging, and the file system all at once. The decisions:

Decision Options
Unit of modularity File, directory, explicit declaration, package
Import granularity Whole module, selected names, glob
Visibility Public/private, pub(crate)-style graded, explicit export lists
Cyclic imports Allowed (complicates compilation order), forbidden (simplifies everything)
Name-to-file mapping Implicit (Java, Python) or explicit (Rust mod, C++ modules)
Separate compilation Per-file, per-module, per-package, whole-program

[DURABLE] Forbid cyclic module dependencies if you possibly can. They force whole-program analysis, complicate incremental compilation, and are almost always a design smell in user code. Go forbids them; the ecosystem is measurably healthier for it.

⚠️ GOTCHA — C++ modules are the cautionary tale. Standardized in C++20; GCC 16.1 (April 2026) still describes its C++20 modules support as experimental, requiring -fmodules. Six years from standardization to "still experimental" in a major implementation is what happens when a module system must interoperate with a textual-inclusion legacy. Design modules before you have a legacy, not after.


§4. Type Systems

4.1 The design space

Axis Options
Checked when Static, dynamic, gradual, optional
Inference None, local, bidirectional, global (HM)
Polymorphism Parametric (generics), ad-hoc (overloading/traits), subtype, row
Subtyping None, nominal, structural, both
Variance Invariant, co/contravariant, declaration-site or use-site
Higher-kinded No, yes (Haskell, Scala)
Dependent No, limited (const generics, refinement), full (Idris, Lean, Agda)
Effects Implicit, checked exceptions, monadic, algebraic effect handlers
Linearity None, affine (Rust ownership), linear, uniqueness

4.2 Inference

Hindley–Milner (Algorithm W) — full inference for the let-polymorphic lambda calculus. Unification-based, principal types, and no annotations required anywhere. The catch: HM does not survive contact with subtyping, higher-rank types, or type classes without significant extension, and its error messages are notoriously bad — unification fails at some arbitrary point far from the actual mistake.

Bidirectional type checking — the modern default. Split into two mutually recursive judgments: checking (Γ ⊢ e ⇐ T, "does e have type T?") and synthesis (Γ ⊢ e ⇒ T, "what type does e have?"). Annotations at the boundaries. - Why it won: it scales to subtyping, higher-rank polymorphism, dependent types, and GADTs, where HM does not; it needs fewer annotations than you'd think; and — the reason practitioners care — error messages are dramatically better, because when checking fails you know the expected type and can report both sides. - Used in some form by Rust, Swift, TypeScript, Scala 3, Agda, Idris.

Local type inference (C#, Java, Go, C++ auto) — infer variable types from initializers and generic instantiations from arguments; require annotations on function signatures. [DURABLE] Requiring signature annotations is a feature, not a limitation: it makes the code self-documenting, keeps errors local, makes separate compilation tractable, and makes the IDE's job possible.

⚠️ GOTCHA — global inference and error locality are in direct tension. With full inference, an error in one function can surface as a type error in an unrelated one. Every language with global inference eventually adds a "please annotate your top-level definitions" lint. Consider just requiring them.

4.3 Unification and the occurs check

unify(a, b):
  a, b = resolve(a), resolve(b)          # follow substitutions (union-find)
  match (a, b):
    (Var x, Var y) if x == y  -> ok
    (Var x, t) | (t, Var x)   -> occurs_check(x, t); bind(x, t)   ← DON'T SKIP THIS
    (Con(c1, as), Con(c2, bs)) if c1 == c2 && len equal -> zip-unify
    _                          -> type error (report BOTH sides and the location)
The occurs check prevents binding x := List<x>, which creates an infinite type. Skip it and your compiler hangs or stack-overflows on a program like let f = fun x -> x x. Use union-find with path compression for the substitution or unification is accidentally quadratic.

Levels/ranks for generalization: the naive "generalize all free variables not in the environment" is O(n) in environment size at every let. The standard fix is Rémy's level-based generalization — tag each type variable with the let-depth at which it was created and generalize only those deeper than the current level. Every efficient HM implementation does this.

4.4 Traits, type classes, and interfaces

The mechanism for ad-hoc polymorphism, and it comes in three flavours: - Nominal interfaces (Java, C#): a type explicitly declares it implements an interface. Simple, but you cannot retrofit an interface onto a type you don't own. - Structural (Go, TypeScript): if it has the methods, it satisfies the interface. Fixes retrofitting; loses the ability to distinguish two interfaces with the same shape and different meaning. - Type classes / traits (Haskell, Rust, Swift): implementations are declared separately from both the type and the interface. Solves retrofitting and keeps nominality — at the cost of needing coherence rules.

Coherence and the orphan rule. If two crates can both implement Trait for Type, which one applies? Incoherence breaks type-directed dispatch and can break soundness. Haskell and Rust enforce coherence via the orphan rule: you may implement a trait for a type only if you own the trait or the type. It is the single most-complained-about rule in Rust and it is load-bearing.

Implementation strategies: dictionary passing (pass a vtable of the implementation — Haskell, Swift), monomorphization (generate a specialized copy — Rust static dispatch), or vtables at runtime (dyn Trait, Go interfaces).

⚠️ Trait resolution is a solver, and solvers have performance and termination problems. Rust's trait system is expressive enough that resolution can loop or blow up exponentially. [VERSIONED] rustc has been building a next-generation trait solver for years precisely to fix long-standing soundness bugs, unblock features (implied bounds, negative impls), and improve compile times; it reached production use in coherence checking and, as of 2026, stabilization work continues alongside Polonius (the next-generation borrow checker). The lesson for a language designer: an expressive trait/instance-resolution system is a Prolog interpreter in your compiler. Budget accordingly.

4.5 Generics: monomorphize or erase?

[CONTESTED, and one of the genuinely load-bearing decisions.]

Monomorphization (C++, Rust) Erasure / dictionaries (Java, Haskell, Swift)
Runtime cost Zero — specialized code, inlinable Indirection: boxing, vtables, dictionaries
Code size Explodes — one copy per instantiation One copy
Compile time Slow — the dominant cost in large Rust and C++ builds Fast
Separate compilation Hard — need the generic body available Clean
Reflection on type args Available Erased (Java's List<T> doesn't know T at runtime)
Error messages Instantiation-time errors, often terrible (C++ templates pre-concepts) Declaration-time errors

Go's 1.18 generics chose a middle path (GC shape stenciling): monomorphize by representation class rather than by exact type, so all pointer-shaped types share one instantiation. This bounds code growth while keeping most of the performance.

[DURABLE] Whichever you choose, define errors at the declaration site. C++ templates famously deferred all checking to instantiation, producing the pathological error messages that concepts (C++20) exist to fix. Rust's trait bounds check the generic body against its bounds up front. This is worth real implementation effort.

4.6 Ownership, borrowing, and linearity

Rust's contribution is proving that affine types plus region inference can eliminate memory-safety bugs at compile time with no runtime cost, in a language people actually ship. The machinery: - Ownership: each value has one owner; drop at scope end (RAII). - Borrowing: &T (shared, many) / &mut T (unique, one). The XOR rule — aliasing xor mutation — is what makes the whole thing sound and is what makes it hard to learn. - Lifetimes: regions inferred by the borrow checker; annotations only where inference can't decide.

[VERSIONED] NLL (non-lexical lifetimes) shipped in 2018; Polonius is the next-generation formulation, designed to accept currently-rejected patterns (notably "lending iterators"). Rust's own 2026 project-goal updates describe an "alpha" Polonius being tested on CI alongside the next-gen trait solver, with worst-case slowdowns still being tracked. The honest read: borrow checking is a research area with a shipped product on top of it, and the shipped product's rules are still being refined eight years in.

Alternatives worth knowing: linear types (must use exactly once — Linear Haskell), uniqueness types (Clean), region/arena inference (MLKit, Cyclone — the direct ancestor of Rust's design), and Swift's ARC with ~Copyable/borrowing annotations retrofitted.

4.7 The other static analyses

These aren't "type checking" but live in the same phase and are equally load-bearing: - Exhaustiveness checking for pattern matching. [DURABLE] This is one of the highest value-per-implementation-effort features in language design. Maranget's algorithm ("Warnings for pattern matching," 2007) is the standard reference and is genuinely implementable in a few hundred lines. It converts a whole class of runtime bugs into compile errors and it is what makes sum types pleasant instead of tedious. - Definite assignment — is this variable initialized on every path? - Reachability / dead code, unused variables and imports. - Effect checking (§4.9).

4.8 Compile-time execution and metaprogramming

The spectrum, in increasing order of power and danger: 1. Constant folding — the compiler evaluates 2+2. 2. constexpr/comptime — arbitrary evaluation at compile time (C++, Zig's comptime, which is also how Zig does generics). 3. Hygienic macros — syntax-to-syntax transforms that respect scope (Scheme, Rust). 4. Procedural macros / compiler plugins — arbitrary code running in the compiler. 5. Full reflection — programs inspecting and generating themselves.

[VERSIONED, and a genuinely big deal] C++26 shipped static reflection, which Herb Sutter called "the biggest upgrade since templates" and "C++'s decade-defining rocket engine." The ISO committee completed technical work on C++26 on 28 March 2026 in London; the headline features are static reflection, contracts, std::execution (sender/receiver), and a hardened standard library. GCC 16.1 (30 April 2026) already ships reflection and contracts. Note the syntax churn as a lesson: the reflection operator changed from ^ to ^^ during standardization, so early adopter code needed migration.

⚠️ GOTCHA — compile-time execution means your compiler contains an interpreter, and that interpreter is a security boundary and a performance cliff. You need: a step limit (or programs won't terminate), determinism (or builds aren't reproducible), a decision about whether it can do I/O (it should not), and an answer for what it means to debug it. Zig's community discussions consistently name comptime as both its best feature and a significant pain point — that's the shape of this trade-off.

4.9 Effects

An active research frontier that is reaching production: - Checked exceptions (Java) — the first mainstream effect system, and widely considered a partial failure because it lacked polymorphism: you couldn't write a higher-order function generic over what its argument throws. - Monadic effects (Haskell) — expressive, composes badly (monad transformers). - Algebraic effects and handlers (Koka, Eff, OCaml 5's effect handlers) — the current best answer. Effects are operations; handlers interpret them. This is what makes OCaml 5's concurrency work without colouring functions. - Function colouring (§8.3 → language-runtimes-interpreters-and-jits): async/await is an ad-hoc, unprincipled effect system. This is the strongest argument for doing effects properly.


Plugin: programming-language-development · View SKILL.md on GitHub