Better error handling with just two Union Types
In previous posts I've covered my beef with error handling in some programming languages:
But what can we do without going all the way to full-blown Union Types? What if we don't need to represent types like int | string and are willing to limit our needs to optional values and the possibility of functions failing?
Let's take our inspiration from Zig, which already does almost exactly this, and use the same ?T and !T notation. However, we won't follow Zig 1:1. Also note that Kotlin, Swift, Dart, and C# have the ?T half written as T?.
It's worth spelling out what exactly makes it work, and what it takes to pass the API evolution Litmus Test at the end.
1: Limited Union Types
The basic idea is to introduce just two possible kinds of Union Types:
- For a type
T,?TmeansT | None. - For a type
T,!TmeansT | Error. HereErroris assumed to be a single interface type, like Go'serror, i.e., no typed errors.
Corollaries:
- It follows from #1 above that
??Tand?Tare the same type becauseT | None | NoneisT | None. - The desire to have just two Union Types precludes
?!T/!?T: it would meanT | None | Error, and we strive for simplicity (but wait till you read the next post).
Very importantly, the following pseudocode should be accepted by the compiler (this implicit conversion is what Maybe/Result wrappers lack, and what makes the Litmus Test below pass):
var x : string
var maybe_x : ?string = x // implicit T to ?T conversion
maybe_x = none // implicit None to ?T conversion
var error_x : !string = x // implicit T to !T conversion
error_x = Err("Nope") // implicit Error to !T conversion
And the following should still be rejected:
x = maybe_x // ?T is not convertible to T
x = error_x // !T is not convertible to T
In other words, T is a proper subtype of ?T and !T.
2: Extending boolean context
In a boolean context, e.g., inside an if condition:
?Tis truthy if it isTand falsy if it isNone!Tis truthy if it isTand falsy if it isError
3: Control-Flow-Sensitive typing
TypeScript calls it Type Narrowing, Crystal — Type Restriction, and Scala — Flow Typing. But the idea is the same: at compile time, a variable's type becomes more precise depending on code flow.
In our case, a variable of type ?T or !T can be narrowed to type T, and to None/Error in the else branch.
Examples combining it all together
An iterator returning the next element:
func Next() ?T
for val := Next() {
// even though Next returns ?T, here val has the type T
}
Opening a file:
func Open(name: string) !File
if f := Open("test") {
// f is of type File here, not !File
f.Read(...)
} else {
// f is of type Error here
log(f)
}
Function signature evolution Litmus Test
This is the same test that Maybe/Result wrappers fail in Why TypeScript and Crystal have the best Union Types.
Imagine v1 of our library had a function:
func f(x: int) ?string
Now we are going to publish v2 of this lib. What can we do to this interface without breaking the callers?
Extending Inputs
A function is backward compatible if it can accept a wider range of inputs than before. Suppose we've learned to do something useful in our function without x, and we'd like to make x optional. Can we change the type of x to ?int?
func f(x: ?int) ?string
// old callers still work
f(3)
// but now this is possible too
f(none)
Test passed!
Restricting Outputs
A function is backward compatible if it returns a narrower range of outputs than before. Suppose we've learned a way to always return a meaningful string from f, and it does not have to be optional anymore, as we never return none. Can we change the return type to string?
func f(x: int) string
// old callers still work, even though the else branch is dead now
// perhaps the compiler generates an "always true" warning
if s := f(3) {
s.ToUpper()
} else {
UseDefault()
}
// but new callers don't have to check anymore:
f(3).ToUpper()
Test almost passed: we need to define how values of regular types (like string here) are evaluated in a boolean context. A simple rule could be: everything is truthy, except bool false, None, and Error. However, there are wrinkles when narrowing functions returning bool. Going from returning ?bool to bool changes the semantics for older callers: they would take the "no value" branch when false is returned. But that's an acceptable limitation — we just postulate that such an interface change is not backward compatible; don't do it.
Error Handling Ergonomics
Two more things are needed in practice, and both are orthogonal to the type system.
Error Propagation Syntactic Sugar
Since Error converts implicitly to any !T, passing an error up the stack already works:
func Load(name: string) !Config {
if f := Open(name) {
return Parse(f)
} else {
return f // f is Error here
}
}
But as Yogi Berra said, "It's déjà vu all over again". It's not that different from Go's if err != nil { return err }.
A try prefix (as in Zig) or a ? postfix (as in Rust) that returns the Error to the caller, and otherwise narrows to T, removes the boilerplate.
With a try prefix:
func Load(name: string) !Config {
f := try Open(name) // f is File here; if Open returns an Error, Load returns it as-is right away
return Parse(f)
}
or with a ? postfix and no local variable:
func Load(name: string) !Config {
return Parse(Open(name)?)
}
Similarly, supplying a default value for ?T can be done with a ??/orelse operator or an OrElse method.
func Get(i: int) ?int
a := Get(3) ?? 10
b := Get(3).OrElse(10)
Enforcing Error Checking
Discarding a !T value should be a compile error, as it is in Zig:
Open("test") // error: result of type !File is ignored
Conclusion
The results shown in the Examples and in the Litmus Test are not surprising — we expect this from proper Union Types, both in theory and as demonstrated by multiple languages supporting them.
Limiting Union Types to just two special cases is what makes the implementation of this feature much simpler, and perhaps tractable even for a new, quickly growing and evolving group of "systems programming languages".
Comments
Join the discussionNo comments yet. Be the first to comment.