Brainstorming "Zig Object Validator" API
I’m trying to build a Zod-like object validation library in Zig. This post is my
note-to-self on the API design process. My goal was to make something type-safe
and easy to use, but I ran into some interesting problems with Zig’s comptime
and how it works with the Zig Language Server (ZLS). Here’s what I learned.
My main goal was to let a user:
- Define the “shape” of data, including types and validation rules.
- Parse untrusted input against this shape.
- Get back a type-safe, validated Zig struct if it works.
- Have a great developer experience with autocompletion from ZLS.
Attempt 1: The “Schema First” Idea
This is the classic Zod pattern. The user defines a schema, and the library figures out the final Zig type from it. The schema is the single source of truth.
The goal is to create an object() function that generates both the parser and
the final data type. The developer should only have to define the schema.
Here is what I tried:
const std = @import("std");
const testing = std.testing;
pub fn object(comptime definition: anytype) type {
// Get the type of the input schema struct
const SchemaInfo = @TypeOf(definition);
// Introspect the fields of the schema definition at compile time
const schema_fields = std.meta.fields(SchemaInfo);
// Prepare an array to hold the field definitions for our new, generated struct.
var generated_fields: [schema_fields.len]std.builtin.Type.StructField = undefined;
// Loop through the schema fields at compile time to build our new field definitions.
inline for (schema_fields, 0..) |field, i| {
const field_type = @field(definition, field.name);
comptime std.debug.assert(@TypeOf(field_type) == type);
generated_fields[i] = .{
.name = field.name,
.type = field_type,
.default_value_ptr = null,
.is_comptime = false,
.alignment = @alignOf(field_type),
};
}
// Create the new struct type at compile time using the fields we just defined.
const T = @Type(.{
.@"struct" = .{
.layout = .auto,
.fields = &generated_fields,
.decls = &.{},
.is_tuple = false,
},
});
return struct {
pub fn parse(input: anytype) T {
var result: T = undefined;
inline for (std.meta.fields(@TypeOf(input))) |f| {
if (@hasField(T, f.name)) {
@field(result, f.name) = @field(input, f.name);
}
}
return result;
}
};
}
test object {
const UserSchema = object(.{
.name = string(),
});
const data = UserSchema.parse(.{ .name = "alice" });
try testing.expectEqualStrings("alice", data.name);
}The code compiles and works perfectly. The Zig compiler handles all the
comptime logic, generates the type, and makes sure everything is type-safe.
But, the language server fails to figure out the return type of parse() and
the type of data. It just shows (unknown type).
I feel like the Zig compiler is way more powerful than the tooling. The compiler
can run any valid comptime code, but ZLS has to give feedback fast and can’t
always execute complex comptime stuff like @Type. The code is correct, but
the developer experience is not great.
Attempt 2: The anytype Return Value
To make it easier to use, I thought about having object() return a
ready-to-use parser value instead of a type.
The idea is to return a value directly from object() so the user doesn’t have
to write object(...){}.
// The return type is changed from `type` to `anytype`
pub fn object(comptime schema: anytype) anytype {
const GeneratedType = @Type(...);
const Schema = struct { ... }; // The parser struct
return Schema{}; // Return an instance (a value)
}The result is a compile error:
error: expected return type expression, found 'anytype'It turns out that anytype is only for function parameters, not return types. A
function’s signature has to declare a concrete return type. Since the type is
computed inside the function, it can’t be in the signature. The language just
doesn’t allow this.
Attempt 3: The Concrete Struct with a comptime Field
This was a clever idea to try and get the clean API of attempt 2 while keeping
the compiler happy. The plan was to return a single, concrete Schema struct
and pack the dynamically generated type inside it as a field.
Here is how it looks like:
pub const Schema = struct {
// A field to hold the generated type
T: type,
pub fn parse(self: @This(), input: anytype) self.T {
// ...
}
};
pub fn object(comptime schema: anytype) Schema {
const GeneratedType = @Type(...);
return Schema{ .T = GeneratedType };
}
// Usage:
test {
const UserSchema = object(.{ .name = string() }); // Clean API!
const data: UserSchema.T = UserSchema.parse(...); // Tooling still struggles
}This code works perfectly. It’s a valid and solid pattern. But, it runs into the
exact same problem as the first attempt. For ZLS to know the return type of
parse(), it needs to know the value of self.T, which means it has to fully
execute the comptime object() function. It gives up and shows
(unknown type).
I realized that the ZLS limitation is about comptime complexity, not just the
API shape. The main problem for ZLS is figuring out a type that comes from a
complex comptime operation. It doesn’t matter if that result is returned
directly or packed inside another struct.
The Final, Successful Design: “Struct First”
After realizing that any approach using a dynamically generated return type would be hard for ZLS, I changed my strategy. The new goal was to make tooling support the priority by making sure the parser’s public API used simple, statically known types.
The idea is to define a plain Zig struct first, then create a validator from it.
The parse function’s signature should be simple and concrete.
Here is how it looks like:
pub fn Schema(comptime T: type) type {
return struct {
pub fn parse(_: @This(), input: anytype) T {
var result: T = undefined;
inline for (std.meta.fields(@TypeOf(input))) |f| {
if (@hasField(T, f.name)) {
@field(result, f.name) = @field(input, f.name);
}
}
return result;
}
};
}
pub fn schema(comptime T: type, comptime _: anytype) Schema(T) {
return Schema(T){};
}
// Dummy: it should be validator here
pub fn string() type {
return []const u8;
}
test schema {
const User = struct { name: []const u8 };
const UserSchema = schema(User, .{
.name = string(),
});
const data = UserSchema.parse(.{ .name = "alice" });
try testing.expectEqualStrings("alice", data.name);
}The code compiles and works perfectly. The developer experience is great. ZLS
can easily see that UserSchema.parse returns User because User is a
statically known type. Autocompletion and type-on-hover work like they should.
Here is what I learned: Concrete types are tooling-friendly. By designing an API
where the public function signatures use statically known types, you create a
stable contract that tooling can easily understand. The comptime complexity is
moved into the implementation of the parse function, not its signature. This
was the key to solving the ZLS problem. The trade-off is that you have to define
fields in the struct and rules in a separate object, but that’s a common pattern
I believe.
| Tags | zig , api , comptime |
|---|