Hint for simplifying return types?

I have a little library in which, as part of updating things for 0.16, I changed a few functions so that they no longer need to have an error union return type. But there’s also nothing in the compiler that would give me an indication that those return types could be simplified. It just happened to occur to me, then I checked all the functions manually.

To give a minimal example of what I’m talking about:

const std = @import("std");

pub fn main() !void {
    const result = try answer();
    std.debug.print("The answer is {}\n", .{result});
}

fn answer() !u8 {
    return 42;
}

The try is compulsory. Is this something that other people have been puzzled by? Granted, it’s not a serious problem, and there may be good reasons for allowing the signature of a function to stipulate an error union even if there is currently no error return path.

Poking around in Sema.zig for places where this phenomenon might be knowable to the compiler, I found that I could add a check at the end of resolveInferredErrorSetPtr to see if the resolved error set is empty (and that ies.func != .none). But that was just a test to get the compiler to emit a debug print on encountering relevant functions.

Anyway, I’m curious what discussions about this might have taken place in the past, if anyone has a link to a Ziggit thread, GH/Codeberg issue or PR, etc. Thanks!

P.S. In the minimal example above, !void turns out to be unnecessary on main; it compiles and runs without a problem if the return type is changed to void, even though there’s still a try inside. I was a bit surprised by this. The try when calling answer, however, cannot be skipped.

1 Like

The set of inference errors is a kind of generic. From the perspective of lazy compilation, asserting that it will definitely not report an error is somewhat difficult, because there may be a scenario where one architecture has no errors while another architecture does have errors, and Zig cannot observe branches that it cannot reach.

6 Likes

A library should not be using inferred error sets, I personally would argue the same even in applications for this and other reasons.

as @npc1054657282 pointed out, it is possible errors might occur under different targets/options/etc.

1 Like

Explicit error sets are actually worse. Although inferred error sets have such phenomena, they can ensure that the actually generated interfaces are minimal. If an error catch for dead code appears, it is more likely to trigger a corresponding error, whereas in explicit error sets, the redundantly declared errors are more easily ignored.

But it must be admitted that when it comes to interfaces, in order to ensure compatibility, an explicit error set is necessary. This is the only place where I would suggest using an explicit error set.

2 Likes

I agree with restricting error sets to only what is actually possible, I disagree that inferred error sets are a good way to do it.

I would much rather explicit comptime logic to create the error set!

1 Like

Yes, that’s roughly the “good reason” I was alluding to.

I wouldn’t want an error on something like this, but maybe an opt-in diagnostic that is acknowledged to be imperfect.

1 Like

Since answer has an inferred error set and doesn’t return an error in any runtime path, its actual error set is error{}. So when calling it, the compiler doesn’t analyze the failing path, because it’s a known dead path at compile-time, regardless of the optimization mode.

You can try this sort of thing too:

answer() catch @compileError("This doesn't prevent compilation :)") ;

You can also use an explicit error set, either empty (it’ll still work) or not (it won’t).

Edit: I think it’s actually a really cool and useful feature, not only for conditional compilation. I rely a lot on it when working on userland ranged integers for example.

2 Likes

The workflow I currently prefer to use: create an error set for a specific domain. Always use SomeErrorSet.SomeError and never error.SomeError to ensure that errors outside this domain are not returned: whether this convention is violated is easy to audit.
Use an explicit error set in the function signature when required by the interface; otherwise, use inferred error sets to ensure that the actual minimal error set is obtained.
My workflow conflicts with #24028, but I believe my workflow is better because scenarios that break conventions are easier to audit. Personal development may ensure that one always defines the strictly minimal explicit error set, but for team development, scenarios that do not implement the optimal explicit error set are difficult to audit.

3 Likes

I’m tempted to mark the initial reply by @npc1054657282 as the “solution,” since it provides the standard answer for why it’s safer to allow a function to be declared as returning an error union even if there’s no detectable error return path at a given time, on a given architecture, etc.

But since I was initially curious whether the same question has been discussed elsewhere, I’m not sure. I would still enjoy reading more about this, as it’s a Zig behavior that caught me off-guard! The GitHub issue linked in the latest reply is interesting, albeit on a somewhat different issue.

I took this suggestion to heart and defined explicit error sets (which ended up being very simple) for the public API of my library. But there are still inferred error sets in some internal functions. Would it really be considered a best practice to avoid that?

Yeah, I even go as far as saying that using inferred error sets is in most cases sloppy code (and definitely in public APIs).

1 Like

I do explicit sets for internal functions.
Because I will be reading them, and I will be tracing possible errors as I work on the code.

2 Likes