Skip to main content

Some functions can both fail and return nothing

Updated 8 min readMarkdown

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

  1. Treat "not found" as an Error and return !T. This is what Go does with sql.ErrNoRows and fs.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 means try propagates "not found" up the stack as if something went wrong.
  2. Keep the layers, as Zig does: !?T is an error wrapping an optional, unwrapped one layer at a time. It works, but every caller has to unwrap twice, and ?!T ends 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:

  • Find simply returns !T, and "not found" is none, which is now a valid Error value. The caller checks if r := Find(key) {} else if r is None {} else { log(r) }.
  • The boolean context rule becomes simpler: a value is falsy if it is false or Error, and truthy otherwise.
  • Changing a return type from !T to ?T is now a narrowing, so it passes the Litmus Test from the previous post.
  • try needs no extra rules: try on ?T works inside functions returning ?U or !U, and try on !T only 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:

  • ??T is the same as ?T, because T | None | None is T | None
  • !!T is the same as !T, because T | Error | Error is T | Error
  • !?T is the same as ?!T, because both are T | None | Error, which is T | 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 !?T will show up in function signatures.
  • Two-step narrowing. Telling None from Error takes a second if. 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.

No comments yet. Be the first to comment.