A fully type-safe and lightweight way of using exceptions instead of throwing errors
🦺 fully typesafe
🐤 lightweight core library (132 bytes)
⚙️ useful utility functions
🏃 almost no runtime overhead
👌 easy to use syntax
🤝 works everywhere (browser, node, cjs, esm, etc.)
⛔ no external dependencies
Code can fail. Especially when you are accessing multiple services. The common way to handle errors is to throw them. But you won't really know what function could potentially throw an error in your application code.
Well written applications can differentiate between errors and exceptions. They crash on errors and can recover from exceptions.
Wrapping everything into try-catch
blocks is not a good approach since it requires you to know the implementation of the function you are calling, adds a indentation level, alters the program flow and is easy to forget if you are not paying attention.
The exceptions you get in the catch
block are typed as unknown
, so you don't really know what happened and you need to account for different kind of exceptions (e.g. retry sending a request makes only sense if you got a network exception and will probably not make sense if you pass invalid payload to a service).
While it requires just a little of effort to look into a function to see what kind of exceptions get thrown, you probably can handle it manually. But in bigger applications you will probably have a lot of nesting and conditional logic where it is not so easy to spot all different outcomes of a function acll. It is easy to forget to handle an exception case or maybe you want to handle a case that was already handled inside that function, so you'll end up with code that will never be reached.
Adding a new kind of exception deep down in the nested functions would require you to take a look at all the code parts that call that function and check whether they should handle the exception or pass it to the next level.
Don't throw errors and exceptions, return them instead. That's it!
No, there is a little bit more to it.
First of all, we need to make sure that in each part of the code we know what the outcome of a specific function call will be, without looking at the implementation. To do that, we always need to return data as well as exceptions. If we return everything, TypeScript can infer all types and we know what we get back when calling a function.
But now we also need a way to distinguish between a successful result and a result that contains an exception, so we need to wrap the value we return into an object. A by-product of this is that we need to unwrap the actual value at a later point, where we want to access it. This should be made as easiest as possible.
Because we don't throw exceptions, we don't use try-catch
blocks. We need to use if
statements to check whether or result contains a successful response or an exception.
Little helper functions, that are fully typed will greatly improve the Developer Experience. Of course we want our code to be explicit enough, so the code can be read and understood fast. This means we need to come up with meaningful names for our wrapping functions.
And because this is no rocket science, we don't need hundreds of dependencies to make this all happen. The library should be kept clean and efficient.
This packages delivers a solution to all the problems described above.
npm install exceptionally
npm install exceptionally@2
You should also set everything to
strict
mode in yourtsconfig.json
to get the most out of this package.
import { success, exception } from 'exceptionally'
const doSomething = () => {
const value = Math.random()
// whenever you usually throw, return an `exception` instead
// you can pass additional payload if you want
if (value < 0.1) return exception('please try again')
// if a function can return an `exception`, you should wrap the returned element with `success`
return success(value)
}
const result = doSomething()
// instead of having to use `try-catch`, you simply check if the result is an exception
if (result.isException) {
// you can unwrap the exception, and the result will be typed as `string`
console.error(result().toUppercase()) => 'PLEASE TRY AGAIN'
return
}
// because we have handled the exception above, we are left with the `success` object
// unwrap it and it will be typed as a `number`
console.info(result().toPrecision(2)) // => e.g. '0.57'
You need to clone this repository locally and open it in your IDE (VS Code) to see typesafety in action. StackBlitz and github.dev won't show TypeScript errors. Also the TypeScript Playground is not able to show it correctly unless you set some specific options.
All the core functionality to use in any project.
import * from 'exceptionally'
-
success
-
Wrap any value into a
Success
object.import { success } from 'exceptionally' const saySomething = () => { return success('hello world') } const result = saySomething() result.isSuccess // => `true` result.isException // => `false` result() // => `'hello world'`
-
exception
Wrap any value into an
Exception
object.import { exception } from 'exceptionally' const saySomething = () => { return exception("Don't tell me what to do!") } const result = saySomething() result.isSuccess // => `false` result.isException // => `true` result() // => `"Don't tell me what to do!"`
-
isExceptionallyResult
To check if any given value is a
Success
orException
object.import { isExceptionallyResult, success } from 'exceptionally' const result = Math.random() > 0.5 ? success(1) : 0 if (isExceptionallyResult(result)) { const data = result() console.info(data) // => `1` } else { console.info(result) // => `0` }
-
Success
The type returned by
success()
.import { type Success, success } from 'exceptionally' const result: Success = success(1)
-
Exception
The type returned by
exception()
.import { type Exception, exception } from 'exceptionally' const result: Exception = exception(1)
-
ExceptionallyResult
Either a
Success
or aException
.import { exception, type ExceptionallyResult } from 'exceptionally' const result: ExceptionallyResult = Math.random() > 0.5 ? success(1) : exception(0)
-
ExtractSuccess
Get the type of the
Success
object from aExceptionallyResult
.import { exception, type ExtractSuccess, success } from 'exceptionally' const result = Math.random() > 0.5 ? success(new Date()) : exception('error') type Data = ExtractSuccess<typeof result> // => `Success<Date>`
-
ExtractException
-
Get the type of the
Exception
object from aExceptionallyResult
.import { exception, type ExtractException, success } from 'exceptionally' const result = Math.random() > 0.5 ? success(new Date()) : exception('error') type Data = ExtractException<typeof result> // => `Exception<string>`
-
ExtractData
Get the type of the data wrapped in a
ExceptionallyResult
.import { type ExtractData, success } from 'exceptionally' const result = Math.random() > 0.5 ? success(new Date()) : exception('error') type Data = ExtractData<typeof result> // => `Date | string`
-
ExtractSuccessData
Get the type of the data wrapped in a
Success
.import { exception, type ExtractSuccessData, success } from 'exceptionally' const result = Math.random() > 0.5 ? success(new Date()) : exception('error') type Data = ExtractSuccessData<typeof result> // => `Date`
-
ExtractExceptionData
Get the type of the data wrapped in an
Exception
object.import { exception, type ExtractExceptionData, success } from 'exceptionally' const result = Math.random() > 0.5 ? success(new Date()) : exception('error') type Data = ExtractExceptionData<typeof result> // => `string`
Note: for older build-tools, you may need to import the functionality directly from
exceptionally
Utility functions that wrap common use cases.
import * from 'exceptionally/utils'
-
tryCatch
A replacement for
try-catch
andPromise.catch()
. Per default it will log the error to the console.import { exception } from 'exceptionally' import { tryCatch } from 'exceptionally/utils' // You usually don't have control over external code. It might throw an exception. const externalApi = { fetchProjects: () => { if (Math.random() < 0.1) { throw new Error('something went wrong') } return [1, 2, 3] }, } // basic usage const doSomething = () => { // Therefore you can to wrap it in a `tryCatch` to handle the exception. const result = tryCatch(() => externalApi.fetchProjects()) if (result.isException) { return [] } return result } // with exception callback const doSomething = () => { // Therefore you can to wrap it in a `tryCatch` to handle the exception. const result = tryCatch( () => externalApi.fetchProjects(), (error: unknown) => { if (error instanceof Error) { return exception(error.message) } return exception('Some unexpected error occurred') }, ) if (result.isException) { return [] } return result } // custom logger const doSomething = () => { // Therefore you can to wrap it in a `tryCatch` to handle the exception. const result = tryCatch( () => externalApi.fetchProjects(), undefined, // <- the optional exception callback { error: Sentry.captureException }, // will log the error to Sentry ) if (result.isException) { return [] } return result }
processInParallel
Processes and unwraps multiple functions in parallel.
The result is aSuccess
if all functions were successful.
If one of the functions returns anException
, the full result will be anException
.import { exception, success } from 'exceptionally' import { processInParallel } from 'exceptionally/utils' const loadUserDetails = async (): Promise<User> => { const user = await db.getUser() if (!user) return exception('Could not find user') return success(user) } const loadProjects = async (): Promise<Project[]> => { return success([]) } const result = await processInParallel( [ loadUserDetails(), loadProjects(), ] as const, ) // make sure to put `as const` to get proper type-safety if (result.isException) { const [loadUserError, loadProjectError] = result() // => `[string | undefined, unknown]` } else { const [user, projects] = result() // => `[User, Project[]]` }
Note: for older build-tools, you may need to import the functionality directly from
exceptionally
Useful assertion functions.
import * from 'exceptionally/assert'
-
guardSuccess
&assertSuccess
To really make sure that you have handled all exceptions above.
import { exception } from 'exceptionally' import { assertSuccess, guardSuccess } from 'exceptionally/assert' const doSomething = () => { const result = Math.random() > 0.5 ? success(1) : exception(0) // oops, some important code was commented out // if (result.isException) throw new Error(result()) // will show a `TypeScript` error guardSuccess(result) // will show a `TypeScript` error and throw a runtime error assertSuccess(result) return success() }
-
guardException
&assertException
To really make sure that you are dealing with an exception.
import { exception } from 'exceptionally' import { assertException, guardException } from 'exceptionally/assert' const doSomething = () => { const result = Math.random() > 0.5 ? success(1) : exception(0) // oops, some important code was commented out // if (result.isSuccess) return result() // will show a `TypeScript` error guardException(result) // will show a `TypeScript` error and throw a runtime error assertException(result) throw new Error(result()) }
-
assertExceptionsHandled
&guardExceptionsHandled
To really make sure that you have handled all exceptions.
import { exception } from 'exceptionally' import { assertException, guardException } from 'exceptionally/assert' const doSomething = () => { const result = Math.random() > 0.5 ? exception(new FetchException()) : exception(new Error()) const exception = result() if (exception instanceof FetchException) return // oops, some important code was commented out // if (exception instanceof Error) return // will show a `TypeScript` error guardExceptionsHandled(result) // will show a `TypeScript` error and throw a runtime error assertExceptionsHandled(result) }
-
assertSuccessAndUnwrap
Useful for testing your application.
Will not show a TypeScript error likeassertSuccess
when passing anexception
objectimport { expectException } from 'exceptionally/assert' import { describe, expect, it } from 'vitest' // or `jest` or other testing libraries describe('login', () => { it('should return `true` if credentials are correct', async () => { expect(await assertSuccessAndUnwrap(login('username', 'password'))) .toBe(true) }) })
-
assertExceptionAndUnwrap
Useful for testing your application.
Will not show a TypeScript error likeassertException
when passing asuccess
objectimport { expectException } from 'exceptionally/assert' import { describe, expect, it } from 'vitest' // or `jest` or other testing libraries describe('login', () => { it('should handle invalid input', async () => { expect(await assertExceptionAndUnwrap(login('admin', 'invalid-password'))) .toBeInstanceOf({ message: "Credentials don't match any user in this system" }) }) })
-
create wrapper functions for calls to other services
Keep it DRY. Once you have written the code to connect to a service, you can reuse it for different API calls. And you don't need to handle the same edge-cases multiple times. -
internally don't throw anything, just throw errors at the application boundaries
Inside the code you can control, never throw errors. But you need to tell your users and services that consume data from your application if something was not successful. At that point it is ok to throw an Error. -
document what kind of errors your application could throw and use a unique class (or error code) per error
Having an unique meaningful identifier for each kind of error (e.g. validation, network-issues, etc.) will help you understand what has happened even after 3 or more levels of function calls. It makes it easy to handle only specific exceptions and deliver better error messages to your users.TypeScript can't distinguish between different Classes that derive from
Error
. As a workaround we can set a property inside that class to make inference work again.class NetworkException extends Error {
readonly #id = Symbol('NetworkException')
} class DecodeJsonException extends Error { readonly #id = Symbol('DecodeJsonException') }
There exist similar approaches how to best handle errors and exceptions in applications. Here is a comparison between the approach exceptionally
uses and other techniques.
exceptionally
: examples- return a
[data,error] tuple
: examples - return an
{data,error} object
: examples neverthrow
: examples@badrap/result
: examples
exceptionally |
[data,error] tuple |
{data,error} object |
neverthrow |
@badrap/result |
|
---|---|---|---|---|---|
prevents try-catch blocks (example 1) | ✅ | ✅ | ✅ | ✅ | ✅ |
typesafe error handling (example 1) | ✅ | ✅ | ✅ | ✅ | ✅ |
obvious how to handle falsy return values (example 2) | ✅ | ❌ | ❌ | ✅ | ✅ |
can access error object without needing to store it as a variable first (example 3) | ✅ | ❌ | ❌ | ✅ | ✅ |
does not require you to come up with new two variable names per result (example 4) | ✅ | ❌ | ❌ | ✅ | ✅ |
obvious how to handle no data and no error return values (example 5) | ✅ | ❌ | ❌ | ✅ | ✅ |
Error can be any data | ✅ | ✅ | ✅ | ✅ | ❌ |
can detect uncaught errors without an additional package | ✅ | ❌ | ❌ | ❌ | ✅ |
will never throw (unless you really want it) | ✅ | ✅ | ✅ | ❌ | ❌ |
offer useful functions to work with the library more easily | ✅ | ❌ | ❌ | ✅ | ✅ |
adds less than 0.5kb to your bundle |
✅ | ➖ | ➖ | ❌ | ❌ |
has chosen an "exceptionally" name 😋 | ✅ | ❌ | ❌ | ❌ | ❌ |
Do you have other examples? Please open a PR and add them to the table.
It is not possible to recover from an error.
e.g. a OutOfMemoryError
will hinder your application to execute it's code and therefore you can probably do little to nothing against it. The result will probably lead to an exit of the application.
Exceptions are caused by the code of the application itself. The application knows this case could occur and can recover from it.
e.g. a ValidationException
will not store the data in your database, but will also not crash your application.
Become a sponsor ❤️ if you want to support my open source contributions.
Thanks for sponsoring my open source work!