Skip to content

A fully type-safe and lightweight way of using exceptions instead of throwing errors

Notifications You must be signed in to change notification settings

ivanhofer/exceptionally

Repository files navigation

🛡️ exceptionally

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.)
⚠️ can warn you about unhandled exceptions
⛔ no external dependencies

Table of contents

Problem Description

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.

Solution

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.

Installation

TypeScript version 5.0 or higher

npm install exceptionally

TypeScript version 4.x

npm install exceptionally@2

You should also set everything to strict mode in your tsconfig.json to get the most out of this package.

Basics

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'

Examples

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.

API

exceptionally - core functionality

All the core functionality to use in any project.

import * from 'exceptionally'

exposed functions

  • 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 or Exception 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`
    }

exposed types

  • 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 a Exception.

    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 a ExceptionallyResult.

    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 a ExceptionallyResult.

    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`

exceptionally/utils

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'

exposed functions

  • tryCatch

    A replacement for try-catch and Promise.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 a Success if all functions were successful.
    If one of the functions returns an Exception, the full result will be an Exception.

    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[]]`
    }

exceptionally/assert

Note: for older build-tools, you may need to import the functionality directly from exceptionally

Useful assertion functions.

import * from 'exceptionally/assert'

exposed functions

  • 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 like assertSuccess when passing an exception object

    import { 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 like assertException when passing a success object

    import { 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" })
    	})
    })

Best Practices

  • 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') }

Comparisons

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 [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.

Glossary

error

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.

exception

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.

Sponsors

Become a sponsor ❤️ if you want to support my open source contributions.

ivanhofer's sponsors

Thanks for sponsoring my open source work!

Releases

No releases published

Packages

No packages published