Up: Zig
I originally started thinking about a String abstraction back in Weeknotes 2025 W45: Polymorphic, but have settled on something simpler for now.
The need
The core problem: if a function in Zig returns a string (a byte slice), do you need to deallocate it or not?
If the answer is a blanket “never deallocate,” then it’s not possible to return dynamic strings without leaking memory, like here:
fn getName(self: Self, alloc: std.mem.Allocator) []const u8 {
return std.fmt.allocPrint(
alloc,
"Installing {s}",
.{ self.package_name },
);
}
If the answer is a blanket “always deallocate,” then it could be that a function needs to always create a copy for no good reason:
fn getName(self: Self, alloc: std.mem.Allocator) []const u8 {
return alloc.dupe(u8, "Running tests");
}
The approach
Create a String num that can be owned or shared:1
pub const String = union(enum) {
shared: []const u8,
owned_string: []u8,
pub fn fromShared(shared: []const u8) String {
return .{
.shared = shared,
};
}
pub fn fromOwnedString(owned_string: []u8) String {
return .{
.owned_string = owned_string,
};
}
// ...
}
A bytes() function gets the byte slice out:
pub const String = union(enum) {
// ...
pub fn bytes(self: @This()) []const u8 {
return switch (self) {
.owned_string, .shared => |value| value,
};
}
// ...
}
The deinit() function is the entire point of this exercise: it frees the slice — if it’s not shared, that is:
pub const String = union(enum) {
// ...
pub fn deinit(self: @This(), allocator: std.mem.Allocator) void {
switch (self) {
.owned_string => |value| allocator.free(value),
.shared => {},
}
}
}
For example:
const s1: String = .fromShared("foo");
defer s1.deinit(alloc);
const s2: String = .fromOwnedString(
alloc.dupe(u8, "bar"),
);
defer s2.deinit(alloc);
Extension: owned slices
The owned_string enum variant works if the slice covers the entire backing string. But that’ll not always be the case:
const bytes = try cwd.readFileAlloc(
io,
"file.txt",
alloc,
.unlimited,
);
const trimmed_slice = std.mem.trim(
u8,
bytes,
" \t\r\n",
);
Here, trimmed_slice is what we’d pass around, but bytes is what we’d need to deallocate. Deallocating trimmed_slice won’t work.
Introducing OwnedSlice:
const OwnedSlice = struct {
slice: []const u8,
buf: []u8,
pub fn dealloc(self: @This(), alloc: std.mem.Allocator) void {
alloc.free(self.buf);
}
};
The String enum gets a third variant:
pub const String = union(enum) {
owned_string: []u8,
owned_slice: OwnedSlice, // ← NEW
shared: []const u8,
// ...
// NEW:
pub fn fromOwnedSlice(slice: []const u8, buf: []u8) String {
return .{
.owned_slice = .{
.slice = slice,
.buf = buf,
},
};
}
pub fn bytes(self: @This()) []const u8 {
return switch (self) {
// ...
.owned_slice => |value| value.slice, // ← NEW
};
}
pub fn deinit(self: @This(), alloc: std.mem.Allocator) void {
switch (self) {
// ...
.owned_slice => |value| value.dealloc(alloc), // ← NEW
}
}
}
A slice with a different backing string is then constructed with . fromOwnedSlice:
const trimmed_string: String = .fromOwnedSlice(trimed_slice, bytes);
For example:
const bytes = try cwd.readFileAlloc(
io,
"file.txt",
alloc,
.unlimited,
);
const trimmed_slice = std.mem.trim(
u8,
bytes,
" \t\r\n",
);
const s3: String = .fromOwnedSlice(trimed_slice, bytes);
defer s3.deinit(alloc);
-
“Shared,” or maybe “borrowed?” Either works, I suppose. ↩︎