SubType: a mechanism to catch impossible cases at comptime

One partial alternative to this you already can use in some cases is to encode semantic meaning in the bits of the enum/tag value.

For example you could make it so that b and c share a bit that isn’t shared by any other enum value, then you could even convert that bit/enum to a flags enum and switch on whether the bc flag is set or not set.

So one way to deal with those situations is to switch on a different tag that is somehow derived/computed from the original tag. Similar to this post: Curious comptime pattern - #4 by Sze


Personally I think it would be most satisfying if the compiler could statically recognize impossible cases and then maybe even give you errors for trying to handle/write impossible cases. Although that would be complicated by comptime parameters like already pointed out by @chadwain, so maybe there would be a category of always impossible cases and the rest would remain as needing to be handled like now with unreachable.

I am not sure I like the explicit @SubType that much, I think I would prefer if the compiler could keep/analyze enough context information to know that certain cases aren’t possible / if there was more/deeper inference for switch cases.

Basically I wouldn’t want the subtype to be an actual type, I would want it to keep the original type but just with the added information about which cases are still possible. (Mostly because I don’t want my code pathes to have to deal with an combinatoric explosion in the number of concrete types they have to handle, subtypes could also make comptime/generic programming more complicated)

Maybe we could instead have something like this:

const Abc = enum { a, b, c };
fn example(abc: Abc) void {
    switch (abc) {
        .a => {},
        .b, .c => |bc| {
            switch (bc) {
                // .a is impossible, because it isn't reachable (inferred)
                .b => {},
                .c => {},
            }
        },
    }
}

fn example2(abc: Abc) void {
    switch (abc) {
        .a => {},
        .b, .c => |bc| handleBC(bc), // works
    }
    
    handleBC(abc);
    // compile error:
    // handleBC(abc: @reachable(Abc, .{.b, .c}))
    //   parameter abc called with .a reachable
}

// you only need to declare reachability
// to be able to put it inside a separate function
fn handleBC(bc: @reachable(Abc, .{.b, .c})) void {
    // bc still is of type Abc, but calling handleBC
    // with values that could be .a is a compile error
    switch (bc) {
        // .a is impossible, because it isn't part of what is reachable
        .b => {},
        .c => {},
    }
}

The amount of reachability information that is kept by the compiler could be quite different based on tradeoffs between explicitness and allowing things to be inferred. For example you could either just have switch statements that produce reduced reachability, or you also could have if-conditions propagate reachability information to their then and else body.

Or you also could have escape analysis for blocks cause reduced reachability for specific variables in the rest of the block, that way early returns like if(abc == .a) return; would only make the remaining cases reachable. But I think it would always be about what can be known statically, so things that are only known at runtime wouldn’t cause reduced reachability and would still need cases within switch statements.

1 Like