String
String module: functions for working with UTF-8 encoded strings.
March strings are immutable UTF-8 byte sequences. Every string is a refcounted heap allocation with a 24-byte header — there is NO small-string optimisation, so even a 3-byte string costs a malloc. (This comment previously claimed a fat-pointer representation with 15-byte inline storage. That was never true; see specs/2026-07-26-string-performance-profile.md for what the representation actually costs, measured.)
Key naming conventions:
- Functions with
byte_in their name operate on raw byte offsets (O(1)). codepoint_countcounts Unicode codepoints (NOT user-perceived
graphemes — combining characters and emoji clusters count as multiple
codepoints). `grapheme_count` is a legacy ALIAS for it and counts
codepoints too, despite the name: `"e" ++ combining-acute` is one grapheme
but both functions return 2, and a ZWJ emoji sequence is one grapheme but
both return 3. Prefer `codepoint_count`, whose name matches what it does.- Use
byte_sizefor performance-critical paths.
Functions
Return the number of bytes in the UTF-8 encoding of s.
march> String.byte_size("hello")
5
march> String.byte_size("é") -- 2-byte UTF-8 sequence
2
march> String.byte_size("")
0Cost: O(1) — reads a stored length field.
Count the number of Unicode codepoints in the UTF-8 string s.
For ASCII strings this equals the number of visible characters. For strings with multi-byte UTF-8 sequences, this counts codepoints, NOT grapheme clusters — combining marks (á = a + ◌́), emoji modifiers, and ZWJ sequences each count as multiple codepoints.
march> String.codepoint_count("hello")
5
march> String.codepoint_count("é") -- NFC: one codepoint, two bytes
1
march> String.codepoint_count("e" ++ Option.unwrap(String.from_codepoint(769))) -- NFD: e + combining acute
2
march> String.codepoint_count("")
0Use a dedicated Unicode library when you need true grapheme cluster segmentation (for cursor-position arithmetic in a text editor, for example).
Cost: O(n) — scans the byte sequence.
Concatenate two strings.
march> String.concat("foo", "bar")
"foobar"Prefer the ++ operator for inline concatenation. Use IOList when building many string segments to avoid O(n²) copying.
Cost: O(|a| + |b|) — allocates a new string.
Return true if substring appears anywhere in s.
march> String.contains("hello world", "world")
true
march> String.contains("hello world", "xyz")
false
march> String.contains("abc", "") -- empty is always found
trueCost: O(n * m) worst case, where n = byte_size(s) and m = byte_size(substring).
Return true if s ends with suffix.
march> String.ends_with("hello", "lo")
true
march> String.ends_with("hello", "he")
false
march> String.ends_with("hello", "")
trueCost: O(|suffix|).
Encode a Unicode codepoint as a UTF-8 string. Returns Some(string) if the codepoint is valid, None otherwise.
Valid codepoints are in the range 0x0 to 0x10FFFF, excluding surrogate pairs (0xD800 to 0xDFFF).
march> String.from_codepoint(65) -- U+0041
Some("A")
march> String.from_codepoint(233) == Some("é") -- U+00E9
true
march> String.from_codepoint(128512) == Some("😀") -- U+1F600
true
march> String.from_codepoint(-1) -- out of range
None
march> String.from_codepoint(55296) -- U+D800, a surrogate
NoneCost: O(1).
Convert a float to a string using OCaml's default float formatting.
march> String.from_float(3.14)
"3.14"
march> String.from_float(1.0)
"1."
march> String.from_float(10000000000.0)
"10000000000."The exact format matches OCaml's string_of_float. Cost: O(1).
Convert an integer to its decimal string representation.
march> String.from_int(42)
"42"
march> String.from_int(-7)
"-7"
march> String.from_int(0)
"0"Cost: O(digits).
Legacy alias for codepoint_count. Despite the name, this function counts codepoints, not grapheme clusters; new code should call codepoint_count directly. Retained for backward compatibility.
march> String.grapheme_count("héllo")
5
march> String.grapheme_count("e" ++ Option.unwrap(String.from_codepoint(769))) -- one grapheme, two codepoints
2Find the byte offset of the first occurrence of substring in s. Returns Some(offset) if found, None if not found.
march> String.index_of("hello", "ll")
Some(2)
march> String.index_of("hello", "xyz")
None
march> String.index_of("hello", "")
Some(0)Cost: O(n * m) worst case.
Find the first occurrence of substring at or after byte offset start.
The result is an index into s itself, not relative to start, so it can be fed straight back in as the next start when tokenizing.
Prefer this over slicing off the tail and searching again: the tail form re-copies the remaining bytes on every step, making a full tokenize O(n²) in bytes copied.
march> String.index_of_from("a,b,c", ",", 0)
Some(1)
march> String.index_of_from("a,b,c", ",", 2)
Some(3)
march> String.index_of_from("a,b,c", ",", 4)
NoneA negative start is treated as 0; a start past the end returns None; an empty substring matches at start.
Cost: O((n - start) * m) worst case.
Return true if s contains no bytes.
march> String.is_empty("")
true
march> String.is_empty("x")
false
march> String.is_empty(" ")
falseCost: O(1).
Join a list of strings with a separator inserted between each element.
march> String.join(["a", "b", "c"], "-")
"a-b-c"
march> String.join([], ",")
""
march> String.join(["solo"], ",")
"solo"Cost: O(total bytes in list + (n-1) * |sep|).
Find the byte offset of the last occurrence of substring in s. Returns Some(offset) if found, None if not found.
march> String.last_index_of("hello.world.txt", ".")
Some(11)
march> String.last_index_of("hello", "xyz")
None
march> String.last_index_of("hello", "")
Some(5)Cost: O(n * m) worst case.
Pad s on the left with fill until the byte length reaches width. If byte_size(s) >= width, s is returned unchanged.
march> String.pad_left("hi", 5, "0")
"000hi"
march> String.pad_left("hello", 3, "0") -- already long enough
"hello"fill must be a single-byte string (single ASCII character).
Cost: O(width).
Pad s on the right with fill until the byte length reaches width. If byte_size(s) >= width, s is returned unchanged.
march> String.pad_right("hi", 5, ".")
"hi..."
march> String.pad_right("hello", 3, ".")
"hello"fill must be a single-byte string (single ASCII character).
Cost: O(width).
Repeat s exactly n times.
march> String.repeat("ab", 3)
"ababab"
march> String.repeat("x", 0)
""
march> String.repeat("x", 1)
"x"Cost: O(n * |s|) — allocates a new string.
Replace the first occurrence of old in s with new.
march> String.replace("aabbaa", "a", "x")
"xabbaa"
march> String.replace("hello", "xyz", "!") -- no match
"hello"If old is the empty string, s is returned unchanged. See replace_all to replace every occurrence.
Cost: O(n) scan + O(n) for the result string.
Replace every non-overlapping occurrence of old in s with new.
march> String.replace_all("aabbaa", "a", "x")
"xxbbxx"
march> String.replace_all("hello", "l", "r")
"herro"If old is the empty string, s is returned unchanged.
Cost: O(n) where n = byte_size(s).
Return s with its bytes reversed.
march> String.reverse("hello")
"olleh"
march> String.reverse("racecar")
"racecar"Note: byte-reversal is not the same as grapheme-reversal for multi-byte UTF-8 codepoints. This function is primarily useful for ASCII strings.
Cost: O(n).
Alias for slice_bytes: extracts len bytes starting at byte offset start, with the same clamping. Offsets are bytes, not codepoints, so a slice can split a multi-byte character.
march> String.slice("hello world", 0, 5)
"hello"
march> String.slice("hello", 3, 100)
"lo"Return a byte-indexed substring of s starting at byte offset start with length len bytes.
march> String.slice_bytes("hello world", 6, 5)
"world"
march> String.slice_bytes("hello", 0, 100) -- clamped
"hello"Both start and len are clamped to valid byte ranges; no exception is raised for out-of-bounds values. The returned string is a copy (the interpreter does not yet implement GC slices).
Cost: O(len) for the copy.
Split s on every occurrence of sep, returning a List(String).
march> String.split("a,b,c", ",")
["a", "b", "c"]
march> String.split("abc", "") -- empty sep: one string per byte
["a", "b", "c"]
march> String.split("aXbXc", "X")
["a", "b", "c"]Adjacent separators produce empty strings in the result list.
An empty sep splits into single bytes, not characters: ASCII text comes apart one character per string, but a multi-byte UTF-8 character is cut into invalid pieces (String.split("é", "") gives two one-byte strings). To work character by character, use String.to_codepoints.
Cost: O(n) where n = byte_size(s).
Split s on the first occurrence of sep. Returns Some((head, tail)) if sep is found, or None if not found.
march> String.split_first("a:b:c", ":")
Some(("a", "b:c"))
march> String.split_first("hello", ":")
None
march> String.split_first("key=val", "=")
Some(("key", "val"))Useful for parsing key-value pairs or protocols where only the first delimiter matters.
Cost: O(n) scan.
Return true if s begins with prefix.
march> String.starts_with("hello", "he")
true
march> String.starts_with("hello", "lo")
false
march> String.starts_with("hello", "")
trueCost: O(|prefix|).
Extract all Unicode codepoints from a string as an Int list. Each codepoint is yielded as a separate integer in the range 0x0 to 0x10FFFF.
Returns a list ordered left-to-right. Invalid UTF-8 sequences are treated as individual byte values (0x0 to 0xFF); well-formed March strings will not encounter this case.
march> String.to_codepoints("A")
[65]
march> String.to_codepoints("é")
[233]
march> String.to_codepoints("AB")
[65, 66]
march> String.to_codepoints("😀")
[128512]Cost: O(n) where n is the byte length of the string.
Parse s as a floating-point number. Returns Ok(f) on success, Err(msg) if the string is not a valid float.
march> String.to_float("3.14")
Ok(3.14)
march> String.to_float("1e10") == Ok(10000000000.0)
true
march> String.to_float("abc")
Err("not a valid float: abc")Cost: O(n).
Parse s as a decimal integer. Returns Ok(n) on success, Err(msg) if the string is not a valid integer.
march> String.to_int("42")
Ok(42)
march> String.to_int("-7")
Ok(-7)
march> String.to_int("abc")
Err("not a valid integer: abc")Cost: O(n).
Convert all ASCII letters in s to lowercase.
march> String.to_lowercase("HELLO WORLD")
"hello world"Only ASCII letters are affected.
Cost: O(n).
Convert all ASCII letters in s to uppercase.
march> String.to_uppercase("Hello World")
"HELLO WORLD"Only ASCII letters (A–Z, a–z) are affected. For full Unicode case mapping use an external library.
Cost: O(n).
Remove leading and trailing ASCII whitespace (spaces, tabs, newlines, CR).
march> String.trim(" hello ")
"hello"
march> String.trim("\thello\n")
"hello"
march> String.trim("hello")
"hello"Cost: O(n).
Remove trailing ASCII whitespace only.
march> String.trim_end(" hello ")
" hello"Cost: O(n).
Remove leading ASCII whitespace only.
march> String.trim_start(" hello ")
"hello "Cost: O(n).