Skip to content

Instantly share code, notes, and snippets.

@geelen
Last active January 4, 2024 22:51
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save geelen/be04f89ab0400e7606484ddc9e154bfb to your computer and use it in GitHub Desktop.
Save geelen/be04f89ab0400e7606484ddc9e154bfb to your computer and use it in GitHub Desktop.
"Maybe Error" pattern in Typescript

"Maybe Error" pattern in Typescript

TL;DR? 👉 Implementation.

Inspiration

So one of the things I've liked about working with Go is that you use multiple return values a lot, with early exits that work as a kind of guard:

func GetUserFromAuth(name string, auth Auth) (error, User) {
  if !someModel.IsValid(name) {
    return api.ErrInvalid, nil
  }

  accountID := auth.UserID
  if accountID == 0 {
    return errors.New("Missing accountID"), nil
  }
  prodTag := auth.ProductTag
  if prodTag == "" {
    return errors.New("Missing prodTag"), nil
  }
  email := auth.UserEmail
  if email == "" {
    return errors.New("Missing email"), nil
  }
  
  return nil, CreateUser(name, accountID, prodTag, email)
}

At the call site:

err, user := GetUserFromAuth(...)

if (err != nil) {
  return err
}

// Proceed assuming `user` is valid

You have to use every variable you declare in Go, so you have to do something with err (or name it _), in this way errors propagate though lots of early exits.

In Rust, the Result type is used in a similar way:

#[derive(Debug)]
enum Version { Version1, Version2 }

fn parse_version(header: &[u8]) -> Result<Version, &'static str> {
    match header.get(0) {
        None => Err("invalid header length"),
        Some(&1) => Ok(Version::Version1),
        Some(&2) => Ok(Version::Version2),
        Some(_) => Err("invalid version"),
    }
}

let version = parse_version(&[1, 2, 3, 4]);
match version {
    Ok(v) => println!("working with version: {:?}", v),
    Err(e) => println!("error parsing header: {:?}", e),
}

Maybe Error pattern

In Typescript, I wanted the same behaviour, though we have a much more powerful type system than Go, and less powerful pattern matching than Rust, so I wanted to use compile-time guards. I tried a bunch of things, but this it the best API I could come up with:

export function extractPayload(
  request: Request,
  pubKey: string,
): MaybeError<Response, Payload> {

  const jwt = getValidJwt(request, pubKey)
  
  if (!jwt) {
    return { error: new Response('Missing or invalid token', { status: 403 }) }
  }
  if (!jwt.payload.namespace) {
    return { error: new Response('Missing namespace', { status: 400 }) }
  }
  if (!jwt.payload.id) {
    return { error: new Response('Missing id', { status: 400 }) }
  }
  if (!jwt.payload.exp) {
    return { error: new Response('Missing exp', { status: 400 }) }
  }
  const exp = new Date(jwt.payload.exp * 1000)
  if (Date.now() > +exp) {
    return { error: new Response('Expired JWT', { status: 403 }) }
  }

  return jwt.payload
}

When you invoke it:

const payload = extractPayload(request, pubKey)

if (payload.error) {
  return payload.error
}

const { namespace, id, exp } = payload

This is effectively an Either<Left, Right> pattern or a Result<Ok, Err>, but lopsided—once the the .error value is branched off, the payload is what you expected. So, it's a "Maybe Error".

Implementation

type IsError<T> = {
  error: T
}

type NeverError<T> = T & {
  error?: never
}

type MaybeError<T, U> = IsError<T> | NeverError<U>

Return a MaybeError as either a { error: X } object or just your normal happy-path return value:

function gimmeTheGoods(): MaybeError<string, Response> {
  if (somethingBad) return {error: 'oh gnoes'}
  
  return new Response(`Here's the good stuff!`)
}

Deconstruct a MaybeError by checking .error and exit/return/throw so the type is narrowed:

const theGoods = gimmeTheGoods()
if (theGoods.error) {
  console.log(`Skipping request due to: ${theGoods.error}`)
  return
}

// Here, theGoods is magically a normal Response
theGoods.headers.set('Content-Type', 'text/html')
return theGoods

That's it!

Thanks to https://maecapozzi.com/either-or-types/ for the trick. TS looks for the intersection of two types when you create a union of them, so we need to declare that both sides have an .error property. Except, in NeverError, we say that it'll never be there. A bit confusing but it works!

For the earlier example, the types narrow like this:

const payload = extractPayload(request, pubKey)
// payload: MaybeError<Response, Payload> = IsError<Response> | NeverError<Payload>

// .error is the only property that's safe to check
if (payload.error) {
  // In here, payload: IsError<Response> (since we said NeverError.error: never)
  return payload.error // Response
}

// Now, we know payload: NeverError<Payload> = Payload & { error?: never }
// so we can use it just like it's a Payload

const { namespace, id, exp } = payload

If you don't check payload.error or don't actually return in that check, you get the following:

src/handler.ts:17:11 - error TS2339: Property 'namespace' does not exist on type 'MaybeError<Response, Payload>'.

17   const { namespace, id, exp } = payload
             ~~~~~~~~~
             
src/handler.ts:17:22 - error TS2339: Property 'id' does not exist on type 'MaybeError<Response, Payload>'.

17   const { namespace, id, exp } = payload
                        ~~

src/handler.ts:17:26 - error TS2339: Property 'exp' does not exist on type 'MaybeError<Response, Payload>'.

17   const { namespace, id, exp } = payload
                            ~~~

Since the type hasn't been narrowed to NeverError<Payload>!

Of course, the one caveat is that whatever "good" object you're returning can't have a .error property. But you could always wrap it in an object with a .good or .right property if that's that the case.

Alternatives

You can use the the Either in fp-ts:

// Defining
import { left, right, Either } from 'fp-ts/Either'

function extractPayload(...) : Either<Response, Payload>> {
  const jwt = getValidJwt(request, pubKey)
  if (!jwt) {
    return left(new Response('Missing or invalid token', { status: 403 }))
  }
  // ...
  return right(jwt.payload)
}

// Calling
import { isLeft } from 'fp-ts/Either'

const reponseOrPayload = await extractPayload(request, pubKey)

if (isLeft(reponseOrPayload)) {
  return reponseOrPayload.left
}
const { id, namespace, exp } = reponseOrPayload.right

fp-ts does look legit if you want a full on toolkit of FP concepts to work with, so YMMV, but I much prefer mine.

Thoughts?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment