Resizable Structs in Zig

Jul 26, 2025 - 23:15
 0  0

Resizable structs in Zig

In this post I will make the case for the concept of a “runtime resizable struct” in Zig. I will then design an API by exploiting Zig’s powerful comptime functionality.

If you want to skip straight to the implementation, a minimal proof of concept is available as a package on GitHub.

Arrays and many-item pointers

Zig has support for many kinds of collection types in its standard library. All of them can broadly be broken down to two primitive backing types for contiguous data storage:

  • [N]Tarrays, when you always know the length at compile time.
  • [*]Tmany-item pointers, when you may not know the length at compile time.

You may be wondering about slices. Slices can be thought of as syntax sugar around a many-item pointer and a length:

// A desugared slice type
const PersonSlice = struct {
    ptr: [*]Person,
    len: usize,
};

Once you allocate the slice, you can’t grow or shrink its memory without reallocating. That’s why we have std.ArrayList, which is just a wrapper around a slice (itself sugar for many-item pointers) that provides helpers to manage reallocating that slice:

// A naive ArrayList implementation
const PersonList = struct {
    items: []Person,

    pub fn append(self: PersonArrayList, allocator: Allocator, person: Person) !void {
        self.items = try allocator.realloc(self.items, self.items.len + 1);
        self.items[self.items.len] = pie;
    }
};

We can build up lots of interesting collection types with these primitives, but at the end of the day, we have arrays, many-item pointers, and slices (which are just many-item pointers!). If the size is fixed at compile time, you’ll probably be working with arrays. If the size may be influenced by runtime values, you’ll probably be working with many-item pointers.

Structs

Arrays/pointers work well for contiguous storage of the same type, but a struct can be thought of as contiguous collection of different types:

const City = struct {
    name: []const u8,
    population: u32,
    area: f32,
};

It has a compile time known number of values, they have a compile time known size, and as a result, the size of the struct is known at compile time.

The problem

Let’s break down the three tools I outlined above for contiguous storage:

ToolElementsSize
ArraysSameComptime
Many-item pointersSameRuntime
StructsDifferentComptime
???DifferentRuntime

At this point, you’ll notice a missing piece – what if we want to access contiguously stored data, with differing types, but the size/length of that data is only known at runtime?

A use-case

It’s fun to design tools for imaginary problems, but before we continue, is this really something that people need? I believe that it is. Here is a snippet of code in Zig’s standard library where such a thing might be useful.

The status quo

To do this today, you essentially have to take the following steps:

  1. Calculate the size of the data you need to store
  2. Allocate a []u8 to store it in
  3. Break up that byte slice into pieces for each field using a lot of @ptrCast/@alignCast
  4. Initialize the data in each field, making sure to keep track of all runtime lengths

Here I have done so using a simplified version of the use-case from the standard library:

// A known-size data structure
const Connection = struct {
    client: Client,
    host_len: usize,
    read_buffer_len: usize,
    write_buffer_len: usize,
};

// First, calculate the size of, and then allocate, a byte slice for the data
const length = calculateSizeAtRuntime(input);
const bytes = try gpa.alignedAlloc(u8, @alignOf(Connection), length);

// Then break up that byte slice into its components
const conn: *Connection = @ptrCast(bytes);

const host_offset = @sizeOf(Connection);
const host = bytes[host_offset..][0..input.host_len];

const read_buffer_offset = host_offset + input.host_len;
const read_buffer = bytes[read_buffer_offset..][0..input.read_buffer_len];

const write_buffer_offset = read_buffer_offset + input.read_buffer_len;
const write_buffer = bytes[write_buffer_offset..][0..input.write_buffer_len];

// Initialize the known-size data, storing runtime sizes
conn.* = .{
    .client = input.client,
    .host_len = input.host_len,
    .read_buffer_len = input.read_buffer_len,
    .write_buffer_len = input.write_buffer_len,
};

// Initialize the runtime sized data
@memcpy(host, input.host);

Doing all of this correctly can be tricky. Even just the first step is more complex than you might think. In our example, the host and buffer fields are both arrays of u8s, but what if the elements have an alignment requirement? If your fields are not ordered correctly, subsequent fields may become unaligned if you don’t pad correctly. If you don’t track all of the lengths, you can accidentally introduce undefined behavior, and as a result, possible security vulnerabilities. Let alone resizing the thing if the lengths change later!

We can do better!

Variable Length Arrays

As an aside, some programming languages have a concept of variable length arrays (VLA). The idea is to have an array (stack allocated contiguous data) with a length that is known at runtime (it is variable).

Zig does not, and will not, have VLAs in the language spec. Instead, you can allocate a slice on the heap. If you want to have the data on the stack, use an array as a bounded backing store, and work with a slice into it:

// We have an upper limit of 32 values
const my_buffer: [32]u64 = undefined;
const my_vla: []u64 = my_buffer[0..runtime_length];

Dreaming up an API

Variable length arrays don’t, and won’t, exist in Zig, but what if they did? We might have defined our Connection type something like this:

const Connection = struct {
    client: Client,
    host: VariableLengthArray(u8)
    read_buffer: VariableLengthArray(u8),
    write_buffer: VariableLengthArray(u8)
};

Working with it would be a little easier, because we can just initialize the VLAs at runtime:

const conn = Connection{
    .client = input.client,
    .host = .initWithSlice(input.host),
    .read_buffer = .initWithCapacity(input.read_buffer_len),
    .write_buffer = .initWithCapacity(input.write_buffer_len),
};

The VariableLengthArray type could handle the alignment of our data, and we can just focus on the data itself, accessing the fields directly:

std.debug.print("Host: {s}\n", .{conn.host});
const reader = stream.reader(conn.read_buffer);
const writer = stream.writer(conn.write_buffer);

And yet, all of this data is stored contiguously in memory, without having to allocate each individual field separately!

An implementation

We can actually achieve something sort of like this with some good ’ole Zig flavored comptime meta programming.

We’ll define two structs: a ResizableArray(T), and a ResizableStruct(Layout) that uses them. The ResizableArray(T) will just be a marker type - such a thing, as far as I know, can’t actually exist in Zig. It’s used by the comptime code in ResizableStruct(Layout) to know which fields have runtime known lengths.

The ResizableStruct(Layout) will act as a utility type that makes working with pointers to each field easier. We’d use it like this:

const Connection = ResizableStruct(struct {
    client: Client,
    host: ResizableArray(u8)
    read_buffer: ResizableArray(u8),
    write_buffer: ResizableArray(u8)
});

const conn = try Connection.init(allocator, .{
    .host = input.host_len,
    .read_buffer = input.read_buffer_len,
    .write_buffer = input.write_buffer_len,
});
defer conn.deinit(allocator);

const client = conn.get(.client);
client.* = input.client;

const host = conn.get(.host);
@memcpy(host, input.host);

const reader = stream.reader(conn.get(.read_buffer));
const writer = stream.writer(conn.get(.write_buffer));

// We can resize the arrays later; this invalidates the above pointers.
conn.resize(.{
    .host = 123,
    .read_buffer = 456,
    .write_buffer = 789,
});

The Connection becomes a utility type. It’s kind of like a Slice, or an ArrayList. It can be passed around, stored in arrays, and can be resized at runtime. The only information it needs to store is a pointer to the start of the data, and the lengths of each array. The backing implementation looks like this:

const Connection = struct {
  ptr: [*]u8,
  lens: struct {
    host: usize,
    read_buffer: usize,
    write_buffer: usize,
  }
}

The only cost is four usizes! This works because we are able to use comptime magic to get the size of each field. Let’s take a look at the current implementation of the get method:

pub fn get(self: Self, comptime field: FieldEnum(Layout)) blk: {
    const Field = @FieldType(Layout, @tagName(field));
    break :blk if (isResizableArray(Field)) []Field.Element else *Field;
} {
    const offset = offsetOf(self.lens, @tagName(field));
    const size = sizeOf(self.lens, @tagName(field));
    const bytes = self.ptr[start..][0..size];

    return @ptrCast(@alignCast(bytes));
}

Look familiar? This is the same basic pattern used in the example use-case when we broke up the byte array. The comptime magic comes in by examining ResizableArray. First, we can easily check if a field is resizable with a little helper function:

pub fn ResizableArray(comptime T: type) type {
    return struct {
        pub const Element = T;
    };
}

fn isResizableArray(comptime T: type) bool {
    return @typeInfo(T) == .@"struct" and
        @hasDecl(T, "Element") and
        T == ResizableArray(T.Element);
}

Once we know FieldType is a ResizableArray(T), we can then safely access FieldType.Element. With the array element types and the self.lens array, we now have everything we need to calculate the size, offset and alignment of every field, regardless of their positioning in the struct.

I have published a minimal implementation of this API as a package on GitHub, and you can use it today. The API docs are on GitHub pages as well, but they can be boiled down to four methods:

  • init to allocate the memory
  • get to get a pointer to a field
  • resize to resize the arrays
  • deinit to free the memory

If there is enough interest, I can write a follow-up article on how the implementation works.

Request for feedback

I think this, or something like it, would be a valuable addition to Zig’s standard library, but it needs scrutiny. If you have a real world use-case for this type, I would love to hear about it. If you can think of enhancements to the API, feel free to open an issue on GitHub.

What's Your Reaction?

Like Like 0
Dislike Dislike 0
Love Love 0
Funny Funny 0
Angry Angry 0
Sad Sad 0
Wow Wow 0