DEV Community

Luke Harold Miles
Luke Harold Miles

Posted on • Updated on

Return your errors instead of throwing them in typescript

Want your code to never throw errors during runtime? You can get pretty close using this error-return pattern inspired by golang. (I assume Go copied it from a long tradition.)

The problem: Typescript and javascript have no way to indicate that a function may throw an error. So you typically have to either run your code until it crashes or hunt down the source code in github or node_modules (since the bundled dist usually only has non-minified headers) to figure out where to try/catch.

The error-return pattern makes your tooling track where errors may occur so you don't have to memorize or hunt for that information yourself. With error-return, an unhandled error is immediately shown in your editor with a typescript error.

Here's an example of the pattern in a sequential, branching networking task:

async function loadSong(id: string): Err | Song {
    const metadata = await loadMetadata(id)
    if (isErr(metadata)) return metadata // returns error!
    const mp3 = await loadMp3(id)
    if (!isErr(mp3)) return Song(metadata, mp3)
    // try ogg it might work
    const ogg = await loadOgg(id)
    if (!isErr(ogg)) return Song(metadata, ogg)
    // maybe the mirror has it?
    const mirrorMp3 = await loadMp3(id, { useMirror: true })
    if (!isErr(mirrorMp3)) return Song(metadata, mirrorMp3)
    return Err('all audio hosts failed')
}
Enter fullscreen mode Exit fullscreen mode

(Quite nice compared to four levels of indentation with try-catch.)

Then, if you tried to use this function without catching the error in, say, an html element you would get a type error:

const addElm = document.body.appendChild
function playSong(id: string) {
    const song = loadSong(id)
    addElm(Player(song).play())
    // ↑ typescript error: .play() does not exist on type Err
    addElm(Metadata(song))
    // ↑ same error
}
Enter fullscreen mode Exit fullscreen mode

The pattern forces you to account for the failure case:

// Good code: won't runtime error and has no typescript errors
function playSong(id: string) {
    const song = loadSong(id)
    if (isErr(song)) {
        addElm(ErrorDiv('could not load song'))
        return
    }
    addElm(Player(song).play())
    addElm(Metadata(song))
}
Enter fullscreen mode Exit fullscreen mode

Useful for preventing:

  • blank screen and "button does nothing" bugs in the browser
  • server timeout and bad response bugs in node
  • system scripts failing in intermediate states, leaving junk behind
  • unexpected errors in library code

You don't need a library

All the code for this pattern fits in a short file, and you can customize it to your needs. Here's my implementation:

// err.ts:

const ERR = Symbol('ERR')
type Err = {
    [ERR]: true
    error: unknown
    type?: ErrTypes
}

/** Optional addition if you want to handle errors differently based on their type */
type ErrTypes = 'internet' | 'fileSystem' | 'badInput'

export function isErr(x: unknown): x is Err {
    return typeof x === 'object' && x != null && ERR in x
}

export function Err(message: string, type?: string) {
    return { [ERR]: true, error: message, type: type }
}

/** Make an error-throwing function into a error-returning function */
export async function tryFail<T>(
    f: (() => Promise<T>) | (() => T)
): Promise<T | Err> {
    try {
        return await f()
    } catch (e) {
        return { [ERR]: true, error: e }
    }
}

/** If you need to convert your error values back into throw/catch land */
export function assertOk<T>(x: T | Err) {
    if (isErr(x)) throw Error(x.error)
}
Enter fullscreen mode Exit fullscreen mode

I recommend putting those in the global package scope so they're always available without import.

Use the error-return pattern for external libraries & stdlib

Easiest to demonstrate with an example

/** Sometimes has error in runtime and crashes server */
function getUserBad1(id: string) {
    const buf = readFileSync(`./users/${id}.json`)
    return JSON.parse(buf.toString())
}

/** Works but verbose: */
function getUserBad2(id: string) {
    let buf: Buffer
    try {
        buf = readFileSync(`./users/${id}.json`)
    } catch (e) {
        console.warn('could not read file:', e)
        return null
    }
    let user: User
    try {
        user = JSON.parse(buf.toString())
        return user
    } catch (e) {
        console.warn('could not parse user file as json')
        return null
    }
}

/** tryFail pattern is best of both worlds */
function getUser(id: string) {
    const buf = tryFail(() => readFileSync(`./users/${id}.json`))
    if (isErr(buf)) return buf
    return tryFail(() => JSON.parse(buf.toString()))
}
Enter fullscreen mode Exit fullscreen mode

Wrap unreliable functions to make them error-returning

If you're using some library functions all over the place and are tired of repeating the tryFail(()=>...) everywhere (even though it beats massive try-catch chains), it can be helpful to wrap the library with error-returning logic.

We just need one more function in our error library:

// err.ts:
function errReturnify<In, Out>(
    f: (...args: In) => Out
): (...args: In) => Out | Err {
    return (...args: In) => {
        try {
            return f()
        } catch (e) {
            if (e instanceof Error) return Err(e.message)
            return Err(`unknown error in ${f.name}: ${JSON.stringify(e)}`)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Then we can use it to make wrapped library:

// wrapped/fs.ts
/** Wrap up error-throwing functions into error-returning ones */
import {
    cpSync as cpSync_,
    mkdirSync as mkdirSync_,
    readFileSync as readFileSync_,
} from 'fs'

export const cpSync = errReturnify(cpSync_)
export const mkdirSync = errReturnify(mkdirSync_)
export const readFileSync = errReturnify(readFileSync_)

// wrapped/JSON.ts
export default JSON = {
    parse: errReturnify(JSON.parse),
    stringify: JSON.stringify,
}
Enter fullscreen mode Exit fullscreen mode

Then you can use the library code with perfect elegance and reliability

// server.ts
import { readFileSync } from './wrapped/fs'
import JSON from './wrapped/JSON'

function getUser(id: string) {
    const buf = readFileSync(`./users/${id}.json`)
    if (isErr(buf)) return buf
    return JSON.parse(buf.toString())
}
Enter fullscreen mode Exit fullscreen mode

A little inconvenience, but it's just two lines to wrap any function f. Worth the effort if you're using f more than a few times.

Conclusion

In short, this simple pattern can make typescript code dramatically more reliable while avoiding the awkward empty lets with nested try/catch that pervade typescript networking code.

Discussion (0)