March Docs

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_count counts 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_size for performance-critical paths.

Functions

fnbyte_sizebyte_size(s) do string_byte_length(s) end#

Return the number of bytes in the UTF-8 encoding of s.

    String.byte_size("hello")  -- 5
    String.byte_size("é")      -- 2  (2-byte UTF-8 sequence)
    String.byte_size("")       -- 0

Cost: O(1) — reads a stored length field.
fncodepoint_countcodepoint_count(s) do string_grapheme_count(s) end#

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.

    String.codepoint_count("hello")  -- 5
    String.codepoint_count("é")      -- 1  (NFC: one codepoint, two bytes)
    String.codepoint_count("é")      -- 2  (NFD: e + combining acute)
    String.codepoint_count("")       -- 0

Use 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.
fnconcatconcat(a, b) do string_concat(a, b) end#

Concatenate two strings.

    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.
fncontainscontains(s, substring) do string_contains(s, substring) end#

Return true if substring appears anywhere in s.

    String.contains("hello world", "world")  -- true
    String.contains("hello world", "xyz")    -- false
    String.contains("abc", "")               -- true  (empty is always found)

Cost: O(n * m) worst case, where n = byte_size(s) and m = byte_size(substring).
fnends_withends_with(s, suffix) do string_ends_with(s, suffix) end#

Return true if s ends with suffix.

    String.ends_with("hello", "lo")  -- true
    String.ends_with("hello", "he")  -- false
    String.ends_with("hello", "")    -- true

Cost: O(|suffix|).
fnfrom_codepointfrom_codepoint(cp : Int) : Option(String)#
fnfrom_floatfrom_float(f) do float_to_string(f) end#

Convert a float to a string using OCaml's default float formatting.

    String.from_float(3.14)  -- "3.14"
    String.from_float(1.0)   -- "1."
    String.from_float(1e10)  -- "10000000000."

The exact format matches OCaml's `string_of_float`.
Cost: O(1).
fnfrom_intfrom_int(n) do int_to_string(n) end#

Convert an integer to its decimal string representation.

    String.from_int(42)   -- "42"
    String.from_int(-7)   -- "-7"
    String.from_int(0)    -- "0"

Cost: O(digits).
fngrapheme_countgrapheme_count(s) do codepoint_count(s) end#

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.

fnindex_ofindex_of(s, substring) do string_index_of(s, substring) end#

Find the byte offset of the first occurrence of substring in s. Returns Some(offset) if found, None if not found.

    String.index_of("hello", "ll")   -- Some(2)
    String.index_of("hello", "xyz")  -- None
    String.index_of("hello", "")     -- Some(0)

Cost: O(n * m) worst case.
fnindex_of_fromindex_of_from(s, substring, start)#

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.

    String.index_of_from("a,b,c", ",", 0)   -- Some(1)
    String.index_of_from("a,b,c", ",", 2)   -- Some(3)
    String.index_of_from("a,b,c", ",", 4)   -- None

A 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.
fnis_emptyis_empty(s) do string_is_empty(s) end#

Return true if s contains no bytes.

    String.is_empty("")      -- true
    String.is_empty("x")    -- false
    String.is_empty("  ")   -- false

Cost: O(1).
fnjoinjoin(xs, sep) do string_join(xs, sep) end#

Join a list of strings with a separator inserted between each element.

    String.join(["a", "b", "c"], "-")  -- "a-b-c"
    String.join([], ",")               -- ""
    String.join(["solo"], ",")         -- "solo"

Cost: O(total bytes in list + (n-1) * |sep|).
fnlast_index_oflast_index_of(s, sub) do string_last_index_of(s, sub) end#

Find the byte offset of the last occurrence of substring in s. Returns Some(offset) if found, None if not found.

    String.last_index_of("hello.world.txt", ".")  -- Some(11)
    String.last_index_of("hello", "xyz")           -- None
    String.last_index_of("hello", "")              -- Some(5)

Cost: O(n * m) worst case.
fnpad_leftpad_left(s, width, fill) do string_pad_left(s, width, fill) end#

Pad s on the left with fill until the byte length reaches width. If byte_size(s) >= width, s is returned unchanged.

    String.pad_left("hi", 5, "0")   -- "000hi"
    String.pad_left("hello", 3, "0") -- "hello"  (already long enough)

`fill` must be a single-byte string (single ASCII character).

Cost: O(width).
fnpad_rightpad_right(s, width, fill) do string_pad_right(s, width, fill) end#

Pad s on the right with fill until the byte length reaches width. If byte_size(s) >= width, s is returned unchanged.

    String.pad_right("hi", 5, ".")  -- "hi..."
    String.pad_right("hello", 3, ".") -- "hello"

`fill` must be a single-byte string (single ASCII character).

Cost: O(width).
fnrepeatrepeat(s, n) do string_repeat(s, n) end#

Repeat s exactly n times.

    String.repeat("ab", 3)  -- "ababab"
    String.repeat("x", 0)   -- ""
    String.repeat("x", 1)   -- "x"

Cost: O(n * |s|) — allocates a new string.
fnreplacereplace(s, old, new) do string_replace(s, old, new) end#

Replace the first occurrence of old in s with new.

    String.replace("aabbaa", "a", "x")  -- "xabbaa"
    String.replace("hello", "xyz", "!")  -- "hello"  (no match)

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.
fnreplace_allreplace_all(s, old, new) do string_replace_all(s, old, new) end#

Replace every non-overlapping occurrence of old in s with new.

    String.replace_all("aabbaa", "a", "x")  -- "xxbbxx"
    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).
fnreversereverse(s) do string_reverse(s) end#

Return s with its bytes reversed.

    String.reverse("hello")   -- "olleh"
    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).
fnsliceslice(s, start, len) do string_slice(s, start, len) end#

Alias for slice_bytes: extract len bytes starting at start.

fnslice_bytesslice_bytes(s, start, len) do string_slice(s, start, len) end#

Return a byte-indexed substring of s starting at byte offset start with length len bytes.

    String.slice_bytes("hello world", 6, 5)  -- "world"
    String.slice_bytes("hello", 0, 100)       -- "hello"  (clamped)

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.
fnsplitsplit(s, sep) do string_split(s, sep) end#

Split s on every occurrence of sep, returning a List(String).

    String.split("a,b,c", ",")   -- ["a", "b", "c"]
    String.split("hello", "")    -- ["hello"]  (empty sep returns whole string)
    String.split("aXbXc", "X")   -- ["a", "b", "c"]

Adjacent separators produce empty strings in the result list.

Cost: O(n) where n = byte_size(s).
fnsplit_firstsplit_first(s, sep) do string_split_first(s, sep) end#

Split s on the first occurrence of sep. Returns Some(head, tail) if sep is found, or None if not found.

    String.split_first("a:b:c", ":")  -- Some("a", "b:c")
    String.split_first("hello", ":")  -- None
    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.
fnstarts_withstarts_with(s, prefix) do string_starts_with(s, prefix) end#

Return true if s begins with prefix.

    String.starts_with("hello", "he")  -- true
    String.starts_with("hello", "lo")  -- false
    String.starts_with("hello", "")    -- true

Cost: O(|prefix|).
fnto_codepointsto_codepoints(s : String) : List(Int)#
fnto_floatto_float(s)#

Parse s as a floating-point number. Returns Ok(f) on success, Err(msg) if the string is not a valid float.

    String.to_float("3.14")  -- Ok(3.14)
    String.to_float("1e10")  -- Ok(10000000000.0)
    String.to_float("abc")   -- Err("not a valid float")

Cost: O(n).
fnto_intto_int(s)#

Parse s as a decimal integer. Returns Ok(n) on success, Err(msg) if the string is not a valid integer.

    String.to_int("42")   -- Ok(42)
    String.to_int("-7")   -- Ok(-7)
    String.to_int("abc")  -- Err("not a valid integer")

Cost: O(n).
fnto_lowercaseto_lowercase(s) do string_to_lowercase(s) end#

Convert all ASCII letters in s to lowercase.

    String.to_lowercase("HELLO WORLD")  -- "hello world"

Only ASCII letters are affected.

Cost: O(n).
fnto_uppercaseto_uppercase(s) do string_to_uppercase(s) end#

Convert all ASCII letters in s to uppercase.

    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).
fntrimtrim(s) do string_trim(s) end#

Remove leading and trailing ASCII whitespace (spaces, tabs, newlines, CR).

    String.trim("  hello  ")   -- "hello"
    String.trim("\thello\n")   -- "hello"
    String.trim("hello")       -- "hello"

Cost: O(n).
fntrim_endtrim_end(s) do string_trim_end(s) end#

Remove trailing ASCII whitespace only.

    String.trim_end("  hello  ")  -- "  hello"

Cost: O(n).
fntrim_starttrim_start(s) do string_trim_start(s) end#

Remove leading ASCII whitespace only.

    String.trim_start("  hello  ")  -- "hello  "

Cost: O(n).