Denis Defreyne

Polymorphic strings in Zig

Up: Zig

I originally started thinking about a String abstraction back in Week­notes 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);

  1. “Shared,” or maybe “borrowed?” Either works, I suppose. ↩︎

Note last edited November 2026.
Incoming links: