Mutable Strings
This section describes the mutable string capabilities, building on the core StrBuf type from section 3.7.
These capabilities are not gated behind a preview feature: ADR-0014 was implemented on 2025-12-27, and everything specified here compiles with no --preview flag. As §3.7:2 states, the only thing a program needs in order to name StrBuf is an explicit import of the trusted standard-library module.
StrBuf Representation
A StrBuf value consists of three components: a pointer to the string data, the length in bytes, and the allocated capacity.
When capacity is zero, the string data points to read-only memory (a string literal). When capacity is greater than zero, the string data is heap-allocated and can be mutated.
This representation allows string literals to remain cheap (no allocation) while enabling mutation when needed. Mutation methods automatically promote read-only strings to the heap.
StrBuf Ownership
StrBuf is an affine type: a StrBuf value is consumed when used and cannot be used again unless explicitly cloned.
StrBuf is not @copy. Passing a string to a function or assigning it to another binding moves the string.
fn takes_string(s: StrBuf) -> i32 { 0 }
fn main() -> i32 {
let mut s = "hello";
takes_string(s); // s is moved
// takes_string(s); // ERROR: use of moved value
0
}
Construction
StrBuf.new() returns an empty string with no allocation.
StrBuf.with_capacity(cap: u64) returns an empty string with pre-allocated capacity for cap bytes. When cap is zero, it performs no allocation and returns a string whose capacity is zero.
const std = @import("std");
const StrBuf = std.strbuf.StrBuf;
fn main() -> i32 {
let empty = StrBuf.new();
let prealloc = StrBuf.with_capacity(1024);
0
}
Query Methods
fn len(borrow self) -> u64 returns the length of the string in bytes.
fn capacity(borrow self) -> u64 returns the allocated capacity of the string. Returns zero for string literals.
fn is_empty(borrow self) -> bool returns true if the string length is zero.
Query methods use borrow self to access the string without consuming it, leaving the string valid after the call.
const std = @import("std");
const StrBuf = std.strbuf.StrBuf;
fn main() -> i32 {
let s = "hello";
if s.len() == 5 && !s.is_empty() {
0
} else {
1
}
}
Mutation Methods
fn push_str(inout self, other: StrBuf) appends the contents of other to the string. If the string is a literal (capacity zero), it is first promoted to the heap.
fn push(inout self, byte: u8) appends a single byte to the string.
fn clear(inout self) removes all content from the string but retains the allocated capacity.
fn reserve(inout self, additional: u64) ensures the string has capacity for at least additional more bytes.
Mutation methods use inout self to modify the string in place. The variable must be declared with let mut to allow mutation.
const std = @import("std");
const StrBuf = std.strbuf.StrBuf;
fn main() -> i32 {
let mut s = StrBuf.new();
s.push_str("hello");
s.push_str(" world");
s.push(33); // '!' character
0
}
Heap Promotion
When a mutation method is called on a string literal (capacity zero), the string is promoted to the heap:
- A heap buffer is allocated with capacity for the existing content plus the new content
- The existing content is copied from read-only memory to the heap buffer
- The string's pointer and capacity are updated
- The mutation is performed
Heap promotion is transparent to the user. There is no separate "owned" vs "borrowed" string distinction.
const std = @import("std");
const StrBuf = std.strbuf.StrBuf;
fn main() -> i32 {
let mut s = "hello"; // literal: capacity = 0
s.push_str("!"); // promotes to heap, then appends
// s is now "hello!" with capacity > 0
0
}
Growth Strategy
When appending would exceed the current capacity, a new buffer is allocated whose capacity is at least the required capacity (the current length plus the appended bytes), the existing content is copied into it, and the old buffer is freed. The capacity actually chosen is implementation-defined (§3.7:41): the only guarantees are that it is sufficient for the append and that growth is amortized, so that a sequence of appends costs O(1) amortized time per appended byte. A program MUST NOT assume any particular resulting value from capacity().
This implementation chooses that capacity by starting from the larger of the current capacity and 16 bytes and doubling until it reaches the required capacity. Two consequences are worth naming, because the earlier "double the current capacity" wording implied otherwise. Growth from a literal or an empty buffer lands on 16 however few bytes were appended; and a single append can more than double the capacity, because the doubling is a loop rather than one step. Appending 5 bytes to an empty StrBuf gives capacity 16, and appending 30 more (35 required) then runs 16 → 32 → 64, giving capacity 64 rather than 32. The loop is what provides the amortized bound of 3.10:24; it is not itself a requirement, and a later release may choose differently.
Clone
fn clone(borrow self) -> StrBuf creates a deep copy of the string, allocating a new heap buffer with the same content.
Clone borrows self so the original string remains valid. Cloning always allocates, even for string literals.
const std = @import("std");
const StrBuf = std.strbuf.StrBuf;
fn main() -> i32 {
let a = "hello";
let b = a.clone(); // deep copy
// Both a and b are valid
0
}
Destructor
When a StrBuf value is dropped:
- If capacity is zero (literal), no action is taken
- If capacity is greater than zero (heap-allocated), the heap buffer is freed
The destructor automatically distinguishes between string literals and heap-allocated strings, ensuring correct cleanup.
const std = @import("std");
const StrBuf = std.strbuf.StrBuf;
fn main() -> i32 {
let mut s = "hello";
s.push_str("!"); // promotes to heap
0
} // destructor frees the heap buffer
Byte StrBuf Semantics
Rue strings are conventionally UTF-8 rather than strictly validated:
- StrBuf literals are valid UTF-8 (validated at compile time)
- At runtime, strings are byte sequences
- Methods like
push_straccept any bytes - No runtime UTF-8 validation overhead
This approach matches Go's string and Rust's bstr crate: UTF-8 is the convention, but the type does not enforce it at runtime.
Byte Access
fn byte_at(borrow self, index: u64) -> Option(u8) returns the byte stored at index. When index is less than the length it returns Some(byte); when index is greater than or equal to the length it returns None. The read is bounds-checked and never traps: an out-of-range index yields None rather than aborting the program. byte_at borrows self, so the string remains valid after the call.
const std = @import("std");
const StrBuf = std.strbuf.StrBuf;
fn main() -> i32 {
let s: StrBuf = "hi";
let O = std.option.Option(u8);
let first = match s.byte_at(0) {
O.Some(b) => @intCast(b), // 'h' = 104
O.None => -1,
};
// Reading past the end yields None, not a trap.
let past = match s.byte_at(2) {
O.Some(b) => @intCast(b),
O.None => 0,
};
first + past // 104
}