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')
}
(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
}
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))
}
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)
}
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()))
}
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)}`)
}
}
}
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,
}
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())
}
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 let
s with nested try/catch that pervade typescript networking code.
Top comments (3)
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
Also is there a reason to use a string in the
Err
function and passe.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.This is great. Noticed in
errReturnify
the...args
aren't being used, I think you need to pass them into thef
call!