@ethossoftworks/outcome
v2.3.0
Published
An alternative approach to async/await error handling for TypeScript
Downloads
103
Maintainers
Readme
Outcome
An alternative approach to async/await error handling for TypeScript
Build
git clone https://github.com/ethossoftworks/outcome-ts.git
cd outcome-ts
yarn # or npm install
yarn build # or npm run build
# yarn test # Run testing script
# yarn test-inspect # Run testing script with chrome dev-tools inspector
Usage
Outcome works best (while not required) with the following TypeScript compiler option:
"strictNullChecks": true
Using strictNullChecks
will prevent the Outcome value from being set to null or undefined unless the user-specified Outcome type supports it.
Add to Project
npm install @ethossoftworks/outcome
# or
yarn add @ethossoftworks/outcome
Create a Standard Outcome
import { Outcome } from "@ethossoftworks/outcome"
async function foo(): Promise<Outcome<User>> {
// Do some things
if (success) {
return Outcome.ok(myValue)
}
return Outcome.error("Foo bar")
}
Consume an Outcome
import { Outcome } from "@ethossoftworks/outcome"
const fooResult = await foo()
if (fooResult.isError()) {
console.warn("There was a problem:", fooResult.error)
return
}
// Type inference allows type-safe compile time use of the value
console.log(fooResult.value)
Wrap an Existing Promise With an Outcome
import { Outcome } from "@ethossoftworks/outcome"
function promiseFunction(): Promise<string> {
return new Promise((resolve, reject) => {
resolve("It worked")
})
}
const wrappedResult = await Outcome.wrap(promiseFunction())
if (wrappedResult.isError()) {
console.warn("There was a problem:", wrappedResult.error)
return
}
// Type inference allows type-safe compile time use of the value
console.log(wrappedResult.value)
Wrap Try/Catch With an Outcome
import { Outcome } from "@ethossoftworks/outcome"
async function myApiRequest(): Promise<Outcome<ApiResponse>> {
return Outcome.try(async () => {
const response = await fetch("http://example.com/movies.json")
const json = response.json()
return ApiResponse(json)
})
}
const result = await myApiRequest()
if (result.isError()) {
console.warn("There was a problem:", result.error)
return
}
// Type inference allows type-safe compile time use of the value
console.log(result.value)
Usage With a Defined Error Type
import { Outcome } from "@ethossoftworks/outcome"
enum UserError {
EmailNotFound = 0,
InvalidPassword,
}
async function login(): Promise<Outcome<User, UserError>> {
// Do some things
if (success) {
return Outcome.ok(user)
}
return Outcome.error(UserError.InvalidPassword)
}
function assertUnreachable(x: never): never {
throw new Error()
}
const userResult = await login()
if (userResult.isError()) {
switch (userResult.error) {
case UserError.EmailNotFound:
console.warn("Email not found")
break
case UserError.InvalidPassword:
console.warn("Invalid password")
break
default:
// This Guarantees an exhaustive case check
assertUnreachable(userResult.error)
break
}
return
}
// Type inference allows type-safe compile time use of the value
console.log(userResult.value)
Motivation
Promises and Async/Await are wonderful, flexible APIs that provide a great developer experience. However, promises and async/await have some undesirable syntax when dealing with complex error handling.
Promise Issues
Promise callbacks can make logic branching more difficult and more confusing to reason about while handling all errors appropriately.
Promises in TypeScript do not have error types associated with them.
For example:
function foo(): Promise<Bar> { return new Promise((resolve, reject) => { resolve(new Bar()) }) } function foo2(): Promise<Bar> { return new Promise((resolve, reject) => { resolve(new Bar()) }) } function main() { let bar = null let bar2 = null foo() .then((val) => { // Bar must be a `let` variable or must be passed along to foo2() bar = val return foo2() }) .then((val) => { bar2 = val console.log(bar, bar2) }) .catch((e) => { // Can only single error handler when using sequential promises // e is an Exception, which requires more type checking before handling the error }) }
Becomes:
async function foo(): Promise<Outcome<Bar, FooError>> { return Outcome.ok(new Bar()) } async function foo2(): Promise<Outcome<Bar, Foo2Error>> { return Outcome.ok(new Bar()) } function main() { const bar = await foo() if (bar.isError()) { // Handle individual error return } const bar2 = await foo2() if (bar.isError()) { // Handle individual error return } // Access to both foo and foo2 values at the same level console.log(bar.value, bar2.value) }
Async/Await Issues
Using Async/Await safely necessitates the use try/catch blocks to handle errors
To use a value outside of the try/catch block, a user must define a
let
instead of aconst
outside of the try/catch block. This is not guaranteed to be safe at compile time and type inference is iffy.Using
const
, the resultant value can only be used within the try block which may prevent clean handling of additional errors when using the resultant value. In addition, another level of indentation is undesirable.Often errors in an application are not exceptional and it should not be necessary to treat them as exceptions.
All errors are exception and have no helpful type association.
For example:
async function foo(): Promise<Bar> { return new Bar() } async function foo2(bar: Bar): Promise<boolean> { // Some task requiring bar return true } function main() { try { // Anything we want to do with foo must happen in this try/catch block const bar = await foo() const bar2 = await foo2(bar) console.log(bar, bar2) } catch (e) { // e is an Exception, which requires more type checking before handling the error // Either we handle both errors in a single catch, move into another level of try/catch, // or handle additional errors with another mechanism (separate function, .etc) } }
Becomes:
async function foo(): Promise<Outcome<Bar, FooError>> { return Outcome.ok(new Bar()) } async function foo2(bar: Bar): Promise<Outcome<boolean, Foo2Error>> { // Some task requiring bar return Outcome.ok(true) } function main() { const bar = await foo() if (bar.isError()) { // Handle individual error return } const bar2 = await foo2(bar) if (bar2.isError()) { // Handle individual error return } console.log(bar.value, bar2.value) }
Golang uses the concept of errors as values which allows for checking for errors and handling them without requiring another block indentation. This also allows easier handling of complex error branches.
The downside of how Golang handles errors is that it does not enforce handling errors at compile time which can lead to the Golang equivalent of null pointer exceptions.
Outcome
strives to provide type-safe error handling without the syntactic bloat of try/catches or callbacks.
Design
The goal of Outcome is to allow for clean, complex branching based on success or failure of operations (whether asynchronous or not). This is achieved through type guards (which allow for compile time type inference) and forcing the checking of success/failure before working with the corresponding value. Outcome is also designed to allow for specified error types to allow for cleaner APIs where users can know ahead-of-time all of the different errors that a particular function can return.
Outcome<T, E>
is a union type of the Ok<T>
and Error<E>
types. The value
and error
properties of those respective types are only accessible after a type check on the Outcome
.
Error
is by default an unknown
type which enforces type checking to handle any error. This prevents changes to error types causing unknown problems down the road. An error type may be specified to enforce only returning specific types of errors for a giving Outcome
.
isOk()
, isError()
, and Outcome.isOutcome()
are TypeScript type guards that allows for compile-time type inference.
Any existing promise can be converted to an Outcome
with the provided helper function Outcome.wrap()
. Since promises do not have error types, wrapping promises will use the unknown
type for the error value.
Release Notes
2.2.1
- Fixed npm package
2.2.0
- Updated TypeScript version
- Added helpers to Outcome class
2.1.1
- Converted build system to Rollup
2.1.0
- Exported Error and Ok types for custom type guards
2.0.0
- Added
Outcome.isOutcome()
- Added
isOk()
method to outcomes - Changed
Outcome.val()
toOutcome.ok()
- Changed
Outcome.err()
toOutcome.error()
1.1.0
- Added
Outcome.try()
1.0.0
- Initial Release