Confusing behavior with comptime switches

Hi,
please consider the following code:

const std = @import("std");
const myenum = enum { a, b };

pub fn foo1(mye: myenum) void {
    const val = 1 + switch (mye) {
        .a => 1,
        .b => 2,
    };
    std.debug.print("{d}\n", .{val});
}

fn foo2(mye: myenum) void {
    const val: u8 = 1 + switch (mye) {
        .a => 1,
        .b => 2,
    };
    std.debug.print("{d}\n", .{val});
}

fn foo3(mye: myenum) void {
    const val: u8 = 1 + @as(u8, switch (mye) {
        .a => 1,
        .b => 2,
    });
    std.debug.print("{d}\n", .{val});
}

fn foo4(mye: myenum) void {
    var val: u8 = 1;
    val += switch (mye) {
        .a => 1,
        .b => 2,
    };
    std.debug.print("{d}\n", .{val});
}

pub fn main() !void {
    foo1(.a);
}

When main calls foo1, I get a compile error:

main.zig:5:21: error: value with comptime-only type 'comptime_int' depends on runtime control flow
    const val = 1 + switch (mye) {
                    ^~~~~~
main.zig:5:29: note: runtime control flow here
    const val = 1 + switch (mye) {
                            ^~~

Ok, that makes sense. 1 is a comptime literal, and the switch depends on the function argument. I guess I add a type annotation. So I call foo2, where I specified the type of the result.
But I still get

main.zig:13:25: error: value with comptime-only type 'comptime_int' depends on runtime control flow
    const val: u8 = 1 + switch (mye) {
                        ^~~~~~
main.zig:13:33: note: runtime control flow here
    const val: u8 = 1 + switch (mye) {
                                ^~~

Wow, do I really have to specify that the switch should produce a u8? Yes I do, as I did in foo3. This compiles and runs. But alternatively, I could have made the result a var and do the operations in individual steps like I did in foo4. Here I do not need a type annotation for the switch (why?), but I loose the information that val will not change anymore throughout the function.

I wondered why I have to do extra work here in order to compile.
Is this explicit type casting required in a similar setting, where the types are not that obvious?
Is the type inference system work in progress and this will be possible at some point?

It is ok, if I have to do the extra annotations, I just want to know why they are necessary like in foo3, but not in foo4.

Thank you for your answers,

The reason for this behavior is peer type resolution, which is the mechanism the compiler uses to make all operands of an operation agree on a common type. For a switch statement, only its prongs are peers, so it will determine its type from its prongs and its result type.

In foo1 there is no explicit result type, so switch will resolve to comptime_int and (correctly) complain about a comptime value depending on rumtime control flow.

In foo2 the same thing happens: the u8 annotation is only valid for the assignment and not for all operands of the + expression so there’s still no result type. That’s a consequence of how zig propagates result types, if there was something like Result type spreading it would compile (I’m not implying that that would be a good idea though).

In foo3, there’s an explicit result type of u8 provided. All switch prongs have that as their result type and u8 is not comptime-only, so all is well and it compiles.

foo4 is basically equivalent to foo3: the switch has a result type via val and coerces its prongs to that type.

There’s actually a very common example where this exact issue would cause an annoying compile error if the compiler didn’t special-case it:

fn foo(eu: anyerror!u64) u64 {
    const result = eu catch |err| switch (err) {
        error.MyError => 123,
        else => 456,
    };
    return result;
}

If the switch statement only considered its prongs, it would resolve its type to comptime_int, detect that it relies on runtime control flow and cause a compile error. For that reason the compiler actually treats the non-error case of catch (or an equivalent if (eu) |payload| {} else |err| {} construct) like an additional switch prong, which makes it a peer of the other prongs and makes the entire switch resolve to u64 instead of comptime_int. No compile error :slight_smile:

2 Likes

Thank you for the explanation. That makes perfect sense. Now I know better what to look out for.

1 Like