You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Proposal: Prevent @typeInfo and @TypeOf from triggering compile errors #6620
An important use case in Zig is the ability to load a module and use reflection to inspect the decarations it exposes. Unfortunately, it's also common to see modules that use pub const foo = @compileError(..); for deprecation, specialization, or other purposes like translation errors from translate-c. These two approaches are at odds with each other. This proposal suggests a way to keep the current use of @compileError and still inspect reflection data without triggering errors.
For a basic fix, we need the ability to identify when a declaration fails to compile without actually failing the compile. To do this, we can add an element to TypeInfo with tag .CompileError.
.CompileError should be an empty tag, because allowing the metaprogram to inspect the error string could cause difficult to find dependencies on compiler versions.
We also need a special type @Type(.CompileError) which will be used in the decls of the type info of the containing struct.
Creating a variable of type @Type(.CompileError) is a compile error.
This would identify not just calls to @compileError but also declarations that are invalid for other reasons.
Guarantee that calls to @TypeOf cannot cause a compile error. Instead, @TypeOf(<code that causes compile error>) returns .CompileError. This could be useful for a lot of use cases that are currently difficult, like
checking if an instance of one type can coerce to another type
checking if a function signature is compatible with a set of arguments
checking if a combination of argument types is valid for a given generic function
All of these use cases are technically solvable with just (1) through (5), but require the use of an intermediate struct whose type info can be inspected. This last point makes that use case a bit cleaner, and unifies the behaviors of "figuring out the type of a declaration for reflection" and "using @TypeOf".
I ran into a sort of interesting use-case for this. Like many projects, I have a type for a runtime-sized list with a comptime-known maximum length. However, mine is extern (ideally packed, but that has broken codegen at the time of writing) so that I can embed these lists in a larger structure and then use read/writeStruct to serialize the entire thing. This simplifies memory management and has some performance benefits, notably reducing the number of reads/writes that need to be issued.
Today I saw #9134, which looks great and would ideally replace my own container type, but it returns a standard struct (that is, non-extern, non-packed). So it couldn't be used with read/writeStruct. I set out to see if I could change the layout, and it turns out you can do this today:
However, I quickly noticed that a couple types I tested this on failed to compile because it triggers the compile errors on deprecated decls. So while this would work initially, if deprecated members were ever added to ArrayListFixed, my code would suddenly fail to compile. Without a way to futureproof against this, it seems that I would be better off maintaining my own implementation.
I am interested in seeing at least the @TypeOf(expr) == noreturn and @TypeOf(incompatible, types) == noreturn component of this proposal realized.
In particular, being able to check whether two types can have peer type resolution performed on them would be very useful for being able to construct more complex type check error messages.
I have a particular use case, in wanting to modify std.hash_map.verifyContext: currently, it has hard checks, such that for adapted contexts, it will issue a compile error even if the inputted key can coerce to the expected PseudoKey (e.g. *const [n:0]u8, []u8, [:0]const u8, aren't accepted in map.getOrPutAdapted(string_literal, Adapted), where Adapted accepts []const u8 as PseudoKeys).
One way to solve that problem would be to just remove the checks, but then the resulting error message in the case where the types really aren't compatible, become much less useful than they currently are.
The second way would be to implement a function in userland that checks whether the types are peer-type-resolvable, but that adds extra maintenance burden.
The third, and imo simplest way to address this would be to allow @TypeOf given multiple values to return null, or noreturn, or whatever else.
I have a branch with the basic necessary modifications to Sema for the third option, and I would be happy to go through and update everything else if given the go-ahead.
Using .NoReturn for this case doesn't make sense because it represents an expression that causes a runtime exit of the target artifact. whereas .CompileError would mean an expression that halts compilation and cannot be allowed to exist in runtime or comptime.
one other solution to @TypeOf(incompatible, types) is also to modify it to return ?type instead of modifying std.builtin.Type.
Making @TypeOf(incompatible, types) == null is actually what I've done in my branch. The bigger question becomes what do do with @TypeOf(val). It would be consistent to have it also return null, but that would then mean all code that uses it and wants to just assume current behavior has to be changed to things like fn foo(a: anytype, b: @TypeOf(a).?) void. It's not the worst, but it does seem odd to require for such a common use case.
well for that case in the meantime they would have to add .? but long term im hopeful for #9260 which would change that to fn foo(a: infer A, b: infer B) void or fn foo(a: infer T, b: T) void
Another idea that just came to mind would be to make it return a specific type - either a primitive (compilationerror), or a declaration in std.builtin (e.g. std.builtin.CompileError) - whose only purpose is to be returned from a @TypeOf call that's given invalid peer types.
This would allow essentially all code to remain as it is, but adds the ability to do things like @TypeOf(<expr>, ...) == std.builtin.CompileError.
A minimal fix here might be to add a .type field to Declarations.
There might be a good reason why this isn't already the case, but if not, this would allow introspection of container types, without taking a field access on them, and triggering a compile error. Whether a decl const oops = @compileError("do not touch!"); should have a special CompileError type, or just NoReturn, seems less important than providing a way to get at that type without a field access.
It sounds like the @TypeOf builtin would be overloaded too much
@TypeOf(single) returns what is says: the type of single, which could be a "compile error" type
@TypeOf(multiple, types) returns the peer type of its operands, or returning a "compile error" type if that's impossible, OR returning a "compile error" type if one of the operands would lead to a compile error
It's fundamentally 2 different behaviours wrapped into 1 builtin. Also, notice how one can't tell the difference between "these 2 types don't have a peer" and "the peer type is a compile error"
What if a new builtin, @PeerTypeOf(...) ?type (name could be changed), was introduced, that returns the peer type of its operands, or null if that's impossible, or returning a "compile error" type if one of its operands would lead to a compile error.
Apologies that this is basically reposting #3641, but since this new @TypeOf behaviour of checking for compile error would sort of allow a userland version of @PeerTypeOf anyways I thought it would be relevant.
There was a recent discussion, a short one, on Ziggit, which touches on this issue. Mostly I wanted to point out that the issue this is tracking relates to "bottom types", and the ambiguity and differences of approach to it can be clarified by bringing that concept into focus.
In sound type systems, Bottom is uninhabited: if type inference derives bottom, then compiling fails. Zig is not interested in building a sound type system, I'm sure, that would make for a very different language. But it's consequential for this discussion, because noreturn is an inhabited bottom type, meaning the actual type of runtime code which compiles just fine. Like it says on the label, you can't return from it, and the documentation explains two consequence of this: it's the realized type of certain expressions, and it also coerces to any other type, so that in the not-uncommon case of two conditional branches, one of which panics, exits, or otherwise won't return a value, the resulting type of that conditional is the one which allows the program to continue. I believe that makes it problematic as the return type of @TypeOf(A,B) for incompatible types, since it implies that for two incompatible types A and B, @TypeOf(@TypeOf(A,B),C)) would be C. But as soon as code is emitted based on that conclusion, that code won't compile.
An answer of null would improve on the current compile error, but is unsatisfactory in other ways. It would pollute every use of two-argument @TypeOf, and it would mean a separate path for incompatible types: that is correct for runtime code, but I don't see it as ideal for type reflection.
Nektro raises a good point about the difference between CompileError and NoReturn, namely that CompileError is the comptime 'no return', and NoReturn is the runtime noreturn. I don't see either as suitable for incompatible peer coercion. Since we're facing the difficulty posed by an inhabited bottom type, a good solution is what other type systems where bottom is inhabited do, which is to have an other bottom type, the empty type, which is like noreturn except peer coercion of Empty and T produces Empty, not T. Empty is when you have an inhabited Bottom type and also need an uninhabited one: that's what we're seeing here.
It doesn't have to have a type-theoretic name like Empty or Bottom, I'd say that Type.NoType is most like the rest of Zig's type system. I do think we need three types here, because NoType isn't even an error, it's informative, telling comptime reflection that it can't emit code which expects the two types to coerce to a common type.
Activity
Rocknest commentedon Oct 9, 2020
By the way we already have
noreturn.@TypeOf(@compileError("")) == .NoReturnjcmoyer commentedon Jun 17, 2021
I ran into a sort of interesting use-case for this. Like many projects, I have a type for a runtime-sized list with a comptime-known maximum length. However, mine is
extern(ideallypacked, but that has broken codegen at the time of writing) so that I can embed these lists in a larger structure and then useread/writeStructto serialize the entire thing. This simplifies memory management and has some performance benefits, notably reducing the number of reads/writes that need to be issued.Today I saw #9134, which looks great and would ideally replace my own container type, but it returns a standard struct (that is, non-
extern, non-packed). So it couldn't be used withread/writeStruct. I set out to see if I could change the layout, and it turns out you can do this today:However, I quickly noticed that a couple types I tested this on failed to compile because it triggers the compile errors on deprecated decls. So while this would work initially, if deprecated members were ever added to
ArrayListFixed, my code would suddenly fail to compile. Without a way to futureproof against this, it seems that I would be better off maintaining my own implementation.InKryption commentedon Mar 16, 2023
I am interested in seeing at least the
@TypeOf(expr) == noreturnand@TypeOf(incompatible, types) == noreturncomponent of this proposal realized.In particular, being able to check whether two types can have peer type resolution performed on them would be very useful for being able to construct more complex type check error messages.
I have a particular use case, in wanting to modify
std.hash_map.verifyContext: currently, it has hard checks, such that for adapted contexts, it will issue a compile error even if the inputted key can coerce to the expectedPseudoKey(e.g.*const [n:0]u8,[]u8,[:0]const u8, aren't accepted inmap.getOrPutAdapted(string_literal, Adapted), whereAdaptedaccepts[]const u8asPseudoKeys).One way to solve that problem would be to just remove the checks, but then the resulting error message in the case where the types really aren't compatible, become much less useful than they currently are.
The second way would be to implement a function in userland that checks whether the types are peer-type-resolvable, but that adds extra maintenance burden.
The third, and imo simplest way to address this would be to allow
@TypeOfgiven multiple values to returnnull, ornoreturn, or whatever else.I have a branch with the basic necessary modifications to Sema for the third option, and I would be happy to go through and update everything else if given the go-ahead.
nektro commentedon Mar 16, 2023
Using
.NoReturnfor this case doesn't make sense because it represents an expression that causes a runtime exit of the target artifact. whereas.CompileErrorwould mean an expression that halts compilation and cannot be allowed to exist in runtime or comptime.one other solution to
@TypeOf(incompatible, types)is also to modify it to return?typeinstead of modifyingstd.builtin.Type.InKryption commentedon Mar 16, 2023
Making
@TypeOf(incompatible, types) == nullis actually what I've done in my branch. The bigger question becomes what do do with@TypeOf(val). It would be consistent to have it also return null, but that would then mean all code that uses it and wants to just assume current behavior has to be changed to things likefn foo(a: anytype, b: @TypeOf(a).?) void. It's not the worst, but it does seem odd to require for such a common use case.nektro commentedon Mar 16, 2023
well for that case in the meantime they would have to add
.?but long term im hopeful for #9260 which would change that tofn foo(a: infer A, b: infer B) voidorfn foo(a: infer T, b: T) voidInKryption commentedon Mar 16, 2023
Another idea that just came to mind would be to make it return a specific type - either a primitive (
compilationerror), or a declaration instd.builtin(e.g.std.builtin.CompileError) - whose only purpose is to be returned from a@TypeOfcall that's given invalid peer types.This would allow essentially all code to remain as it is, but adds the ability to do things like
@TypeOf(<expr>, ...) == std.builtin.CompileError.mnemnion commentedon Jun 18, 2024
A minimal fix here might be to add a
.typefield to Declarations.There might be a good reason why this isn't already the case, but if not, this would allow introspection of container types, without taking a field access on them, and triggering a compile error. Whether a decl
const oops = @compileError("do not touch!");should have a specialCompileErrortype, or justNoReturn, seems less important than providing a way to get at that type without a field access.TheHonestHare commentedon Aug 14, 2024
It sounds like the
@TypeOfbuiltin would be overloaded too much@TypeOf(single)returns what is says: the type ofsingle, which could be a "compile error" type@TypeOf(multiple, types)returns the peer type of its operands, or returning a "compile error" type if that's impossible, OR returning a "compile error" type if one of the operands would lead to a compile errorIt's fundamentally 2 different behaviours wrapped into 1 builtin. Also, notice how one can't tell the difference between "these 2 types don't have a peer" and "the peer type is a compile error"
What if a new builtin,
@PeerTypeOf(...) ?type(name could be changed), was introduced, that returns the peer type of its operands, ornullif that's impossible, or returning a "compile error" type if one of its operands would lead to a compile error.Apologies that this is basically reposting #3641, but since this new
@TypeOfbehaviour of checking for compile error would sort of allow a userland version of@PeerTypeOfanyways I thought it would be relevant.mnemnion commentedon Aug 14, 2024
There was a recent discussion, a short one, on Ziggit, which touches on this issue. Mostly I wanted to point out that the issue this is tracking relates to "bottom types", and the ambiguity and differences of approach to it can be clarified by bringing that concept into focus.
In sound type systems, Bottom is uninhabited: if type inference derives bottom, then compiling fails. Zig is not interested in building a sound type system, I'm sure, that would make for a very different language. But it's consequential for this discussion, because
noreturnis an inhabited bottom type, meaning the actual type of runtime code which compiles just fine. Like it says on the label, you can't return from it, and the documentation explains two consequence of this: it's the realized type of certain expressions, and it also coerces to any other type, so that in the not-uncommon case of two conditional branches, one of which panics, exits, or otherwise won't return a value, the resulting type of that conditional is the one which allows the program to continue. I believe that makes it problematic as the return type of@TypeOf(A,B)for incompatible types, since it implies that for two incompatible typesAandB,@TypeOf(@TypeOf(A,B),C))would beC. But as soon as code is emitted based on that conclusion, that code won't compile.An answer of
nullwould improve on the current compile error, but is unsatisfactory in other ways. It would pollute every use of two-argument@TypeOf, and it would mean a separate path for incompatible types: that is correct for runtime code, but I don't see it as ideal for type reflection.Nektro raises a good point about the difference between
CompileErrorandNoReturn, namely thatCompileErroris the comptime 'no return', andNoReturnis the runtimenoreturn. I don't see either as suitable for incompatible peer coercion. Since we're facing the difficulty posed by an inhabited bottom type, a good solution is what other type systems where bottom is inhabited do, which is to have an other bottom type, the empty type, which is likenoreturnexcept peer coercion ofEmptyandTproducesEmpty, notT. Empty is when you have an inhabited Bottom type and also need an uninhabited one: that's what we're seeing here.It doesn't have to have a type-theoretic name like
EmptyorBottom, I'd say thatType.NoTypeis most like the rest of Zig's type system. I do think we need three types here, becauseNoTypeisn't even an error, it's informative, telling comptime reflection that it can't emit code which expects the two types to coerce to a common type.mlugg commentedon Apr 22, 2026
This use case is better handled by simpler userspace conventions, such as using the value
{}orvoidto represent "unsupported".