@quintal/monads
v0.3.2
Published
A collection of monads (Result, Option) for TypeScript, inspired by [the Rust programming language](https://doc.rust-lang.org/std/result/).
Downloads
19
Maintainers
Readme
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 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:
- [x] Serialize and deserialize monads for API usage
- [ ] 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/monads
Result
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. For cases where having no value is expected, have a look at the Option
type.
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<User, AuthenticateUserError> {
// Wrap the dangerous db call with `asyncResult` to catch the error if it's thrown.
// `usersResult` is of type `AsyncResult<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<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<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<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 creatingAsyncResult
objects. - If you are unsure that an external function you're using might throw an error, you can use the
resultFromThrowable(() => value)
orasyncResultFromThrowable(() => Promise.resolve(value))
functions. These functions return aResult
with as value type the return type of the function, and as error typeunknown
, in case it unexpectedly throws an error. - If you have serialized a
Result
and want to deserialize it, you can useresultFromSerialized
orasyncResultFromSerialized
.
Method Overview
Result
comes with a wide variety of convenience methods that make working with it more succinct.
Querying the contained value
isOk
andisErr
aretrue
if theResult
isok
orerr
, respectively.isOkAnd
andisErrAnd
returntrue
if theResult
isok
orerr
, respectively, and the value inside of it matches a predicate.inspect
andinspectErr
peek into theResult
if it isok
orerr
, 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
:
expect
throws the provided custom message.unwrap
throws a generic error.unwrapOr
returns the provided default value.unwrapOrElse
returns 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
:
expectErr
throws the provided custom message.unwrapErr
throws the success value.
Transforming the contained value
ok
transformsResult<T, E>
intoOption<T>
, mappingok(value)
tosome(value)
anderr(error)
tonone
.err
transformsResult<T, E>
intoOption<E>
, mappingerr(error)
tosome(error)
andok(value)
tonone
.transpose
transforms aResult
of anOption
into anOption
of aResult
flatten
removes at most one level of nesting from aResult<Result<T, E>, E>
.map
transformsResult<T, E>
intoResult<U, E>
by applying the provided function to the contained value ofok
and leavingerr
values unchanged.mapErr
transformsResult<T, E>
intoResult<T, F>
by applying the provided function to the contained value oferr
and leavingok
values unchanged.mapOr
transformsResult<T, E>
intoU
by applying the provided function to the contained value ofok
, or returns the provided default value if theResult
iserr
.mapOrElse
transforms aResult<T, E>
intoU
by 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
.
and
andor
take anotherResult
as input, and produce aResult
as output.andThen
andorElse
take 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.
match
allows you to pattern match on both variants of aResult
.serialize
reduces theResult
class to a simple 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
type.
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
comes with a wide variety of convenience methods that make working with it more succinct.
Querying the contained value
isSome
andisNone
aretrue
if theOption
issome
ornone
respectively.isSomeAnd
returnstrue
if theOption
issome
and the value inside of it matches a predicate.inspect
peeks into theOption
if 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
:
expect
throws the provided custom message.unwrap
throws a generic error.unwrapOr
returns the provided default value.unwrapOrElse
returns the result of evaluating the provided function.
Transforming the contained value
okOr
transformsOption<T>
intoResult<T, E>
, mappingsome(value)
took(value)
andnone
toerr
using the provided defaulterr
value.okOrElse
transformsOption<T>
intoResult<T, E>
, mappingsome(value)
took(value)
andnone
to a value oferr
using the provided function.transpose
transforms anOption
of aResult
into aResult
of anOption
.flatten
removes at most one level of nesting from anOption<Option<T>>
.map
transformsOption<T>
intoOption<U>
by applying the provided function to the contained value ofsome
and leavingnone
values unchanged.mapOr
transformsOption<T>
intoU
by applying the provided function to the contained value ofsome
, or returns the provided default value if theOption
isnone
.mapOrElse
transformsOption<T>
intoU
by applying the provided function to the contained value ofsome
, or returns the result of evaluating the provided fallback function if theOption
isnone
.filter
calls the provided predicate function on the contained value ofsome
, and returnssome(value)
if the function returnstrue
; otherwise, returnsnone
.zip
returnssome([s, o])
if it issome(s)
and the providedOption
value issome(o)
; otherwise, returnsnone
.zipWith
calls the provided functionf
and returnssome(f(s, o))
if it issome(s)
and the providedOption
value 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
, andxor
take anotherOption
as input, and produce anOption
as output.andThen
andorElse
take 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.
match
allows you to pattern match on both variants of anOption
.
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 this is not a fork, my 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.