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'sformat!, .NET's olderString.Format, many template languages). - A dedicated function —
format("total: {}", count)(Rustformat!, 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()/printwould 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 run —StrStart, alternatingStrTextandStrExprStart … StrExprEndbracketed hole tokens, thenStrEnd— rather than oneStrtoken. The hole tokens are ordinary tokens with real source spans, so the parser reusesparse_expressionunchanged and a diagnostic inside a hole points at the right byte in the file. Plain strings (no hole) still emit oneStrtoken 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.