
Compile-time clock injection in Zig
Time-dependent code is notoriously hard to test. UUID generators that rely on system clocks (v1, v6, and v7) need to exercise edge cases like counter overflow, clock regression, and pre-epoch timestamps. You can’t trigger these with a real clock. You need to control time.
The question: how to inject fake clocks without paying for it in production? All code in this post targets Zig 0.15.2.
The problem
v7 UUID generation depends on std.time.milliTimestamp(). The generator maintains per-thread state: the last millisecond seen and a 12-bit counter. When the counter overflows (4096 UUIDs in one millisecond), the generator spin-waits for the clock to advance. If it doesn’t advance within ~20ms, it returns error.ClockStall.
What I wanted was a clock that never advances (to test ClockStall) and one that’s frozen for one call then jumps forward (to test counter overflow recovery).
With the real system clock, these error paths stay untested in CI, and you find out they’re broken in production.
First attempt: function pointers
The obvious approach is to store the clock function as a field:
const V7State = struct {
last_ms: i64 = 0,
counter: u12 = 0,
clock_fn: *const fn () i64 = std.time.milliTimestamp,
};
This works. Tests swap in a fake:
var fake_time: i64 = 1000;
fn fakeClock() i64 { return fake_time; }
test "clock stall" {
v7_state.clock_fn = fakeClock;
// ...exercise the stall path...
}
But function pointers have costs. Every call to clock_fn goes through a pointer the compiler can’t inline. And you can assign a function with the wrong signature without knowing until runtime, or worse, get silent UB from a mismatched calling convention.
For a library targeting zero overhead, function pointers are an unnecessary tax.
The fix: comptime generics
Zig’s compile-time evaluation lets you parameterize an entire type by its clock functions. The library needs both a nanosecond clock (for v1 and v6 timestamps) and a millisecond clock (for v7), so the type takes both as comptime parameters:
pub const Uuid = UuidImpl(std.time.nanoTimestamp, std.time.milliTimestamp);
pub fn UuidImpl(
comptime nanoTimestampFn: anytype,
comptime milliTimestampFn: anytype,
) type {
return struct {
bytes: [16]u8,
// All generators, state, and methods live here.
// They call nanoTimestampFn() and milliTimestampFn() directly.
threadlocal var v7_state: V7State = .{};
pub fn v7() error{ClockStall}!Self {
// ...
const now_ms = milliTimestampFn(); // direct call, no pointer
// ...
}
};
}
The compiler inlines the concrete function directly. Production code uses Uuid, which binds real clocks. Tests instantiate UuidImpl with fake clocks:
test "v7 ClockStall when clock is frozen" {
const FrozenUuid = UuidImpl(std.time.nanoTimestamp, struct {
fn clock() i64 {
return 1000; // always returns the same millisecond
}
}.clock);
FrozenUuid.v7_state = .{
.initialized = true,
.last_ms = 1000,
.counter = std.math.maxInt(u12),
};
try testing.expectError(error.ClockStall, FrozenUuid.v7());
}
Each test type gets its own UuidImpl instantiation, which means its own threadlocal state. No test pollution. No global teardown. The fake clock struct is anonymous: defined inline at the call site, used once, then gone.
For the “counter overflow resolves when clock advances” test, the fake clock uses mutable struct-level state:
test "v7 counter overflow resolves when clock advances" {
const AdvancingUuid = UuidImpl(std.time.nanoTimestamp, struct {
var calls: u32 = 0;
fn clock() i64 {
@This().calls += 1;
return if (@This().calls <= 1) 1000 else 1001;
}
}.clock);
AdvancingUuid.v7_state = .{
.initialized = true,
.last_ms = 1000,
.counter = std.math.maxInt(u12),
};
const uuid = try AdvancingUuid.v7();
try testing.expectEqual(@as(u48, 1001), uuid.getTimestampV7().?);
}
The clock returns 1000 the first time (triggering the spin-wait), then 1001 on subsequent calls (breaking the spin). The generator recovers, re-randomizes its counter, and produces a valid UUID with the new timestamp.
Comptime signature validation
anytype is flexible but silent. Without validation, passing a wrong function (wrong return type, wrong parameter count) could compile but produce garbage. I learned this the hard way, so the library validates at compile time:
comptime {
if (@typeInfo(@TypeOf(nanoTimestampFn)) != .@"fn")
@compileError("nanoTimestampFn must be a function, got " ++
@typeName(@TypeOf(nanoTimestampFn)));
const nano_info = @typeInfo(@TypeOf(nanoTimestampFn)).@"fn";
if (nano_info.params.len != 0)
@compileError("nanoTimestampFn must take no parameters");
if (nano_info.return_type.? != i128)
@compileError("nanoTimestampFn must return i128");
// Same checks for milliTimestampFn (must return i64, no params)
}
Pass in a fn(i32) i64 by mistake, and the build fails with a message like:
error: nanoTimestampFn must take no parameters
Not a runtime panic. Not UB. A build error you can actually read. These checks can’t be exercised by zig test (Zig has no compile_fail test support as of 0.15), so they’re verified by inspection, but they’ve already caught errors during development.
What this buys
Zero runtime cost. The clock function is known at compile time. The compiler inlines it. There’s no pointer indirection, no indirect call. Production Uuid code is identical to writing std.time.milliTimestamp() directly.
Isolated test state. Each UuidImpl(...) instantiation generates a unique type with its own threadlocal variables. FrozenUuid.v7_state and AdvancingUuid.v7_state are completely independent. No need to reset global state between tests.
Full error path coverage. The library has 100+ tests. The comptime clock tests cover ClockStall for v1, v6, and v7, counter overflow recovery, clock regression, and pre-1582 timestamp saturation. None reachable with the real system clock.
When this pattern applies
This isn’t UUID-specific. Any Zig library that depends on an external effect (time, randomness, I/O, a system call) can use the same pattern:
- Write the implementation as a
fn(...) typeparameterized by the effect - Export a convenience alias that binds the real effect
- Tests instantiate with fakes at comptime
I landed on this pattern for UUIDs, but it’s dependency injection without the ceremony. No interfaces, no allocations, no runtime dispatch. The type system carries the configuration, and the compiler erases it.
If your function pointer exists only to make testing possible, it’s paying a runtime cost for a build-time concern. Comptime generics eliminate that trade-off.
For the bugs this testing approach caught, see Silent data corruption and other UUID bugs I shipped. For the full design story, start with Building a UUID library in Zig over a weekend.