Why are impossible errors in the returned error set not a compile error?

Why is the fact that FakeError can never be generated by foo below not a compile error?

const std = @import("std");

pub const RealAndFakeError = error{
    RealError,
    FakeError,
};

pub fn foo() RealAndFakeError!void {
    return error.RealError;
}

test {
    try std.testing.expectError(error.RealError, foo());
}

I am forced to handle FakeError in switch cased even though it will never be generated. Shoudn’t the declaration of RealAndFakeError be a compile error?

Maybe related to the halting problem?

Consider this use case:

pub const FooError = error{
    WindowsOnlyError,
    LinuxOnlyError,
};

pub fn foo() FooError!void {
    switch (builtin.os.tag) {
        .windows => {
            // Note: This block is only evaluated when the target is Windows
            return error.WindowsOnlyError;
        },
        .linux => {
            // Note: This block is only evaluated when the target is Linux
            return error.LinuxOnlyError;
        },
        else => {},
    }
}

This allows callsites to handle errors in a cross-platform way even if there are certain errors that are only possible on certain platforms.

For real examples of this, see most of the explicit std.fs error sets:

2 Likes

So I guess zig prioritizes the error set as a “contractual obligation of the caller” more than “documentation of function behavior”?

Perhaps it is LSP responsibilty to warn that an error is never created, because its hard for me to go back into my codebase after a while and prune errors.

And I guess it breaks lazy compilation if the compiler has to find all possible errors.

I don’t think this is 100% knowable due to conditional compilation. Imagine a complex set of comptime logic, some coming from build options from build.zig, etc. It may be that one specific error is only returned when a certain depedency has a certain version, and only when targeting a specific OS, and only when certain CPU features are enabled, etc.

Ignoring that complication, one way to force a compile error in a trivial case would be to just remove the error in question from the error set and see if the compiler gives you a expected type 'error{RealError}', found type 'error{FakeError}' error. But, again, not getting the error may be a false positive since it may still be returned in specific build configurations.

1 Like

Also, see this relevant issue:

1 Like

You can use inferred error set by using !void as function return type.


Also consider this advice from Mitchell Hashimoto

Years into Zig, some advice: use inferred error sets for rapid development but try to convert them to explicit error sets as soon as you can. Likewise, avoid else => in error switches. Its hugely valuable to discover you introduced new error case from the compiler erroring.

3 Likes

Another reason this can be useful is function pointers. The type of a function pointer can’t have an inferred error set, so the function pointer type needs to be a proper superset of the errors returned by any of those functions.

You can see that in this situation, you’d need to handle all of the errors, because the function won’t know the specifics of the function pointer it’s invoking at the time it’s called.

It would be nice for the LSP to do this, yes. But in the meantime, you can replace the error value with error{ThisErrorDoesNotExist}, and the compiler will tell you what the actual error set is. I usually just write that error{E}.

4 Likes

Why should it be? A function declared to be returning a particular type doesn’t imply that it would return every possible value the type is capable of holding.

3 Likes