Conditional switch cases

When handling errors in polymorphic code, sometimes we run into situations where particular errors are absent from an inferred error set. Consider the following:

const std = @import("std");

fn foo() !void {
    return error.FooHappened;
}

fn bar() !void {
    return error.BarHappened;
}

fn call(cb: anytype) void {
    cb() catch |err| {
        switch (err) {
            error.FooHappened => std.debug.print("foo happened\n", .{}),
            error.BarHappened => std.debug.print("bar happened\n", .{}),
        }
    };
}

pub fn main() !void {
    call(foo);
    call(bar);
}

This wouldn’t compile since the set returned by foo() doesn’t contain error.BarHappened and bar()'s set is missing error.FooHappened. While we can work around this by making foo() and bar() return the combined set, that’s undesirable since the resulting binary would end up with unreachable prongs.

Similar situations can arise when working with enums. The ability to disable particular switch cases based on comptime conditions is generally useful. In C, this is pretty easy:

    case TURTLE: return milkTurtle();
#ifdef UNICORN
    case UNICORN: return milkUnicorn();
#end
    case ZEBRA: return milkZebra();

One way we can implement the feature is to use undefined to denote the absence of a case:

fn call(cb: anytype) void {
    cb() catch |err| {
        const E = @TypeOf(err);
        switch (err) {
            ifPartOf(error.FooHappened, E) => std.debug.print("foo happened\n", .{}),
            ifPartOf(error.BarHappened, E) => std.debug.print("bar happened\n", .{}),
        }
    };
}

fn ifPartOf(comptime err: anytype, comptime Set: anytype) Set {
    const errors = @typeInfo(Set).error_set orelse return err;
    return inline for (errors) |e| {
        if (std.mem.eql(u8, @errorName(err), e.name)) break err;
    } else undefined;
}

I think this is fairly intuitive. undefined is not a legal value, ergo the case should be tossed out. The left side being left accidentally undefined is unlikely.

1 Like

Edit: Probably not as relevant as I thought initially, because something like => if (case_is_possible) comptime unreachable is not possible.

1 Like

You can use a proper type instead of anytype without changing foo and bar

const std = @import("std");

pub fn main() !void {
	call(foo);
	call(bar);
}

fn foo() !void {
	return error.FooHappened;
}

fn bar() !void {
	return error.BarHappened;
}

const E = error{ FooHappened, BarHappened };

fn call(cb: fn () E!void) void { // function types cannot have an inferred error set.
	cb() catch |err| switch (err) {
		error.FooHappened => std.debug.print("foo happened\n", .{}),
		error.BarHappened => std.debug.print("bar happened\n", .{}),
	};
}
3 Likes

Heh, while I have this URL in my clipboard: #21507 would enable splatting switch prongs from comptime-known arrays.

At which point it’s dead easy, just do an if statement to assign one of two arrays to a comptime const, then use it.

2 Likes

This proposal seems questionable. unreachable is supposed to be a promise the programmer makes to the compiler that a particular branch would never be reached. Now suddenly it’s become a directive to ignore the missing value.

Omitting cases based on whether it’s an impossible value would also let us control multiple branches from a single place. If we have something like this:

const has_symlink = true;

const DirEntryType = enum(u32) {
    file,
    dir,
    symlink = if (has_symlink) 2 else undefined,
};

Then all occurrences of .symlink => would get excised automatically.

this is absolutely the answer - you can always coerce error sets to supersets, so just do so explicitly, one way or another.