Building Your Own Collections
A collection is a type that stores many values.
You have already seen several examples:
std.ArrayList(T)
std.AutoHashMap(K, V)
std.StringHashMap(V)These are general-purpose collections. They are useful because they solve common problems.
But sometimes you need a collection with rules that are specific to your program.
Examples:
a stack that never grows past 64 items
a queue that overwrites old values
a list that stores only unique IDs
a pool that reuses deleted objects
a table optimized for small stringsIn Zig, building your own collection is normal. You do not need classes or inheritance. You define a struct, choose its fields, and write functions that operate on it.
Start with the Rule
Before writing code, decide what the collection promises.
For example, a stack promises:
push adds to the top
pop removes from the top
peek reads the top without removing itA unique list promises:
each value appears at most onceA fixed buffer promises:
capacity never changes
no heap allocation happensA good collection has a small, clear rule.
A Fixed Stack
Here is a stack with fixed capacity.
It stores at most 8 integers.
const std = @import("std");
const FixedStack = struct {
items: [8]i32,
len: usize,
pub fn init() FixedStack {
return FixedStack{
.items = undefined,
.len = 0,
};
}
pub fn push(self: *FixedStack, value: i32) !void {
if (self.len == self.items.len) return error.Full;
self.items[self.len] = value;
self.len += 1;
}
pub fn pop(self: *FixedStack) ?i32 {
if (self.len == 0) return null;
self.len -= 1;
return self.items[self.len];
}
pub fn peek(self: *const FixedStack) ?i32 {
if (self.len == 0) return null;
return self.items[self.len - 1];
}
};Using it:
pub fn main() !void {
var stack = FixedStack.init();
try stack.push(10);
try stack.push(20);
try stack.push(30);
while (stack.pop()) |value| {
std.debug.print("{}\n", .{value});
}
}Output:
30
20
10This stack does not allocate. Its storage is inside the struct.
The Fields Are the Design
Look at the fields:
items: [8]i32,
len: usize,The array stores the values.
The length tells us how many slots are currently used.
The valid items are:
items[0..len]The unused part of the array does not matter.
items:
[10][20][30][?][?][?][?][?]
^
len = 3The values after len are not part of the stack.
Why items Is Undefined
The stack starts with:
.items = undefinedThis is safe because no value is read until it has been written.
At the beginning:
len = 0So the stack contains no valid items.
When we push:
self.items[self.len] = value;
self.len += 1;we write the slot before it becomes part of the valid range.
Make Invalid States Impossible
A collection should protect its own rules.
For this stack, callers should not modify len directly.
This is why collection fields are often kept as implementation details. Zig does not have private fields in the same way some languages do, but you can still design the public API carefully.
The user of the type should call:
try stack.push(10);
_ = stack.pop();They should not manually write:
stack.len = 999;The convention is: treat fields as internal unless the type clearly exposes them.
A Generic Fixed Stack
The previous stack works only for i32.
We can make it generic.
fn FixedStack(comptime T: type, comptime capacity: usize) type {
return struct {
const Self = @This();
items: [capacity]T,
len: usize,
pub fn init() Self {
return Self{
.items = undefined,
.len = 0,
};
}
pub fn push(self: *Self, value: T) !void {
if (self.len == self.items.len) return error.Full;
self.items[self.len] = value;
self.len += 1;
}
pub fn pop(self: *Self) ?T {
if (self.len == 0) return null;
self.len -= 1;
return self.items[self.len];
}
pub fn peek(self: *const Self) ?T {
if (self.len == 0) return null;
return self.items[self.len - 1];
}
pub fn slice(self: *const Self) []const T {
return self.items[0..self.len];
}
};
}Now you can create different stack types:
const IntStack = FixedStack(i32, 8);
const ByteStack = FixedStack(u8, 256);Example:
pub fn main() !void {
var stack = FixedStack([]const u8, 4).init();
try stack.push("alpha");
try stack.push("beta");
while (stack.pop()) |value| {
std.debug.print("{s}\n", .{value});
}
}Output:
beta
alphaThis is a common Zig pattern.
A function returns a type.
The type is specialized at compile time.
Methods Are Just Functions
This method:
pub fn push(self: *Self, value: T) !void {
if (self.len == self.items.len) return error.Full;
self.items[self.len] = value;
self.len += 1;
}takes:
self: *Selfbecause it modifies the stack.
This method:
pub fn peek(self: *const Self) ?T {
if (self.len == 0) return null;
return self.items[self.len - 1];
}takes:
self: *const Selfbecause it only reads the stack.
Use *Self when the method mutates the collection.
Use *const Self when the method only reads.
Returning Optionals
pop can fail in a normal way.
The stack might be empty.
So it returns:
?TThat means:
maybe a T, maybe nullThis is better than crashing on empty input.
if (stack.pop()) |value| {
std.debug.print("{}\n", .{value});
} else {
std.debug.print("empty\n", .{});
}Use optionals for expected absence.
Use errors for operations that can fail for operational reasons, such as allocation, full capacity, or invalid input.
Returning Errors
push can fail because the stack has fixed capacity.
So it returns:
!voidThis means:
success, or an errorThe error is explicit:
if (self.len == self.items.len) return error.Full;Then callers must handle it:
try stack.push(10);This makes the collection’s limits visible.
A Unique List
Now let’s build a small collection with a different rule.
It stores each integer at most once.
const UniqueList = struct {
items: std.ArrayList(i32),
pub fn init(allocator: std.mem.Allocator) UniqueList {
return UniqueList{
.items = std.ArrayList(i32).init(allocator),
};
}
pub fn deinit(self: *UniqueList) void {
self.items.deinit();
}
pub fn contains(self: *const UniqueList, value: i32) bool {
for (self.items.items) |item| {
if (item == value) return true;
}
return false;
}
pub fn append(self: *UniqueList, value: i32) !void {
if (self.contains(value)) return;
try self.items.append(value);
}
pub fn slice(self: *const UniqueList) []const i32 {
return self.items.items;
}
};Using it:
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var list = UniqueList.init(gpa.allocator());
defer list.deinit();
try list.append(10);
try list.append(20);
try list.append(10);
std.debug.print("{any}\n", .{list.slice()});
}Output:
{ 10, 20 }The second 10 is ignored.
The rule lives inside the collection.
Callers do not need to remember to check for duplicates themselves.
Wrapping Standard Collections
UniqueList wraps std.ArrayList.
This is a good technique.
You can use the standard library for storage and still enforce your own API.
Examples:
Stack over ArrayList
Queue over ArrayList
UniqueList over ArrayList
NameTable over StringHashMap
ObjectPool over ArrayListThe wrapper hides operations that would break your rule.
For example, UniqueList should not expose direct mutable access to its inner ArrayList, because a caller could insert duplicates.
Ownership Rules
Every collection should make ownership clear.
Ask these questions:
Does the collection allocate memory?
Does it own the memory?
Who calls deinit?
Does it store borrowed slices?
Does it copy inserted data?
Does removal free anything?A fixed stack owns its storage directly:
items: [capacity]TNo allocator, no deinit.
An ArrayList wrapper owns heap memory:
items: std.ArrayList(T)It needs deinit.
A string map may store borrowed keys:
try map.put("name", value);or owned keys:
const copy = try allocator.dupe(u8, name);
try map.put(copy, value);The cleanup rules are different.
Borrowed vs Owned Data
Suppose a collection stores strings.
Borrowed version:
const NameList = struct {
items: std.ArrayList([]const u8),
};This stores slices. It does not copy the bytes.
The caller must ensure those bytes remain valid.
Owned version:
const OwnedNameList = struct {
allocator: std.mem.Allocator,
items: std.ArrayList([]u8),
};This collection copies each string into heap memory and frees it later.
That design needs more code, but ownership is safer.
An Owned String List
const OwnedStringList = struct {
allocator: std.mem.Allocator,
items: std.ArrayList([]u8),
pub fn init(allocator: std.mem.Allocator) OwnedStringList {
return OwnedStringList{
.allocator = allocator,
.items = std.ArrayList([]u8).init(allocator),
};
}
pub fn deinit(self: *OwnedStringList) void {
for (self.items.items) |item| {
self.allocator.free(item);
}
self.items.deinit();
}
pub fn append(self: *OwnedStringList, value: []const u8) !void {
const copy = try self.allocator.dupe(u8, value);
errdefer self.allocator.free(copy);
try self.items.append(copy);
}
pub fn slice(self: *const OwnedStringList) []const []u8 {
return self.items.items;
}
};Important line:
errdefer self.allocator.free(copy);If append fails after copying the string, errdefer frees the copy.
Without this, allocation failure inside items.append could leak memory.
This is why collection code must be careful.
Designing Cleanup
If a collection allocates, give it a deinit.
pub fn deinit(self: *Self) void {
// free owned resources
}Callers should use:
defer collection.deinit();If a collection does not allocate, it usually does not need deinit.
For example, this fixed stack owns only inline storage:
items: [capacity]TNo heap memory exists.
Exposing Slices
Many collections expose their contents as a slice:
pub fn slice(self: *const Self) []const T {
return self.items[0..self.len];
}A read-only slice is safer:
[]const TIt lets callers read items without changing the collection.
A mutable slice:
[]Tis more dangerous because callers can modify elements directly.
Sometimes that is fine. Sometimes it breaks your collection’s rule.
For UniqueList, a mutable slice could let the caller create duplicates by assigning values.
So prefer []const T unless mutation is part of the design.
Keep the API Small
A collection should expose only the operations it wants to support.
For a stack:
push
pop
peek
len
isEmptyFor a queue:
enqueue
dequeue
peek
len
isEmptyFor a map:
put
get
remove
contains
iteratorDo not expose everything by default.
A smaller API is easier to maintain and harder to misuse.
Test the Invariants
An invariant is a rule that must always remain true.
For FixedStack:
len <= capacity
items[0..len] are valid
items[len..capacity] are ignoredFor a ring buffer:
head < capacity
len <= capacity
logical index maps through moduloFor a unique list:
no duplicate valuesWrite tests for these rules.
test "fixed stack pops in reverse order" {
var stack = FixedStack(i32, 4).init();
try stack.push(10);
try stack.push(20);
try std.testing.expectEqual(@as(?i32, 20), stack.pop());
try std.testing.expectEqual(@as(?i32, 10), stack.pop());
try std.testing.expectEqual(@as(?i32, null), stack.pop());
}Tests are especially useful for collections because small mistakes can corrupt state.
Common Beginner Mistakes
The first mistake is exposing internal fields too freely. If callers can change len, they can break the collection.
The second mistake is forgetting cleanup for owned memory.
The third mistake is returning mutable access when read-only access would be enough.
The fourth mistake is not handling allocation failure after partially changing state.
The fifth mistake is making the collection too general too early.
Start with the rule your program needs. Generalize later.
A Useful Mental Model
A collection is a small machine.
It has state:
fieldsIt has operations:
functionsIt has rules:
invariantsGood collection code keeps those three things aligned.
The fields should support the rules.
The functions should protect the rules.
The caller should not need to remember hidden rules.
Summary
To build your own collection in Zig, define a struct, choose the state, and write methods that protect the collection’s rules.
Use fixed arrays when capacity is known.
Use ArrayList when the collection grows.
Use allocators when the collection owns heap memory.
Use deinit when cleanup is needed.
Expose slices carefully.
Keep the API small.
Most importantly, decide ownership and invariants before you write too much code.