Loft strings can embed expressions inside {...} braces. A colon after the expression introduces a format specifier that controls width, alignment, precision, number base, and output style.
Basic interpolation
Any expression inside {...} is evaluated and converted to text.
struct Point { x: integer, y: integer }
A type that receives the PARTS of a format string. See the last section.
struct Query {
const parts: vector<text>,
const values: vector<text>,
}
lit gets the bytes you wrote in the source file.
fn lit(self: Query, s: text) { self.parts += [s]; }
hole_text gets an interpolated value. It is never added to parts.
fn hole_text(self: Query, v: text?) { self.values += [v ?? ""]; }
fn hole_int(self: Query, v: integer) { self.values += ["{v}"]; }
Show the two lists separately, so you can see what went where.
fn shape(self: Query) -> text {
out = "";
for q_part in self.parts { out += "<{q_part}>" }
for q_val in self.values { out += "[{q_val}]" }
return out;
}
fn main() {
name = "world";
assert("hello {name}" == "hello world", "basic interpolation");
assert("1 + 2 = {1 + 2}" == "1 + 2 = 3", "expression in braces");
The braces hold any expression, not just a name — a field, an index, a call, arithmetic. Each is evaluated left to right as the text is built.
assert("{len(name)}" == "5", "a call inside a hole");
Writing a real brace
A single { opens a hole, so double it to mean the character itself. {{ is { and }} is }. This is why the struct examples further down compare against "{{x:1,y:2}}" — that is the six-character text {x:1,y:2}.
assert("{{literal}}" == "{{literal}}", "doubled braces are one brace each");
assert(len("{{}}") == 2, "`{{}}` is two characters, not four");
Width and alignment
A number after : sets the minimum width. The value is padded with spaces to fill the width. {val:6} — right-aligned (default for numbers) {val:>6} — right-aligned (explicit) {val:<6} — left-aligned {val:^6} — centered Numbers are right-aligned by default; everything else — text, booleans, characters, vectors, structs — is left-aligned.
assert("{42:6}" == " 42", "default number align is right");
assert("{42:>6}" == " 42", "explicit right-align");
assert("{42:<6}" == "42 ", "left-align number");
assert("{42:^6}" == " 42 ", "center-align number");
s = "hi";
assert("{s:6}" == "hi ", "default text align is left");
assert("{s:>6}" == " hi", "right-align text");
assert("{s:^6}" == " hi ", "center-align text");
A width shapes the field whatever the value is, so it pads a boolean or a vector exactly as it pads a number.
assert("{true:6}" == "true ", "a boolean is padded too");
assert("{[1,2]:8}" == "[1,2] ", "a vector is padded too");
A fill character
Put any single non-digit character before the alignment to pad with that character instead of a space. It comes first, before the other flags.
assert("{s:*>6}" == "****hi", "fill with `*`");
assert("{42:*^11}" == "****42*****", "fill and centre");
A digit cannot be a fill character, because a digit is how a width starts: {n:0>5} is the comparison 0 > 5, not "pad with 0, right-align, width 5". Loft refuses that spec rather than guessing; to zero-pad, write 05.
The flags may be written in any order
assert("{0.5:+<8.3}" == "+0.500 ", "sign before alignment");
assert("{0.5:<+8.3}" == "+0.500 ", "alignment before sign");
Zero padding
Prefix the width with 0 to pad with zeros instead of spaces.
assert("{7:03}" == "007", "zero-padded 3 digits");
assert("{42:05}" == "00042", "zero-padded 5 digits");
assert("{-1:04}" == "-001", "zero-padded negative: sign before zeros");
A zero pad fills the number, so the sign — and a 0x marker — stays in front of the zeros it adds. This holds for floats and singles too.
assert("{3.125:08.2}" == "00003.12", "zero-padded float");
assert("{-3.5:08.2}" == "-0003.50", "zero-padded negative float");
assert("{1.5f:08.2}" == "00001.50", "zero-padded single");
Signed format
+ forces a sign on positive numbers.
assert("{42:+}" == "+42", "explicit positive sign");
assert("{-42:+}" == "-42", "negative sign always shown");
assert("{0:+}" == "+0", "sign on zero");
+ is about numbers, not about integers — a float and a single take it too.
assert("{3.5:+}" == "+3.5", "sign on a float");
assert("{0.5:+.3}" == "+0.500", "sign beside a precision");
Hexadecimal, binary, octal
:x for lowercase hex, :X for uppercase, :# adds the 0x / 0b / 0o marker. :b for binary, :o for octal, :d for the decimal default.
assert("{255:x}" == "ff", "hex lowercase");
assert("{255:X}" == "FF", "hex uppercase");
assert("{255:#x}" == "0xff", "hex with prefix");
assert("{10:b}" == "1010", "binary");
assert("{8:o}" == "10", "octal");
A width goes before the base letter, and the marker keeps its place in front of a zero pad — the result is something you can paste back into a program.
assert("{255:6x}" == " ff", "hex in a 6-wide field");
assert("{255:#06x}" == "0x00ff", "the `0x` marker precedes the zeros");
assert("{10:#06b}" == "0b1010", "the `0b` marker precedes the zeros");
A base is a property of an integer, so asking for one anywhere else is a compile error rather than a spec that quietly does nothing. {2.5:x} says "x has no effect on float".
Float precision
.N after the colon fixes the number of decimal places. Only float and single have decimal places, so .N on any other type is a compile error: {7:.2} says "a precision has no effect on integer". That matters because the mistake is easy to make — a price you thought was a float is an integer, and {price:.2} would otherwise render no decimals at all.
assert("{3.125:.1}" == "3.1", "1 decimal place");
assert("{3.125:.2}" == "3.12", "2 decimal places");
assert("{3.125:.0}" == "3", "0 decimal places");
assert("{0.0:.3}" == "0.000", "trailing zeros");
Width + precision
Combine width and precision: {val:W.P} where W is total width and P is decimal places.
assert("{3.125:8.2}" == " 3.12", "width 8 precision 2");
assert("{-3.125:8.2}" == " -3.12", "negative width+precision");
JSON format
:j (also spelled :json or :J) renders a struct, a vector or an enum as JSON instead of loft's own compact form: quoted keys, and nothing else changed. See the JSON documentation page for full details.
jp = Point { x: 1, y: 2 };
assert("{jp}" == "{{x:1,y:2}}", "the default form is loft's own");
assert("{jp:j}" == "{{\"x\":1,\"y\":2}}", "`:j` quotes the keys");
assert("{jp:json}" == "{{\"x\":1,\"y\":2}}", "`:json` is the same spec");
assert("{[jp]:j}" == "[{{\"x\":1,\"y\":2}}]", "a vector of structs");
:j is a choice between two renderings of a composite value, so it is not available on a scalar: {7:j} and {"a":j} are compile errors. A scalar has one rendering, and inside a struct it is already the JSON one.
Vector format
Vectors are formatted as [a,b,c] by default. A format specifier inside a for loop applies to each element.
v = [1, 2, 3];
assert("{v}" == "[1,2,3]", "default vector format");
assert("{v:>10}" == " [1,2,3]", "a vector takes a field like anything else");
assert("{v:#}" == "[ 1, 2, 3 ]", "`:#` spaces a vector out too");
assert("{for fmt_n in 1..4 {fmt_n * 10}:04}" == "[0010,0020,0030]", "formatted vector elements");
Large integers and single-precision floats
integer is a 64-bit type, so a value near its limit formats like any other number rather than losing digits.
n = 1000000;
assert("{n}" == "1000000", "integer default");
assert("{n:>10}" == " 1000000", "integer right-aligned");
big = 9223372036854775807;
assert("{big}" == "9223372036854775807", "the largest integer keeps every digit");
assert("{big:x}" == "7fffffffffffffff", "and renders in any base");
A single is a float of half the width; every specifier above applies to it unchanged.
f = 1.5f;
assert("{f}" == "1.5", "single default");
assert("{f:8.2}" == " 1.50", "single width and precision");
assert("{f:+}" == "+1.5", "single with a forced sign");
Pretty-printing a struct
The :# specifier expands a struct across a spaced, readable layout instead of the compact default form.
p = Point { x: 1, y: 2 };
assert("{p}" == "{{x:1,y:2}}", "default struct format is compact");
assert("{p:#}" == "{{ x: 1, y: 2 }}", "`:#` pretty-prints with spaces");
Null, and why formatting never fails
Every type has a null, and a null renders as the word null. It is a value, not an error, so it pads like any other rendering.
absent: integer? = null;
assert("{absent}" == "null", "a null integer renders as `null`");
assert("{absent:>8}" == " null", "and takes the field it was given");
A zero pad is for numbers, and null is not one, so it pads with spaces.
assert("{absent:08}" == " null", "`null` is never zero-padded");
An operation inside a hole that cannot produce a value yields null rather than stopping the program, and the null prints as null. Building a message — a log line, an error report — can therefore never itself fail.
top = 5;
bottom = 0;
assert("{top / bottom}" == "null", "a division by zero prints null");
Character format
c = 'A';
assert("{c}" == "A", "character default");
assert("{c:>5}" == " A", "a character takes a field like anything else");
Boolean format
assert("{true}" == "true", "boolean true");
assert("{false}" == "false", "boolean false");
Building a value instead of text
Everything above joins the pieces into one text. When the type you assign to says so, the format string builds that type instead, and the type is told which bytes you wrote and which came from a value.
A type opts in by defining lit (for your bytes) and one hole_… method per value kind it accepts: hole_text, hole_int, hole_float, hole_single, hole_boolean, hole_character. There is no new syntax — the type you assign to decides — and plain text is unchanged.
who = "ada";
q: Query = "SELECT * FROM t WHERE name = {who}";
The text you wrote is in parts; the value is in values, never in the text. That separation is the point: a value cannot turn into syntax, because the only way into the text is lit, and lit only ever receives bytes from your source file.
assert(q.shape() == "<SELECT * FROM t WHERE name = >[ada]",
"the literal and the value stay apart");
The value can be anything at all, including text that would otherwise change the meaning of the statement. It is still just a value.
danger = "'; DROP TABLE t; --";
q2: Query = "WHERE name = {danger}";
assert(q2.shape() == "<WHERE name = >['; DROP TABLE t; --]",
"a value that looks like syntax is still a value");
Each kind goes to its own method, so the type sees the real type.
q3: Query = "id={7} name={who}";
assert(q3.shape() == "<id=>< name=>[7][ada]", "an integer hole calls hole_int");
The type is taken from an annotated binding, a struct-literal field or the function's declared return type. A call argument does NOT target — db.db_rows("… {name}") is an ordinary text and reports expected Query, got text — so route through a local: q: SqlText = "SELECT id FROM users WHERE name = {name}"; db.db_rows(q) A format spec on a target's hole is a compile error, because the value is handed to the type rather than rendered: there is nothing for a width to pad.
}
Run it yourself
This page is a loft program. It runs here, in your browser — press Run, then ask it something.
Stop on a line
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// @NAME: Formatting
// @TITLE: Format strings — interpolation, alignment, and number bases
// Loft strings can embed expressions inside `{...}` braces. A colon after
// the expression introduces a format specifier that controls width, alignment,
// precision, number base, and output style.
// ## Basic interpolation
// Any expression inside `{...}` is evaluated and converted to text.
struct Point { x: integer, y: integer }
// A type that receives the PARTS of a format string. See the last section.
struct Query {
const parts: vector<text>,
const values: vector<text>,
}
// `lit` gets the bytes you wrote in the source file.
fn lit(self: Query, s: text) { self.parts += [s]; }
// `hole_text` gets an interpolated value. It is never added to `parts`.
fn hole_text(self: Query, v: text?) { self.values += [v ?? ""]; }
fn hole_int(self: Query, v: integer) { self.values += ["{v}"]; }
// Show the two lists separately, so you can see what went where.
fn shape(self: Query) -> text {
out = "";
for q_part in self.parts { out += "<{q_part}>" }
for q_val in self.values { out += "[{q_val}]" }
return out;
}
fn main() {
name = "world";
assert("hello {name}" == "hello world", "basic interpolation");
assert("1 + 2 = {1 + 2}" == "1 + 2 = 3", "expression in braces");
// The braces hold any expression, not just a name — a field, an index, a
// call, arithmetic. Each is evaluated left to right as the text is built.
assert("{len(name)}" == "5", "a call inside a hole");
// ## Writing a real brace
// A single `{` opens a hole, so double it to mean the character itself.
// `{{` is `{` and `}}` is `}`. This is why the struct examples further down
// compare against `"{{x:1,y:2}}"` — that is the six-character text `{x:1,y:2}`.
assert("{{literal}}" == "{{literal}}", "doubled braces are one brace each");
assert(len("{{}}") == 2, "`{{}}` is two characters, not four");
// ## Width and alignment
// A number after `:` sets the minimum width. The value is padded with
// spaces to fill the width.
// {val:6} — right-aligned (default for numbers)
// {val:>6} — right-aligned (explicit)
// {val:<6} — left-aligned
// {val:^6} — centered
// Numbers are right-aligned by default; everything else — text, booleans,
// characters, vectors, structs — is left-aligned.
assert("{42:6}" == " 42", "default number align is right");
assert("{42:>6}" == " 42", "explicit right-align");
assert("{42:<6}" == "42 ", "left-align number");
assert("{42:^6}" == " 42 ", "center-align number");
s = "hi";
assert("{s:6}" == "hi ", "default text align is left");
assert("{s:>6}" == " hi", "right-align text");
assert("{s:^6}" == " hi ", "center-align text");
// A width shapes the field whatever the value is, so it pads a boolean or a
// vector exactly as it pads a number.
assert("{true:6}" == "true ", "a boolean is padded too");
assert("{[1,2]:8}" == "[1,2] ", "a vector is padded too");
// ## A fill character
// Put any single non-digit character before the alignment to pad with that
// character instead of a space. It comes first, before the other flags.
assert("{s:*>6}" == "****hi", "fill with `*`");
assert("{42:*^11}" == "****42*****", "fill and centre");
// A digit cannot be a fill character, because a digit is how a width starts:
// `{n:0>5}` is the comparison `0 > 5`, not "pad with 0, right-align, width 5".
// Loft refuses that spec rather than guessing; to zero-pad, write `05`.
// ## The flags may be written in any order
assert("{0.5:+<8.3}" == "+0.500 ", "sign before alignment");
assert("{0.5:<+8.3}" == "+0.500 ", "alignment before sign");
// ## Zero padding
// Prefix the width with `0` to pad with zeros instead of spaces.
assert("{7:03}" == "007", "zero-padded 3 digits");
assert("{42:05}" == "00042", "zero-padded 5 digits");
assert("{-1:04}" == "-001", "zero-padded negative: sign before zeros");
// A zero pad fills the number, so the sign — and a `0x` marker — stays in
// front of the zeros it adds. This holds for floats and singles too.
assert("{3.125:08.2}" == "00003.12", "zero-padded float");
assert("{-3.5:08.2}" == "-0003.50", "zero-padded negative float");
assert("{1.5f:08.2}" == "00001.50", "zero-padded single");
// ## Signed format
// `+` forces a sign on positive numbers.
assert("{42:+}" == "+42", "explicit positive sign");
assert("{-42:+}" == "-42", "negative sign always shown");
assert("{0:+}" == "+0", "sign on zero");
// `+` is about numbers, not about integers — a float and a single take it too.
assert("{3.5:+}" == "+3.5", "sign on a float");
assert("{0.5:+.3}" == "+0.500", "sign beside a precision");
// ## Hexadecimal, binary, octal
// `:x` for lowercase hex, `:X` for uppercase, `:#` adds the `0x` / `0b` / `0o`
// marker. `:b` for binary, `:o` for octal, `:d` for the decimal default.
assert("{255:x}" == "ff", "hex lowercase");
assert("{255:X}" == "FF", "hex uppercase");
assert("{255:#x}" == "0xff", "hex with prefix");
assert("{10:b}" == "1010", "binary");
assert("{8:o}" == "10", "octal");
// A width goes before the base letter, and the marker keeps its place in
// front of a zero pad — the result is something you can paste back into a
// program.
assert("{255:6x}" == " ff", "hex in a 6-wide field");
assert("{255:#06x}" == "0x00ff", "the `0x` marker precedes the zeros");
assert("{10:#06b}" == "0b1010", "the `0b` marker precedes the zeros");
// A base is a property of an integer, so asking for one anywhere else is a
// compile error rather than a spec that quietly does nothing. `{2.5:x}` says
// "`x` has no effect on float".
// ## Float precision
// `.N` after the colon fixes the number of decimal places. Only `float` and
// `single` have decimal places, so `.N` on any other type is a compile error:
// `{7:.2}` says "a precision has no effect on integer". That matters because
// the mistake is easy to make — a `price` you thought was a float is an
// integer, and `{price:.2}` would otherwise render no decimals at all.
assert("{3.125:.1}" == "3.1", "1 decimal place");
assert("{3.125:.2}" == "3.12", "2 decimal places");
assert("{3.125:.0}" == "3", "0 decimal places");
assert("{0.0:.3}" == "0.000", "trailing zeros");
// ## Width + precision
// Combine width and precision: `{val:W.P}` where W is total width
// and P is decimal places.
assert("{3.125:8.2}" == " 3.12", "width 8 precision 2");
assert("{-3.125:8.2}" == " -3.12", "negative width+precision");
// ## JSON format
// `:j` (also spelled `:json` or `:J`) renders a struct, a vector or an enum as
// JSON instead of loft's own compact form: quoted keys, and nothing else
// changed. See the JSON documentation page for full details.
jp = Point { x: 1, y: 2 };
assert("{jp}" == "{{x:1,y:2}}", "the default form is loft's own");
assert("{jp:j}" == "{{\"x\":1,\"y\":2}}", "`:j` quotes the keys");
assert("{jp:json}" == "{{\"x\":1,\"y\":2}}", "`:json` is the same spec");
assert("{[jp]:j}" == "[{{\"x\":1,\"y\":2}}]", "a vector of structs");
// `:j` is a choice between two renderings of a composite value, so it is not
// available on a scalar: `{7:j}` and `{"a":j}` are compile errors. A scalar
// has one rendering, and inside a struct it is already the JSON one.
// ## Vector format
// Vectors are formatted as `[a,b,c]` by default.
// A format specifier inside a `for` loop applies to each element.
v = [1, 2, 3];
assert("{v}" == "[1,2,3]", "default vector format");
assert("{v:>10}" == " [1,2,3]", "a vector takes a field like anything else");
assert("{v:#}" == "[ 1, 2, 3 ]", "`:#` spaces a vector out too");
assert("{for fmt_n in 1..4 {fmt_n * 10}:04}" == "[0010,0020,0030]", "formatted vector elements");
// ## Large integers and single-precision floats
// `integer` is a 64-bit type, so a value near its limit formats like any other
// number rather than losing digits.
n = 1000000;
assert("{n}" == "1000000", "integer default");
assert("{n:>10}" == " 1000000", "integer right-aligned");
big = 9223372036854775807;
assert("{big}" == "9223372036854775807", "the largest integer keeps every digit");
assert("{big:x}" == "7fffffffffffffff", "and renders in any base");
// A `single` is a float of half the width; every specifier above applies to it
// unchanged.
f = 1.5f;
assert("{f}" == "1.5", "single default");
assert("{f:8.2}" == " 1.50", "single width and precision");
assert("{f:+}" == "+1.5", "single with a forced sign");
// ## Pretty-printing a struct
// The `:#` specifier expands a struct across a spaced, readable layout
// instead of the compact default form.
p = Point { x: 1, y: 2 };
assert("{p}" == "{{x:1,y:2}}", "default struct format is compact");
assert("{p:#}" == "{{ x: 1, y: 2 }}", "`:#` pretty-prints with spaces");
// ## Null, and why formatting never fails
// Every type has a null, and a null renders as the word `null`. It is a
// value, not an error, so it pads like any other rendering.
absent: integer? = null;
assert("{absent}" == "null", "a null integer renders as `null`");
assert("{absent:>8}" == " null", "and takes the field it was given");
// A zero pad is for numbers, and `null` is not one, so it pads with spaces.
assert("{absent:08}" == " null", "`null` is never zero-padded");
// An operation inside a hole that cannot produce a value yields null rather
// than stopping the program, and the null prints as `null`. Building a
// message — a log line, an error report — can therefore never itself fail.
top = 5;
bottom = 0;
assert("{top / bottom}" == "null", "a division by zero prints null");
// ## Character format
c = 'A';
assert("{c}" == "A", "character default");
assert("{c:>5}" == " A", "a character takes a field like anything else");
// ## Boolean format
assert("{true}" == "true", "boolean true");
assert("{false}" == "false", "boolean false");
// ## Building a value instead of text
// Everything above joins the pieces into one `text`. When the type you
// assign to says so, the format string **builds that type instead**, and the
// type is told which bytes you wrote and which came from a value.
//
// A type opts in by defining `lit` (for your bytes) and one `hole_…` method
// per value kind it accepts: `hole_text`, `hole_int`, `hole_float`,
// `hole_single`, `hole_boolean`, `hole_character`. There is no new syntax —
// the type you assign to decides — and plain `text` is unchanged.
who = "ada";
q: Query = "SELECT * FROM t WHERE name = {who}";
// The text you wrote is in `parts`; the value is in `values`, never in the
// text. That separation is the point: a value cannot turn into syntax,
// because the only way into the text is `lit`, and `lit` only ever receives
// bytes from your source file.
assert(q.shape() == "<SELECT * FROM t WHERE name = >[ada]",
"the literal and the value stay apart");
// The value can be anything at all, including text that would otherwise
// change the meaning of the statement. It is still just a value.
danger = "'; DROP TABLE t; --";
q2: Query = "WHERE name = {danger}";
assert(q2.shape() == "<WHERE name = >['; DROP TABLE t; --]",
"a value that looks like syntax is still a value");
// Each kind goes to its own method, so the type sees the real type.
q3: Query = "id={7} name={who}";
assert(q3.shape() == "<id=>< name=>[7][ada]", "an integer hole calls hole_int");
// The type is taken from an annotated binding, a struct-literal field or the
// function's declared return type. A call argument does NOT target —
// `db.db_rows("… {name}")` is an ordinary text and reports `expected Query, got
// text` — so route through a local:
// q: SqlText = "SELECT id FROM users WHERE name = {name}";
// db.db_rows(q)
// A format spec on a target's hole is a compile error, because the value is handed
// to the type rather than rendered: there is nothing for a width to pad.
}