Proposal: Add `std.meta.satisfies` Function

Specific Content

Function signature:

pub fn satisfies(comptime T: type, comptime I: type) bool {
	...
}

pub fn assertSatisfies(comptime T: type, comptime I: type) void {
	...
}

assertSatisfies function is the same as satisfies, the only difference is that when it finds that the matching rule is not met, it will tell you the reason for the dissatisfaction in the compilation error message.

Specific rules:

The function returns true when the declarations of type I can match any subset of type T—that is, when type T is “shaped like” type I. Before detailing the rules, I should remind you that these rules are actually not complicated; they are very easy to understand through code, they are just not easy to describe in words—much like addition, subtraction, multiplication, and division are complex to define mathematically, but that does not affect our ability to use them!


Matching Rules

1. Type Declarations

Type declarations require the same name and the same container kind. That is, if a type declaration A in I is assigned struct{}, then the same-named type in T must also be of struct type.

pub const I = struct {
	pub const A = struct{};
};

// Satisfies
pub const T = struct {
	pub const A = struct{};
};

// Does not satisfy
pub const T = struct {
	pub const A = enum{};
};

In other words, the keyword before the braces that defines the type must match, while the contents inside the braces are ignored (except for tuples). This includes:

struct
packed struct
enum
union
union(enum)
union(T)  // where T follows the same matching rules as constant types below
opaque
error

In addition, there are several special cases for type declaration matching:

  • type: matches any type.
  • comptime_int: matches any integer type only.
  • comptime_float: matches any floating-point type only.
  • struct{T, ...} (tuple): matches any one of the types listed in the tuple members.

2. Constant Declarations

Constants require the same name and the same type. That is, if member a in I is of type u32, then member a in T must also be of type u32.

pub const I = struct {
	pub const a: u32 = undefined;
	pub const b: [4]f64 = undefined;
};

// Satisfies
pub const T = struct {
	pub const a: u32 = 10;
	pub const b: [4]f64 = .{1.0, 2.0, 3.0, 4.0};
};

// Does not satisfy
pub const T = struct {
	pub const a: u64 = 100;
	pub const b: [4]f64 = .{1.0, 2.0, 3.0, 4.0};
};

There is one special case for constant matching: if the constant’s type is defined inside I, then the same-named declaration in T should have the equivalent type defined in T, not the type defined in I. For example:

pub const I = struct {
	pub const T = struct{};
	pub const a: T = undefined;
};

// Should be:
pub const A = struct {
	pub const T = struct {
		x: usize,
		y: usize,
	};
	pub const a: T = .{
		.x = 100,
		.y = 200,
	};
};

// Not:
pub const A = struct {
	pub const T = struct{};
	pub const a: I.T = undefined;
};

Because I is merely a template for checking; its values are generally considered meaningless. If a type is truly intended for global use, it should not be placed inside I.


3. Function Declarations

Functions require duck‑type callability. That is, the parameters and return type must mimic those declared in I, while calling conventions and other attributes may differ and are left to compile‑time handling. For example:

pub const I = struct {
	pub fn foo(a: i32, b: f64) i32 {
		_ = .{a, b}; // only to suppress unused variable errors
		return undefined;
	}
};

// Satisfies
pub const T = struct {
	pub fn foo(a: i32, b: f64) i32 {
		...
	}
};

// Does not satisfy
pub const T = struct {
	pub fn foo(a: i32, b: f32) i32 {
		...
	}
};

As with constant declarations, for function declarations that reference types defined inside I, the corresponding types in the same‑named function in T should be looked up in T, including Self.

Additionally, there is one special case for function declarations: if the first parameter of the function in T is *anyopaque / *const anyopaque, then it shall be allowed to match both *anyopaque / *const anyopaque and *Self / *const Self in I.


4.Top‑Level Types

Matching the container kind of the top‑level types (I and T themselves) is left to the user to check directly.

Purpose

Improve the user experience of ‘anytype’.

and so on…

Reference

Verafahn/vftrait: A library for duck type checking during compilation.
This is just an exploratory implementation, it does not achieve all the features I mentioned. But I think there are many usage scenarios for this function, and it can already be included in the standard library.

2 Likes

I don’t think std wants to encourage any particular way to do this kind of meta programming.

Especially since most cases are simple enough to do by hand.

std tends to favour smaller, more composable, APIs.


You could create your own types, to allow those types to be used as it is perfectly reasonable an API would want them.
e.g meta.AnyType, meta.AnyInt, meta.AnyFloat, meta.Any(.{ A, B, C})

1 Like

Why is this a proposal with an explicit description of what the userland function does that does not also have the source code for the function?

I don’t expect this or anything like it to be accepted into std.

std.meta.trait was in std for years and got nuked.

3 Likes

I have provided a feasible implementation, but there are slight differences from the above rules. Its reference section.

I see the writing on the wall, but to offer some pushback…

  1. availing a solution (not necessarily a holy grail) in std, rather than via new keywords or builtins as suggested in other recent brainstorms, could be valuable to many users even if the std itself decided NOT to overcomplicate its code with use of the facility. (It looks like std code did have references to std.meta.trait, and the indirection-complication likely contributed to the smell that led to killing trait.
  2. std.meta.trait is ages old now and after a quick look at it… I feel it’s more awkward and complicated than @Verafahn’s proposal here.
  3. @Verafahn’s proposal looks like it adheres to the “optional only” aspect discussed in the earlier thread, and thus could be entirely or mostly ignored by std code if it didn’t add value.
  4. There have been recent conversations around whether std should include the kitchen sink - in particular, whether it should include facilities not needed by the language or std itself; I don’t know the core team’s read on that, but if there was readiness to expand the boundaries a little, in light of the maturity stage of zig, then this could be a candidate.
  5. If the inclusion of this (or something like this) in std made zig more attractive to programmers endeared with trait-style programming and use-cases, it could expand the scope of zig adoption. (I rarely ever write code that would benefit from the benefits of clear interface definition for polymorphic designs, so this position is not an expression of selfish desire at all).
  6. On the other hand, one of the other significant ingredients in earlier discussions has been the value of seeing interface specification in the function signature (via the use of a new zig keyword, for instance). This new proposal does not (seem to) scratch that itch, and that may be an unscratchable itch (it’s “going too far”) in that it involves supporting a new keyword (probably), and thus changing the language, not just the std.

I appreciate the thoughtfulness of your proposal, @Verafahn, and expect your vftrait lib, at least, will find users as such.

2 Likes

You’re thinking too object-oriented. Instead of creating interfaces/traits (your I parameter), you should probably just write a different “satisfies” function for each I.
I was intrigued anyway and tried implementing a basic version of this for structs, but for some reason it doesn’t actually check if T has any of the declarations that I has, and I can’t figure out why, so if someone could help me figure it out that’d be great

pub fn isSupersetStruct(T: type, I: type) bool {
    const i_info = @typeInfo(I).@"struct";
    inline for (.{ i_info.field_names, i_info.decl_names }) |members| {
        inline for (members) |member| {
            if (!@hasField(T, member) or @FieldType(I, member) != @FieldType(T, member)) {
                return false;
            }
        }
    }
    return true;
}

1 Like

@FieldType does not work on decls you have to @TypeOf(@field(T, decl_name))

@field does work on decls, for that reason it will likely be renamed to something like @member, it is the programmatic equivalent to foo.bar.

You can use @hasDecl() (separately from @hasField()).