There’s recently been a lot of good discussion surrounding anytype
and duck-typing in Zig. These discussions have been happening for years obviously, but some of the more recent ones here on Ziggit (such as “A better anytype?” and “Zig’s Comptime Is Bonerks Good”) inspired me to try and use Zig’s comptime to solve a few of the issues that were mentioned.
Working through this, I came up with a basic Comptime Type Constraint pattern. Basically, this is just a comptime function that takes in a Type and returns the same Type if all requirements are met (otherwise resulting in a compile error). The focus here is readability and reuse. Readability, in this case, refers both to readers of the code and the error messages that are generated. Reuse simply means the ability to easily apply the pattern in multiple places.
This pattern isn’t entirely new (the stdlib uses comptime validation functions in several areas), but I think it’s under represented. Specifically, using the validation function within a function signature is something I haven’t seen much of, if ever.
With that said, here’s a basic demo of the pattern. Feel free to play with the arg
Type and compile to see the pattern in action:
const std = @import("std");
const log = std.log;
const mem = std.mem;
const meta = std.meta;
pub fn main() !void {
// Change my Type and recompile to see the `IsFoo()` Comptime Type Constraint in action.
// Types: Foo, FooTwo, BadFoo, Bar, Baz
const arg: BadFoo = .{};
someFn(@TypeOf(arg), arg);
}
/// Just a demo Function using our Comptime Type Constraint (`IsFoo()`).
pub fn someFn(ArgT: type, arg: IsFoo(ArgT)) void {
log.debug("Good Foo: {}", .{ arg });
return;
}
/// Our Comptime Type Constraint.
/// This ensures the provided Type is a Struct that contains:
/// - Field: `foo: bool`
/// - Function: `doFoo([]const u8) bool`
pub fn IsFoo(CheckT: type) type {
var check_msg: []const u8 = "The Type `" ++ @typeName(CheckT) ++ "` must be a Struct with the field `foo: bool` and function `doFoo([]const u8) bool`.";
// Check the Type
const raw_info = @typeInfo(CheckT);
if (raw_info != .Struct) @compileError(check_msg);
const info = raw_info.Struct;
var good: bool = true;
// Check for a Field
checkFoo: {
for (info.fields) |field| {
if (!mem.eql(u8, field.name, "foo")) continue;
if (field.type != bool) break;
break :checkFoo;
}
good = false;
check_msg = check_msg ++ "\n- Missing Field: `foo: bool`";
}
// Check for a Declaration
checkDoFoo: {
declCheck: for (info.decls) |decl| {
if (!mem.eql(u8, decl.name, "doFoo")) continue;
const DeclT = @TypeOf(@field(CheckT, "doFoo"));
const decl_info = @typeInfo(DeclT);
if (decl_info != .Fn) break;
for (decl_info.Fn.params, 0..) |param, idx| {
const ParamT = switch (idx) {
0 => []const u8,
else => unreachable,
};
if (param.type != ParamT) break :declCheck;
}
if (decl_info.Fn.return_type != bool) break;
break :checkDoFoo;
}
good = false;
check_msg = check_msg ++ "\n- Missing Fn: `doFoo([]const u8) bool`";
}
if (!good) @compileError(check_msg);
return CheckT;
}
/// The most basic Struct matching the IsFoo Constraint.
pub const Foo = struct {
foo: bool = false,
pub fn doFoo(arg: []const u8) bool {
return mem.eql(u8, arg, "foo");
}
};
/// Another Struct matching the IsFoo Constraint with additional Fields and Declarations.
pub const FooTwo = struct {
foo: bool = false,
other_field: []const u8 = "I can have other fields...",
pub const other_decl: []const u8 = "...and other declarations too!";
pub fn doFoo(arg: []const u8) bool {
return mem.eql(u8, arg, "FOO_TWO");
}
};
/// This Struct nearly matches the IsFoo Constraint, but has a bad signature for `doFoo()`.
pub const BadFoo = struct {
foo: bool = false,
pub fn doFoo(arg: []const u8) []const u8 {
return if (!mem.eql(u8, arg, "foo")) "BAD FOO!" else "foo";
}
};
/// This Struct is similar to `BadFoo`, but has a bad declaration name instead.
pub const Bar = struct {
foo: bool = false,
pub fn doBar(arg: []const u8) bool {
return mem.eql(u8, arg, "bar");
}
};
/// This Struct is missing ALL requirements of the IsFoo Constraint.
pub const Baz = struct {
baz: []const u8 = "",
pub fn doBaz(arg: []const u8) bool {
return mem.eql(u8, arg, "baz");
}
};
This pattern improves readability and reuse in two ways:
- For both code readers and writers, it provides a succinct place to see exactly what requirements a Type must have when reading a function signature. Since this is just a function itself, it can be reused across several functions easily. It also shows up easily in tools like ZLS so you don’t have to blow up the Doc Comment of every function where this Constraint applies just to clarify what the Type of some Parameter must be.
- It lifts errors about the Type to the call site instead of leaving them buried within the function. This can help a lot when debugging and trying to figure out exactly which call has a troublesome parameter.
There are also downsides of course. For one, this isn’t compatible with anytype
as the Type must be provided directly; thus adding more parameters to a function. I typically avoid anytype
in favor of providing the Type anyway, but it’s understandable that not everyone feels the same way.
This pattern also only applies functions. These constraints couldn’t be directly used as Types for fields or declarations, though they could help in Type validation. You could also extend this to automatically create vtables (that could be used anywhere), but that’s a separate discussion beyond the scope of this small solution.
Does this pattern properly address the issues I mentioned? Are there other downsides I’m missing?