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.

3 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.

3 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 implemented a basic version of this for structs:

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

This doesn’t do deep equality checks, and it doesn’t compare attributes (which I don’t think is possible), but it should be good enough

2 Likes

@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()).

2 Likes

That’s very confusing… Why is there no @DeclType???

Because you can just trivially type @TypeOf(@field(T, decl_name)).

For field types it’s a bit more involved since you need an instance of the type. Before @FieldType you would need to use something like @TypeOf(@field(@as(T, undefined), field_name)).

2 Likes

I don’t understand how you associate it with object-oriented programming. This is just a general anytype parameter type constraint method, providing clearer calling conventions and compilation errors.

At first, I also considered creating a separate check function for each different convention, but later found out that this was completely duplicating the wheel. I can definitely “dynamically generate” a check function through a separate function and another separate type definition template.

The reason for this design is that I do not want the type template itself to reference anything other than the language itself. And this is also a way I have found to maximize the preservation of readability.

This is an object-oriented proposal because you’re proposing a form of inheritance. The structural typing style that this would seem to enable doesn’t match Zig’s almost completely nominal style, not to mention that inheritance in general tends to cause over-abstraction. I haven’t seen your codebase, but if you haven’t been able to make it work well with tagged unions, existing type reflection tools, or even vtable interfaces, the problem might be with your code style instead of Zig itself.

Anyway, if you start with my isSupersetStruct function and tweak it for your needs, you’ll get the behavior you’re looking for. If you do, and you end up figuring out attribute comparison, let me know because I haven’t quite figured that out

This is what a typical object-oriented style implementation looks like.

Tag unions and vtable interfaces aren’t that different from OP’s static interface in terms of the subtype idea.

The difference comes in where they’re used: tag unions are good for code where the subtype isn’t known at comptime, so without aggressive inlining optimizations, you usually need an extra runtime branch to check. Also, tag union interfaces need all possible subtypes to be determined centrally ahead of time.

Vtable interfaces are also for situations where the subtype isn’t known at comptime. Without aggressive devirtualization, they require a vtable jump. Compared to tag unions, they have the added benefit that you don’t need to know all possible subtypes ahead of time, which is useful for setting up customization points.

OP’s static interface (also common in anytype use cases) works for code where the subtype is known at comptime. The downside is it can bloat the binary, but for interfaces where the expected subtypes are pretty much fixed in the program (like most Io interface use cases), it can actually perform better. Logically, it’s not really more “OO” than tagged unions or vtable interfaces.

But OP’s convention is basically the same as the current anytype logic. It’s essentially still based on unreliable string matching, and compared to the current anytype, it might only have the potential advantage of helping with LSP. Without providing clearer conventions to avoid accidental matching due to string collisions, it doesn’t really add any extra value.

1 Like

both require the type to be known at comptime, but unlike tagged union approach, all implementations don’t need to be known by whoever wrote the code.

1 Like

Oh, all the ‘runtime unknown’ and ‘runtime known’ in my reply should actually be ‘comptime unknown’ and ‘comptime known.’ Sorry for making such a silly mistake.