Some functions can both fail and return nothing
In the previous post we've limited Union Types to just two kinds: ?T for T | None and !T for T | Error. One of the corollaries was that ?!T/!?T is not supported, because it would mean T | None | Error, and we strive for simplicity.
But a lookup that can both fail and find nothing is common enough:
// the DB may be down (Error), or the key may be absent (None)
func Find(key: string) !?Record
Let's see what it takes to support !?T without giving up on simplicity.
The obvious options
- Treat "not found" as an
Errorand return!T. This is what Go does withsql.ErrNoRowsandfs.ErrNotExist. "No value" becomes a special error, and callers have to compare error values to tell the two apart. But "no value" is an expected outcome, not an error, and the type signature should indicate this. It also meanstrypropagates "not found" up the stack as if something went wrong. - Keep the layers, as Zig does:
!?Tis an error wrapping an optional, unwrapped one layer at a time. It works, but every caller has to unwrap twice, and?!Tends up being a different type from!?T, which violates the "Union Types are sets" approach.
Tempting option 3: Derive None from Error
If None is a subtype of Error, then ?T is implicitly convertible to !T, and T | None | Error is the same as T | Error. In other words, !?T is just !T. We get a simple chain T <: ?T <: !T, and the nesting problem goes away without any new types.
There are many benefits to this approach:
Findsimply returns!T, and "not found" isnone, which is now a validErrorvalue. The caller checksif r := Find(key) {} else if r is None {} else { log(r) }.- The boolean context rule becomes simpler: a value is falsy if it is
falseorError, and truthy otherwise. - Changing a return type from
!Tto?Tis now a narrowing, so it passes the Litmus Test from the previous post. tryneeds no extra rules:tryon?Tworks inside functions returning?Uor!U, andtryon!Tonly inside functions returning!U.
But there is one problem, and it is a big one: none becomes an error without any context. var f: !File = none compiles, and log(f) has nothing useful to print. Worse, try m.Get(key) inside a function returning !Config propagates none to the caller instead of saying which key was missing, and nothing in the type system reminds you to do better.
This is not a theoretical concern. Rust tried exactly this: std::option::NoneError on nightly allowed ? to convert an Option into a Result, and it was removed for this very reason. Zig requires an explicit conversion instead: orelse error.NotFound. And Go's io.EOF is the same idea in practice: an "error" that is really an expected absence, with if err == io.EOF checks everywhere.
More fundamentally, half of the argument in the Go post was that the absence of a result is not an error. Making None an Error gives that up for !T. We want None and Error to remain distinct types, so the compiler knows which one we meant.
Better option: Sealed Common Base
Instead, let's derive both None and Error from a common base type Failure:
sealed type Failure = None | Error
Failure is sealed, i.e., None and Error are its only subtypes. Now the Union Type algebra does the rest:
??Tis the same as?T, becauseT | None | NoneisT | None!!Tis the same as!T, becauseT | Error | ErrorisT | Error!?Tis the same as?!T, because both areT | None | Error, which isT | Failure
Nesting flattens, the order does not matter, and !?T is the only new type. We still have just two primitive kinds of Union Types, and nesting them produces the third one. Let's use !?T as the canonical spelling.
Conversions
Every type is implicitly convertible to the types below it, but not the other way around:
Error T None
| / \ |
!T ?T
\ /
!?T
In code:
var rec : Record
var r : !?Record = rec // T to !?T
r = none // None to !?T
r = Err("DB is down") // Error to !?T
var m : ?Record = rec
r = m // ?T to !?T
var e : !Record = rec
r = e // !T to !?T
e = m // rejected: ?T is not convertible to !T
m = e // rejected: !T is not convertible to ?T
The last two lines are the whole point of not going with option 3: None and Error remain distinct, and the compiler will enforce it.
Boolean context and narrowing
The rule from the previous post actually becomes simpler: a value is falsy if it is false or a Failure, and truthy otherwise.
Narrowing works the same way: in the else branch !?T is narrowed to Failure, and one more type check tells None and Error apart:
if r := Find("bob") {
// r is Record here
r.Greet()
} else if r is None {
// r is None here
CreateUser("bob")
} else {
// r is Error here
log(r)
}
For comparison, Zig needs both catch and orelse for the same thing.
try and !?T
The try prefix from the previous post needs no new rules. It returns the failure to the caller, so the failure must be convertible to the return type of the enclosing function:
try applied to |
inside ?U func |
inside !U func |
inside !?U func |
|---|---|---|---|
?T |
✅ | ❌ | ✅ |
!T |
❌ | ✅ | ✅ |
!?T |
❌ | ❌ | ✅ |
Converting between kinds has to be explicit:
func LoadUser(key: string) !User {
r := try Find(key) // rejected: Find may return None, LoadUser cannot
r := try Find(key) ?? Err("user not found: " + key) // OK: None replaced by an Error
return User(r)
}
With option 3 the first line would have compiled, and the caller would have received an error saying nothing.
Litmus Test
Let's run the same test as in the previous post: which signature changes do not break the callers?
Extending Inputs
Changing a parameter type from ?int to !?int is a widening, so old callers passing an int or none still work. Whether a function should ever accept an Error as input is a separate question, but the type system does not need to forbid it.
Test passed!
Restricting Outputs
!?T can be narrowed in two directions: to !T or to ?T, and then further to T. Both paths are backward compatible, same as before.
Case 1: !?T -> !T, no more none. Suppose we've learned how to never return none from Find:
// v1
func Find(key: string) !?Record
// v2: never returns none
func Find(key: string) !Record
// old callers still work, even though the none branch is dead now
if r := Find("bob") {
r.Greet()
} else if r is None {
CreateUser("bob")
} else {
log(r)
}
Test passed! Note that in the dead else if branch r is of type Error, so r is None is always false. Perhaps the compiler generates an "always false" warning, similar to the "always true" warning in the previous post.
Case 2: !?T -> ?T, no more errors. Now suppose instead that Find never has to return errors anymore:
// v1
func Find(key: string) !?Record
// v2: never returns an Error
func Find(key: string) ?Record
// old callers still work, even though the error branch is dead now
if r := Find("bob") {
r.Greet()
} else if r is None {
CreateUser("bob")
} else {
log(r) // dead: r is Failure but not None, and Error is impossible
}
Test passed! Here the compiler is less likely to notice the dead else branch, since else has no condition to evaluate. It also does not change anything for try: try Find(key) inside a function returning !U was rejected before (Find could return none) and is still rejected after.
Changing !T to ?T or back is neither a narrowing nor a widening, so it is a breaking change. And it should be: it changes the meaning of the function, and the compiler should say so.
What does it cost?
- A third type. It is derived rather than declared, but it exists, and
!?Twill show up in function signatures. - Two-step narrowing. Telling
NonefromErrortakes a secondif. Zig has the same cost.
Conclusion
In Why Go-style error handling is not great we've called a function partial if it is unable to produce a return value for all possible inputs. T | Failure is exactly that: either a value, or the reason there is none. ?T and !T cover the two possible reasons, and !?T covers both.
Two primitive Union Types and one sealed base type are all it takes to support nesting.
Comments
Join the discussionNo comments yet. Be the first to comment.