Skip to content

Strings and text

Str is an immutable UTF-8 string. Lengths, indexes and slices count code points, not bytes.

Int main() {
Str s = "héllo, wörld";
println(f"{str_len(s)} {str_slice(s, 0, 5)} {str_char_at(s, 1)}");
println(f"{str_to_upper(s)} {str_index_of(s, "w", 0)}");
List(Str) parts = str_split("a,b,c", ",");
println(f"{parts} {str_join(parts, "-")}");
println(str_replace("2026-09-27", "-", "/"));
return 0;
}
Output
12 héllo 233
HÉLLO, WÖRLD 7
["a", "b", "c"] a-b-c
2026/09/27

The string functions are str_len, str_slice, str_char_at (a code point, as Int), str_from_code, str_index_of, str_contains, str_starts_with, str_ends_with, str_count, str_split, str_join, str_trim, str_to_upper, str_to_lower, str_replace, str_repeat, str_reverse, str_is_int, str_parse_int, str_is_float and str_parse_float. + concatenates, == compares, and <, <=, >, >= order strings by code point.

f"...{expr}..." is how any value becomes text. A hole takes a value of any type:

type User = { Str name; List(Str) roles; };
Int main() {
User u = User {.name = "ada", .roles = ["admin", "dev"]};
Dec price = 12.50m;
println(f"{u.name} has {u.roles.len()} roles");
println(f"{u}");
println(f"{price} {price:trim} {1.0 / 3.0} {Some(2)}");
return 0;
}
Output
ada has 2 roles
User { name: "ada", roles: ["admin", "dev"] }
12.50000000000000000000000000000000 12.5 0.3333333333333333 Some(2)

Numbers print in full (a Float prints the shortest text that reads back as the same value, a Dec all its digits; trim drops a Dec’s trailing zeros). Lists, maps, records and variants print structurally, strings inside them quoted. A Show behavior replaces the built-in text for a type.

A hole can carry a spec after :, and the spec is a list of flags in any order — one rule to remember, no positional mini-language:

Int main() {
Int n = 1234567;
Int m = 255;
Float pi = 3.14159265;
Float share = 0.125;
println(f"|{n:12}|{n:12, left}|{n:12, center}|");
println(f"|{n:group}|{n:hex}|{m:hex, upper}|");
println(f"|{pi:precision 2}|{share:percent}|");
println(f"|{m:zero, width 8, plus}|");
println(f"|{{not a hole}}|");
return 0;
}
Output
| 1234567|1234567 | 1234567 |
|1,234,567|12d687|FF|
|3.14|12.5%|
|+0000255|
|{not a hole}|

width/8, left/right/center, fill 'x'/zero, plus/space/ minus, hex/oct/bin, upper/lower, group, precision 2/.2, decimal/scientific/percent/auto, and trim. The reference page has the full table.

A flag a value’s type cannot mean is an error rather than a silent no-op, so a typo never reads as “it just didn’t apply”. And a spec only changes how text is written, so when the value is known the whole thing reduces at compile time — f"{n:group}" with a known n is a literal.

s + t copies both strings. To build a long string from many pieces, use StrBuf, which appends in place:

StrBuf csv_row(List(Int) xs, Int i, StrBuf acc) {
if (i >= xs.len()) { return acc; }
StrBuf a = if (i > 0) { acc.push_char(',') } else { acc };
return csv_row(xs, i + 1, a.push(f"{xs[i]}"));
}
Int main() {
println(csv_row([1, 2, 3, 5, 8], 0, StrBuf()).finish());
return 0;
}
Output
1,2,3,5,8

r"C:\no\escapes" is a raw string. 'a' is a character literal (a code point Int). b"bytes" is a byte string (Bytes).