Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

15. String interpolation

  • Status: Accepted
  • Date: 2026-09-03

Context

Building a string from parts in Milestone 1 means `"total: " + str(count) + " of "

  • str(total). This is the single most common piece of ceremony in real Korrin code — every message, path, and label pays it. The + str(x) +pattern is noisy, easy to get wrong (forget astr`, get a type error), and reads nothing like the sentence it produces.

The options for fixing it:

  • A prefix form — only f"total: {count}" interpolates; plain "..." never does (Python, C#, JS template literals with a different delimiter).
  • Every string interpolates{ } is always a hole, {{ is a literal brace (Rust's format!, .NET's older String.Format, many template languages).
  • A dedicated functionformat("total: {}", count) (Rust format!, Python .format). Positional, no new syntax, but the arguments are separated from where they appear.

Decision

Every string literal interpolates. "{ expr }" splices the str() value of expr into the string. {{ and }} are literal { and }. There is no prefix — a Korrin string is a template, always.

  • Any expression may appear in a hole, not just a name: "{a + b}", "{items[0].name}", "{f(x)}". A hole is lexed as ordinary tokens, so a nested string needs no escaping: "{m["key"]}" is fine.
  • Holes are evaluated left to right, at the point the string expression is evaluated.
  • A hole value is rendered with display semantics (as str() / print would show it): a string is spliced as-is, not re-quoted; a list shows as [1, 2, 3].
  • An empty hole "{}" is a syntax error. A { with no closing } before the end of the line is a lexical error (E0002).
  • To get a literal {, write {{. A lone } is already literal; }} also works, for symmetry.
  • A string still may not span lines, so neither may a hole.

This also settles a deferred open question: string slicing is text.slice(start, end) (a method, arriving with the M2 method work), and text[i] stays as single-character access.

Consequences

  • The + str(x) + pattern all but disappears. "Hello, {name}!" reads like the output.
  • Breaking: any M1 string containing a literal { now needs {{. A sweep of the codebase, spec, guide, and examples found none, so the practical cost was zero — but it is a real rule change and is noted in the changelog.
  • The lexer gains real complexity: a "..." with a hole is emitted as a token runStrStart, alternating StrText and StrExprStart … StrExprEnd bracketed hole tokens, then StrEnd — rather than one Str token. The hole tokens are ordinary tokens with real source spans, so the parser reuses parse_expression unchanged and a diagnostic inside a hole points at the right byte in the file. Plain strings (no hole) still emit one Str token on the fast path.
  • The AST gains ExprKind::Interpolate(Vec<InterpPart>); the interpreter concatenates the parts.
  • No new keywords.

Alternatives considered

  • f"..." prefix. Explicit — you can tell at a glance whether a string interpolates — but it adds a sigil, a second kind of string, and a thing to forget. Against "one obvious way": there would be two ways to write a plain string and the wrong one fails silently (f"{x}" typo'd as "{x}").
  • format(template, args...). No new syntax, but it separates each value from where it lands in the text, which is exactly the readability problem interpolation solves. Still worth having later as a function for the build-a-format-string-at-runtime case.
  • ${ } or \( ) delimiters. { } is the lightest and matches the most prior art (format!, shell-ish, most template engines). ${} earns nothing.