Tagged union wrapped in error union vs. errors in tagged union?

Ordinary errors can’t have additional data, so you have to return a tagged union instead of an error union. But what if of the possible errors of a function, only some have additional data and some are ordinary errors?

For example, think of a CLI parser that can fail because of invalid input (which will return diagnostics about which input argument was bad) or because memory allocation failed.

I could just return a tagged union, and include the ordinary error set in it, but returning errors that way (instead of in an error union) seem unidiomatic (also try and errdefer won’t work as usual):

const ParseError = std.mem.Allocator.Error;

const ParseResult = union(enum) {
    success: Item,
    invalid: Diagnostic,
    err: ParseError,
};

fn parse(...) ParseResult {...}

On the other hand, I can nest the tagged union inside an error union, but it seems weird that different errors are returned in different ways and the caller needs two different ways to handle them (catch and switch):

const ParseResult = union(enum) {
    success: Item,
    invalid: Diagnostic,
};

fn parse(...) ParseError!ParseResult {...}

What is the idiomatic way to deal with this?

I would say that returning a tagged union instead of an error union is un-idiomatic.

To take a standard library example, when they need a function that can both error out and return additional data, they make it return an error union and also take a pointer to a diagnostics struct that it can modify, C-style.
(Reference: std.zon.parse.fromSlice)
Even this function is not one of my favourites in the standard library. For instance, they don’t bother telling you that you actually need to deinit the diagnostics with an allocator after you’re done using it.

A possibly idiomatic pattern is to combine the error set with returning an optional result, so there are three possible return states: an error, an invalid state and a valid state.
That way, the user can switchlessly get the result and handle the invalid state with the following pattern:

fn parse(...) ParseError!?Item {...]

if(try parse()) |parsed| {
  // Valid result, let's use the parsed item
} else {
  // Invalid result but parser didn't error out, print diagnostics
}

The idea would be that encountering ParseError would mean that the parser code was written badly, not that the user input was wrong.
But a null result would indicate that the user input was wrong, and the parser was able to populate the diagnostics.

As for the diagnostics themselves, I’m not sure there’s a necessarily idiomatic pattern for that.
A way I might prefer, though, would be to accept a caller-provided slice of diagnostics structs, which you fill up as you encounter parse failures.
That’d be allocation-free within the function, wouldn’t need to be deinitted by the caller unless they allocated it themselves, and would also allow the caller to pre-specify the maximum number of parse failures they’re willing to get diagnostics for before the function is forced to break out and return null.
Which would also help prevent extremely bad user input from creating an infinite loop in the parser.

5 Likes

The other fairly common pattern is to store an optional context value in the object, only set when you error. This is also used in the stdlib e.g. to allow Reader implementations to explain what error.ReadFailed means.

2 Likes

Yes, this style (output parameter or storing the data in some object) seems to be common. Although I think it is the worst option because it detaches the data from the control flow it belongs to; it requires the API to document when exactly the data will be set and the caller to duplicate that logic.

1 Like

The best approach I can think of right now is to use dependency injection for the error message reporter.
Whether the error message is logged immediately or stored in a specified memory area is determined through the dependency injection interface.

1 Like