TL;DR? 👉 Implementation.
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),
}
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".
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.
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?