Is there a compiler bug here? Or just intentional design?

const E = enum(u2) {
    foo,
    bar,
    _,
};

const U = union(E) {
    foo,
    bar,
};


var v: U = .foo;
var e: E = .foo;

pub fn main() void {
    // This one compiles.
    switch (v) {
        .foo => {},
        .bar => {},
    }
    
    // This one also compiles.
    switch (v) {
        .foo => {},
        .bar => {},
        else => {},
    }
    
    // This one compiles, too.
    switch (e) {
        .foo => {},
        .bar => {},
        _ => {},
    }
    
    // This one fails to compile.
    switch (v) {
        .foo => {},
        .bar => {},
        _ => {}, // error
    }
}

The compile error is

test.zig:38:5: error: '_' prong only allowed when switching on non-exhaustive enums
    switch (v) {
    ^~~~~~
test.zig:41:9: note: '_' prong here
        _ => {}, // error
        ^
test.zig:38:5: note: consider using 'else'

which, if anything, means that the behavior is completely intentional, as v is a union and not an enum.
The rationale, I believe, being that enums can be non-exhaustive, in which case the _ branch makes sense, but unions cannot.

I mean, are the inconsistencies intentional? And is it intentional to allow to use a non-exhaustive enum as union tag type?

Ah I see now, do you mean how the else branch is allowed when switching on the union even though all variants have been exhausted? Indeed this could be an oversight but I am not sure.

Checking into the compiler (around src/Sema.zig:11020), switching on unions is done through the backing enum, and since E is non-exhaustive, the else branch is allowed on v, but since _ is only allowed for non-exhaustive enums, the last example gets rejected.

It might be worth looking into the issues on codeberg (which it’s not letting me right now :^))

1 Like

I think this makes sense, the union doesn’t have any “non-exhaustive” fields you could declare.

It is ok if you create an E value that doesn’t have a corresponding union field, that just means the programmer then shouldn’t try to create a union value from that. And you can easily avoid that by switching on the E value and handling _ in a special way, for example creating an error.

Being able to make the tag enum non-exhaustive means it can be simpler to deserialize values where you could have an invalid incoming tag value, without needing to create both a non-exhaustive and exhaustive version of the enum and then only convert to the latter once it is valid.
Instead you can convert and switch directly on the same enum.

For these reasons (ergonomics) I think it is intentionally designed this way.

2 Likes

It is still weird to me that both of the two switch blocks compile okay, even if this will not cause any problems in practice.

    // This one compiles.
    switch (v) {
        .foo => {},
        .bar => {},
    }
    
    // This one also compiles.
    switch (v) {
        .foo => {},
        .bar => {},
        else => {},
    }

If you add a new field the first one gives you an error for the not handled possibility, while the second one has nothing as a default implementation.

Both are useful so it isn’t exactly like those two do the same thing, once you consider maintenance they have different behavior.

So for me it is a choice between handling every case explicitly and having a default case (which might not be used yet).

The problem is theory perfection. For any enum value, it is impossible to make both of the two blocks compile.

Why is it a problem? I am not sure whether I understand your sentiment or goal.

Could you find an enum value to let both of those two switch code blocks compile at the same time?

IDK OP’s reasoning, but I dislike this behaviour as you can easily miss handling other tag values, this is inconsistent as all other types force you to handle all values or use an else prong.

I surely don’t write such code in practice. It is just a theory problem. :smiley:

I wonder whether we should create a matrix of all combinations of enum/union/prongs exhaustive/non-exhaustive and the result / error.

But it seems like a lot of work, however if somebody can figure out a way to make that into a nice visualization it might be good learning material.

Which one do you mean? I am getting confused between different cases, are you talking about the union where you can have an else branch even if all cases are handled already?

It seems to me like that is allowed so that you can have a default implementation (which would be assumed to work for new cases), but it is inconsistent with enum variants that give you errors about either needing an else (non-exhaustive), or not being allowed to have an else because all cases are already handled (exhaustive).

So it does seem odd to have a single case where having an else prong is a choice instead of required/disallowed, but using the else you opt in to not have to handle future tag values. (So when I know I want to handle future union tags I use a switch without else prong)

But there is a similar opposite weird special case with non-exhaustive enums:

const E = enum(u2) {
    zero = 0,
    one = 1,
    _,
};

var e: E = .one;

pub fn main() void {
    // This one compiles.
    switch (e) {
        .zero => {},
        .one => {},
        @enumFromInt(2), @enumFromInt(3) => {},
        else => {}, // all cases already handled, but compile error without it, instead of with it
    }
}

I think another dimension that we need to think about is how these interact with comptime-code/conditional-compilation, that either adds or removes a prong and whether that will result in some code working or not.

With non-exhaustive enums you basically declare an intent to not handle every case individually, so forcing you to have an else or _ even if you handle all cases makes the code more future proof/ergonomic, for when you comment out or delete a prong.

With exhaustive enums you declare that you want to handle cases individually and get an error when you don’t unless you have an else branch, where you do get an error if it is an unused else prong.

Unions are similar like exhaustive enums, except you don’t get an error for unused else prong.
Maybe because it is relatively likely that you will receive payloads that you aren’t interested in.

If the code can have 1 to n payloads (maybe varying based on conditional compilation for alternative implementations) but you only want to handle 1 specific one, this would mean you get a compile error when you have only one possible payload, while the else is allowed for 2+.

But I am not sure whether that is the reasoning.

1 Like

I meant I dislike that you can omit the else with a non-exhaustive tag

1 Like

Did you mean?

Because for an non-exhaustive enum it is a compile error:

error: switch on non-exhaustive enum must include 'else' or '_' prong or both
1 Like

I think an argument could be to have switches on unions always work the same way regardless of what kind of tag is used to construct it, basically the union is always defined with the exhaustive subset of the potentially non-exhaustive tag.

Ah, TIL. The error message listens reasonable. And I think this should be applied to unions too.

const U = union(E) {
    foo,
    bar,
};
var v: U = .foo;
pub fn main() void {
    // This one also compiles.
    switch (v) {
        .foo => {},
        .bar => {},
        else => {},
    }
}

So this piece of code can actually compile! I’m really happy about it!

I once designed a kind of ‘configurable enum’ type for customization points. At the time, I naturally assumed that the code above wouldn’t compile, which bothered me because if the user didn’t add any extra custom enums, the else in the code would (I thought) compile error. So, to solve this imaginary error, I added an extra enum placeholder that would never actually be used. But today I realized that Zig had already considered this real-world scenario and doesn’t compile error for it. Cheers!

It is only okay when users pass to E with non-exhaustive enum type with only two named tags foo and bar. If the passed enum types contain any other custom named tags, then it fails to compile.