@quintal/monads v0.4.0
Quintal Monads
A collection of monads (Result, Option) for TypeScript, inspired by the Rust programming language.
Features
- ๐ก๏ธ Easy type-safe error- and empty-value handling,
- ๐ฆ Implements all relevant stable utility methods from Rust,
- โ CommonJS and ES Modules support,
- ๐ Extensive documentation,
- โ๏ธ Super lightweight (only ~1kb gzipped),
- ๐ 0 dependencies,
- ๐งช 100% test coverage.
Roadmap
The following features are planned for future releases:
- Serialize and deserialize monads for API usage
- Fully implement the Option monad (must be done before v1)
- Find a nice way to emulate Rust's question mark syntax
- Write docs on Rust's must-use property
Table of Contents
Getting Started
pnpm add @quintal/monads
# or
bun add @quintal/monads
# or
yarn add @quintal/monads
# or
npm install @quintal/monadsResult
A TypeScript error handling paradigm using a Result monad.
The type Result<T, E> is used for returning and propagating errors. It has the following variants:
ok(value: T), representing success;err(error: E), representing error.
Functions return Result whenever errors are expected and recoverable. It signifies that the absence of a return value is due to an error or an exceptional situation that the caller needs to handle specifically (e.g. database, network, or filesystem calls). For cases where having no value is expected, have a look at the Option monad.
A simple function returning Result might be defined and used like so:
import { type Result, ok, err } from '@quintal/monads';
// Type-safe error handling
type GetUniqueItemError = 'no-items' | 'too-many-items';
// `Result` is an explicit part of the function declaration, making it clear to the
// consumer that this function may error and what kind of errors it might return.
function getUniqueItem<T>(items: T[]): Result<T, GetUniqueItemError> {
// We do not throw, we return `err()`, allowing for a control flow that is easier to process
if (items.length === 0) return err('no-items');
if (items.length > 1) return err('too-many-items');
return ok(items[0]!);
}
// Pattern match the result, forcing the user to account for both the success and the error state.
const message = getUniqueItem(['item']).match({
ok: (value) => `The value is ${value}`,
err: (error) => {
if (error === 'no-items') return 'There were no items found in the array';
if (error === 'too-many-items') return 'There were too many items found in the array';
},
});A more advanced use case might look something like this:
import { type AsyncResult, asyncResult, err, ok } from '@quintal/monads';
enum AuthenticateUserError {
DATABASE_ERROR,
UNKNOWN_USERNAME,
USER_NOT_UNIQUE,
INCORRECT_PASSWORD,
}
async function authenticateUser(
username: string,
password: string,
// AsyncResult allows to handle async functions in a Result context
): AsyncResult<Result<User, AuthenticateUserError>> {
// Wrap the dangerous db call with `asyncResult` to catch the error if it's thrown.
// `usersResult` is of type `AsyncResult<Result<User[], unknown>>`.
const usersResult = asyncResult(() =>
db
.select()
.from(users)
.where({ username: eq(users.username, username) }),
);
// If there was an error, log it and replace with our own error type.
// If it was a success, this fuction will not run.
// `usersDbResult` is of type `AsyncResult<Result<User[], AuthenticateUserError>>`.
const usersDbResult = usersResult.mapErr((error) => {
console.error(error);
// You can differentiate between different kinds of DB errors here
return AuthenticateUserError.DATABASE_ERROR;
});
// If it was a success, extract the unique user from the returned list of users.
// If there was an error, this function will not run.
// `userResult` is of type `AsyncResult<Result<User, AuthenticateUserError>>`.
const userResult = usersResult.andThen(getUniqueItem).mapErr((error) => {
if (error === 'no-items') return AuthenticateUserError.UNKNOWN_USERNAME;
if (error === 'too-many-items') return AuthenticateUserError.USER_NOT_UNIQUE;
return error;
});
// It is possible to chain async functions on an `AsyncResult` (see API documentation)
const authenticatedUserResult = userResult.andThen(async (user) => {
const passwordMatches = await compare(password, user.password);
if (!passwordMatches) return err(AuthenticateUserError.INCORRECT_PASSWORD);
return ok(user);
});
return authenticatedUserResult;
}Or, shortened:
import { type AsyncResult, asyncResult, err, ok } from '@quintal/monads';
enum AuthenticateUserError {
DATABASE_ERROR,
UNKNOWN_USERNAME,
USER_NOT_UNIQUE,
INCORRECT_PASSWORD,
}
async function authenticateUser(
username: string,
password: string,
): AsyncResult<Result<User, AuthenticateUserError>> {
return asyncResult(() =>
db.select().from(users).where({ username: eq(users.username, username) })
)
.mapErr((error) => {
console.error(error);
return AuthenticateUserError.DATABASE_ERROR;
})
.andThen(getUniqueItem)
.mapErr((error) => {
if (error === 'no-items') return AuthenticateUserError.UNKNOWN_USERNAME;
if (error === 'too-many-items') return AuthenticateUserError.USER_NOT_UNIQUE;
return error;
});
.andThen(async (user) => {
const passwordMatches = await compare(password, user.password);
if (!passwordMatches) return err(AuthenticateUserError.INCORRECT_PASSWORD);
return ok(user);
});
}Creating a Result
There are a few ways to initialize a Result, each with a different set of use cases:
- The examples above use the
ok(value)anderr(error)utilities, which are aliases for the object instantiationsnew Ok(value)andnew Err(error). These functions are used in the cases when you know what the result is when creating it. - The same use case counts for the
asyncOk(value)andasyncErr(error)utilities, which areok(value)anderr(error)'s async counterparts, acting as aliases for easily creatingAsyncResultinstances. - If you are unsure that an external function you're using might throw an error, you can use the
resultFromThrowable(() => value)orasyncResultFromThrowable(async () => value)functions. These functions return aResultwith the return type of the function as value, andunknownas the error type, just in case it unexpectedly throws an error while executing. - If you have serialized a
Resultand want to deserialize it, you can useresultFromSerializedorasyncResultFromSerialized. - If you have a set of results you'd like to combine into one, use the
resultFromResults(resultA, resultB, ...)utility function. This either returns a result with an array of all given result values, or the first encountered error.
Method Overview
Result provides a wide variety of convenience methods that make working with it more succinct.
Querying the contained value
isOkandisErraretrueif theResultisokorerr, respectively.isOkAndandisErrAndreturntrueif theResultisokorerr, respectively, and the value inside of it matches a predicate.inspectandinspectErrpeek into theResultif it isokorerr, respectively.
Extracting the contained value
These methods extract the contained value from a Result<T, E> when it is the ok variant. If the Result is err:
expectthrows the provided custom message.unwrapthrows a generic error.unwrapOrreturns the provided default value.unwrapOrElsereturns the result of evaluating the provided function.
These methods extract the contained value from a Result<T, E> when it is the err variant. If the Result is ok:
expectErrthrows the provided custom message.unwrapErrthrows the success value.
Transforming the contained value
oktransformsResult<T, E>intoOption<T>, mappingok(value)tosome(value)anderr(error)tonone.errtransformsResult<T, E>intoOption<E>, mappingerr(error)tosome(error)andok(value)tonone.transposetransforms aResultof anOptioninto anOptionof aResultflattenremoves at most one level of nesting from aResult<Result<T, E>, E>.maptransformsResult<T, E>intoResult<U, E>by applying the provided function to the contained value ofokand leavingerrvalues unchanged.mapErrtransformsResult<T, E>intoResult<T, F>by applying the provided function to the contained value oferrand leavingokvalues unchanged.mapOrtransformsResult<T, E>intoUby applying the provided function to the contained value ofok, or returns the provided default value if theResultiserr.mapOrElsetransforms aResult<T, E>intoUby applying the provided function to the contained value ofok, or applies the provided default fallback function to the contained value oferr.
Boolean operators
These methods treat the Result as a boolean value, where ok acts like true and err acts like false.
andandortake anotherResultas input, and produce aResultas output.andThenandorElsetake a function as input, and only lazily evaluate the function when they need to produce a new value.
Rust syntax utilities
Because we are not actually working with Rust, we are missing some essential syntax to work with the Result monad. These methods attempt to emulate some of this syntax.
matchallows you to pattern match on both variants of aResult.serializereduces theResultobject to a simple, type-safe object literal.
If you have an idea on how to approach emulating Rust's question mark syntax, if let syntax, or other Rust language features that are not easily achieved in Typescript, feel free to open an issue.
Option
A TypeScript optional value handling paradigm using an Option monad.
The type Option<T> represents an optional value. It has the following variants:
some(value: T), representing the presence of a value;none, representing the absence of a value.
Functions return Option whenever the absence of a value is a normal, expected part of the function's behaviour (e.g. initial values, optional function parameters, return values for functions that are not defined over their entire input range). It signifies that having no value is a routine possibility, not necessarily a problem or error. For those cases, have a look at the Result monad.
A simple function returning Option might be defined like so:
import { type Option, some, none } from '@quintal/monads';
// `Option` is an explicit part of the function declaration, making it clear to the
// consumer that this function may return nothing.
function safeDivide(numerator: number, denominator: number): Option<number> {
if (denominator === 0) return none;
return some(numerator / denominator);
}
// Pattern match the result, forcing the user to account for both the some and none state.
const message = safeDivide(10, 0).match({
some: (value) => `The value is: ${value}`,
none: () => 'Dividing by 0 is undefined',
});Method Overview
Option provides a wide variety of convenience methods that make working with it more succinct.
Querying the contained value
isSomeandisNonearetrueif theOptionissomeornonerespectively.isSomeAndreturnstrueif theOptionissomeand the value inside of it matches a predicate.inspectpeeks into theOptionif it issome.
Extracting the contained value
These methods extract the contained value from an Option<T> when it is the some variant. If the Option is none:
expectthrows the provided custom message.unwrapthrows a generic error.unwrapOrreturns the provided default value.unwrapOrElsereturns the result of evaluating the provided function.
Transforming the contained value
okOrtransformsOption<T>intoResult<T, E>, mappingsome(value)took(value)andnonetoerrusing the provided defaulterrvalue.okOrElsetransformsOption<T>intoResult<T, E>, mappingsome(value)took(value)andnoneto a value oferrusing the provided function.transposetransforms anOptionof aResultinto aResultof anOption.flattenremoves at most one level of nesting from anOption<Option<T>>.maptransformsOption<T>intoOption<U>by applying the provided function to the contained value ofsomeand leavingnonevalues unchanged.mapOrtransformsOption<T>intoUby applying the provided function to the contained value ofsome, or returns the provided default value if theOptionisnone.mapOrElsetransformsOption<T>intoUby applying the provided function to the contained value ofsome, or returns the result of evaluating the provided fallback function if theOptionisnone.filtercalls the provided predicate function on the contained value ofsome, and returnssome(value)if the function returnstrue; otherwise, returnsnone.zipreturnssome([s, o])if it issome(s)and the providedOptionvalue issome(o); otherwise, returnsnone.zipWithcalls the provided functionfand returnssome(f(s, o))if it issome(s)and the providedOptionvalue issome(o); otherwise, returnsnone.unzip"unzips" itself, meaning that if it issome([a, b]), this method returns[some(a), some(b)], otherwise,[none, none]is returned.
Boolean operators
These methods treat the Option as a boolean value, where some acts like true and none acts like false.
and,or, andxortake anotherOptionas input, and produce anOptionas output.andThenandorElsetake a function as input, and only lazily evaluate the function when they need to produce a new value.
Rust syntax utilities
Because we are not actually working with Rust, we are missing some essential syntax to work with the Option monad. These methods attempt to emulate some of this syntax.
matchallows you to pattern match on both variants of anOption.serializereduces theOptionobject to a simple, type-safe object literal.
If you have an idea on how to approach emulating Rust's question mark syntax, if let syntax, or other Rust language features that are not easily achieved in Typescript, feel free to open an issue.
Acknowledgement
Though it is not a fork, this implementation draws prior work from Sniptt's monads package and Supermacro's neverthrow package. I was very inspired by their work and the issues the community filed to these repositories.