Type Driven Development: Result

Written by

in

Part 7 – Result

This is a series of posts I’m writing about using types as another tool in software development, Continuous Delivery, & keeping LLM’s honest. They’re also a design & refactoring tool, a communication tool, and reduce how many tests you have to write.

Good type systems create pure & total functions. Where does that leave errors? In programming, we have 4 types of errors:

  1. those we create in our domain
  2. those we expect from infrastructure
  3. situations where we cannot recover
  4. those we weren’t expecting, hence “exceptional” situations

If a type system has Discriminated Unions (sum / variant types), then that’s what we use for 1 & 2, the majority of what programmers deal w/.

type FetchError 
  = BadURL 
  | BadBody 
  | BadStatus 
  | NetworkError 
  | Timeout

Unless you’re dealing w/important hardware situations like Rust’s panics, we just ignore 3 b/c you can’t do anything anyway. 4 will “go away” if you focus on 1 & 2.

Modelling errors w/Unions poses a problem from a Domain Driven Design perspective: how do you differentiate 2 paths; happy & unhappy? The FP world has settled on a Result type:

type Result<T, E>
  = Ok<T>
  | Err<E>Code language: HTML, XML (xml)

It has 2 type parameters; T type for happy path data, E error type for unhappy path.

type FetchResult<Array<Person>, FetchError>Code language: JavaScript (javascript)

However, E in practice never is just 1 error for long. As you start composing more functions together that can fail, you start creating anonymous unions. For example, if we read from an environment variable for our secrets:

type ReadSecrets = () =>
  Result<Secrets, ReadError>Code language: JavaScript (javascript)

… then call fetch, our return type for that function would be both errors:

type fetchPeople = () => 
  Result<Array<Person>, ReadError | FetchError>Code language: JavaScript (javascript)

Those anonymous unions of errors, e.g. ReadError | FetchError, start happening a lot. The type system ensures through exhaustiveness checking you handle all errors. You also discover more about your domain through what can go wrong.

Testing is straight forward b/c the happy path is just Ok<T>:

stubFetch = () => ok([ person ])Code language: JavaScript (javascript)

& unhappy is an Err<E>:

stubBadFetch = () => err(b00m b00m)Code language: JavaScript (javascript)

You still have the flexibility to recover from each error; e.g. if secrets can’t be read from environment variables, you can fetch from a service. Others, like a fetch error BadStatus<429>, you could retry. Why not try/catch? Result is type safe + ensures all are handled.

Result gives you a convention for error handling + separating happy/unhappy paths; u still retain the ability to create DDD Domain errors “PersonUnauthorized”, infrastructure “FetchError”, & Panics like “CantReadSecrets” that u can map to a throw/panic (or Effect.die). You no longer use try/catch unless you’re mapping known unsafe (or potentially unsafe) code to a Result:

try {
  const json = JSON.parse(data)
  return ok(json)
} catch(error) {
  return err(error)
}Code language: JavaScript (javascript)

We’ll talk more in the future about how to model Errors as Values & build applications on top of Result, and the benefits of doing so.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *