⚡ Zig Guide LiveUnofficial
✓ Zig 0.17.0-dev.1454+5faa79730On an older Zig?

Validation from Declarations

The problem

Your library wants optional, per-type behavior: validation rules on one schema, custom serialization on another, nothing on a third. Interfaces and inheritance are the classical answers; Zig has neither. What it has is better suited to libraries: the caller declares what they support, and the library asks, at compile time.

The pattern

  1. The convention: a schema may declare pub const rules = .{ ... }, naming its own fields.
  2. The library checks @hasDecl(T, "rules") before touching it. No declaration means no validation and zero generated code.
  3. inline for walks the rules; @hasField on each rule struct decides which checks apply. Every lookup resolves while compiling; the runtime work is just the comparisons themselves.
const std = @import("std");
const expect = std.testing.expect;

const Violation = struct {
    field: []const u8,
    rule: []const u8,
};

// The library-side half: walk the value's fields; when the schema declares
// a rule for a field, apply it. Schemas without rules validate trivially,
// and unknown rule names fail the build rather than being ignored.
fn validate(value: anytype) ?Violation {
    const T = @TypeOf(value);
    if (!@hasDecl(T, "rules")) return null;

    const info = @typeInfo(@TypeOf(T.rules)).@"struct";
    inline for (info.field_names) |field| {
        const rule = @field(T.rules, field);
        const v = @field(value, field);
        const R = @TypeOf(rule);

        if (@hasField(R, "min_len")) {
            if (v.len < rule.min_len) return .{ .field = field, .rule = "min_len" };
        }
        if (@hasField(R, "max")) {
            if (v > rule.max) return .{ .field = field, .rule = "max" };
        }
    }
    return null;
}

// The caller-side half: rules live on the schema as one declaration,
// naming fields directly. A rule for a field that does not exist fails
// to compile, because @field(value, ...) has nothing to resolve to.
const User = struct {
    name: []const u8,
    age: u8,

    pub const rules = .{
        .name = .{ .min_len = 3 },
        .age = .{ .max = 130 },
    };
};

test "a valid value passes" {
    try expect(validate(User{ .name = "ada", .age = 36 }) == null);
}

test "violations name the field and the rule" {
    const short = validate(User{ .name = "al", .age = 36 }).?;
    try expect(std.mem.eql(u8, short.field, "name"));
    try expect(std.mem.eql(u8, short.rule, "min_len"));

    const old = validate(User{ .name = "methuselah", .age = 200 }).?;
    try expect(std.mem.eql(u8, old.field, "age"));
    try expect(std.mem.eql(u8, old.rule, "max"));
}

test "a schema without rules is fine" {
    const Point = struct { x: f32, y: f32 };
    try expect(validate(Point{ .x = 1, .y = 2 }) == null);
}

Errors land at the right build

Two mistakes are caught at the caller’s compile, with no effort from the library author:

  • A rule for a nonexistent field: @field(value, "nmae") cannot resolve.
  • A min_len rule on an integer field: v.len does not exist on u8.

This is the deep reason to prefer declarations over registration functions. A runtime registerValidator("name", ...) call can only fail at runtime; a declaration is analyzed against the schema while both are open in the compiler.

The standard library does the same thing

This is not an ORM invention. std.json looks for jsonStringify on your type; formatting looks for a format declaration. When your library adopts the same shape (a documented declaration name, discovered with @hasDecl), Zig users already know how to hold it.

In a full ORM

The draft this distills from runs validation inside insert and update, returning a typed error set with the violation attached, and adds cross-field rules (unique-together, conditional requirements) as declarations following the same discovery pattern. The skeleton stays this page’s twenty lines; everything else is more rules.