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.

Top comments (3)

Collapse
 
aidasbaublys profile image
Aidas Baublys • Edited

Why do you use separate try catch blocks instead of one big one in all the examples? With one big one you do not need to create extra functionality, just catch any error and handle it from there. You still use try catch with, imo, needless abstraction and complexity.

Seems to me like pointless optimization and refactoring that makes it easier only to the person who created it (maybe), while any other developer will need to re-learn a widely accepted and working pattern to only discover it being used under the hood:D

Collapse
 
pgross41 profile image
Patrick Gross

Also is there a reason to use a string in the Err function and pass e.message instead of just accepting unknown and passing it the whole error? You lose your stack trace and possibly other things if you don't keep the original error.

Collapse
 
pgross41 profile image
Patrick Gross

This is great. Noticed in errReturnify the ...args aren't being used, I think you need to pass them into the f call!