maybe-result - Safe function return handling in Typescript and Javascript
Deciding when a function should return undefined
, throw an Error
, or return some other indicator that
"something isn't right" is tough. We don't always know how users calling our function
will use it, and we want to be clear, clean, and safe.
This library provides two approaches for wrapping function results:
- Maybe for when something may or may not exist. (Jump to API)
- Result for when you might have an error, but want to let the caller decide how to handle it. (Jump to API)
Jump to Learn More section - Jump to Origins and Alternatives section
Maybe
In many languages, we have concepts of exceptions and null
values.
(JavaScript has both null
and undefined
. Ugh!)
Often a function will need to indicate when a value maybe exists, or it does not.
In JavaScript, the "does not" is usually returned as undefined
or null
, but sometimes
a function will throw an Error
type instead. Thus, the developer needs to figure out
how that particular function behaves and adapt to that if they want to handle
the missing value.
Finally, throwing Errors in TypeScript can be expensive, as a stack trace must be
generated and cross-referenced to the .js.map
files. These stack traces to your
TypeScript source are immensely useful for tracing actual errors, but they are wasted
processing when ignored.
The Maybe
type makes this cleaner. Elm was an early language that defined this.
Rust has an Option
type, which is the same concept.
A Maybe
is a wrapper object that contains either "some" value, or "none".
A function can thus return a Maybe
, and the client can then choose how to handle
the possibly missing value. The caller can explicitly check for isValue
or
isNone
, or can simply unwrap
the Maybe
and let it throw an error if "none".
It is now the caller's choice. There are many other helper functions too, such as
to unwrap with a default value to return in place of throwing if isNone
.
In JavaScript we like to throw Error
types, but in other languages we call these exceptions.
Throwing is still good for exceptional cases. Maybe
is for "normal" control flows.
Maybe
is not only for function return values. It may be used elsewhere where you want a type-safe
and immutable alternative to undefined
and null
.
Here's a nice introduction to the concept:
Implementing a Maybe Pattern using a TypeScript Type Guard
Example by Story
You might define a data repository class (access to a data store) like this:
class WidgetRepository {
get(widgetID: string): Promise<Widget> {
}
}
If the Widget isn't found, you throw a NotFoundError
. All is well until you start expecting
a Widget not to be found. That becomes valid flow, so you find yourself writing this a lot:
let widget: Widget | undefined;
try {
widget = await widgetRepo.get(widgetID);
} catch (error) {
if (!(error instanceof NotFoundError)) {
throw error;
}
}
if (widget) {
}
You may be willing to do that once... but not more. So you first try to change the repository:
class WidgetRepository {
get(widgetID: string): Promise<Widget | undefined> {
}
}
Now it returns undefined
instead of throwing. Oh, but what a hassle - now you have to check for
undefined
every time you call the function! So instead, you define two functions:
class WidgetRepository {
getOrThrow(widgetID: string): Promise<Widget> {
}
getIfFound(widgetID: string): Promise<Widget | undefined> {
}
}
That makes it easier. It works. You just have to write two functions every time you write a get function. 🙄
OR... use Maybe
🎉
class WidgetRepository {
async get(widgetID: string): PromiseMaybe<Widget> {
}
}
const widget = Maybe.unwrap(await widgetRepo.get(widgetID));
const widget = Maybe.unwrapOrNull(await widgetRepo.get(widgetID));
if (widget) {
} else {
}
const widget = (await widgetRepo.get(widgetID)).unwrapOr(defaultWidget);
Maybe API
There are many functions, both on the Maybe
instance and as static helper functions in
the Maybe
namespace:
interface Maybe<T> extends Iterable<T extends Iterable<infer U> ? U : never> {
readonly isValue: boolean;
readonly isNone: boolean;
[Symbol.iterator](): Iterator<T extends Iterable<infer U> ? U : never>;
unwrap(): T;
unwrapOr<T2>(altValue: T2): T | T2;
unwrapOrElse<T2>(altValueFn: () => T2): T | T2;
unwrapOrNull(): T | null;
unwrapOrThrow<E extends Error>(altError?: string | Error | (() => E)): T;
assertIsValue(message?: string): T;
assertIsNone(message?: string): Maybe.None;
or<T2>(other: Maybe<T2>): Maybe<T> | Maybe<T2>;
orElse<T2>(otherFn: () => Maybe<T2>): Maybe<T> | Maybe<T2>;
and<T2>(other: Maybe<T2>): Maybe<T2> | Maybe.None;
andThen<T2>(mapperFn: (value: T) => Maybe<T2>): Maybe<T2> | Maybe.None;
map<U>(mapperFn: (value: T) => U): Maybe<U>;
mapOr<U>(mapperFn: (value: T) => U, altValue: U): MaybeValue<U>;
mapOrElse<U>(mapperFn: (value: T) => U, altValueFn: () => U): MaybeValue<U>;
filter(predicateFn: (value: T) => boolean): Maybe<T>;
toResult<E>(error?: E): Result<T, E | NoneError>;
toString(): string;
}
type PromiseMaybe<T> = Promise<Maybe<T>>;
declare namespace Maybe {
const None: MaybeNone;
type None = MaybeNone;
const Empty: MaybeValue<void>;
type Empty = MaybeValue<void>;
const withValue: <T>(value: T) => MaybeValue<T>;
const notFound: (...what: string[]) => MaybeNotFound;
const asNone: () => MaybeNone;
const empty: () => MaybeValue<void>;
const wrap: <T>(
value: T | undefined | null,
) => MaybeNone | MaybeValue<NonNullable<T>>;
const unwrap: <T>(maybe: Maybe<T>) => T;
const unwrapOrNull: <T>(maybe: Maybe<T>) => T | null;
function allOrNone<T extends Maybe<any>[]>(
...maybes: T
): Maybe<MaybeValueTypes<T>>;
function allValues<T>(...maybes: Maybe<T>[]): T[];
function any<T extends Maybe<any>[]>(
...maybes: T
): Maybe<MaybeValueTypes<T>[number]>;
function isMaybe<T>(value: unknown): value is Maybe<T>;
}
declare class MaybeNotFound extends MaybeNone {
private readonly what;
constructor(what: string[]);
unwrap(): never;
}
declare class NotFoundError extends Error {
readonly status: 404;
readonly statusCode: 404;
constructor(msg: string);
}
Result
Unlike Maybe
, which simply has some value or no value and doesn't want to return undefined
,
Result
is for when you have an error and don't want to throw
.
Similar to Maybe
, this is all about the function giving the caller the choice of
how to handle a situation - in this case an exceptional situation.
This is modeled off of the Rust Result
type, but made to pair cleanly with this
implementation of Maybe
.
Example
Expanding on the previous example of a WidgetRepository
,
let's add a function in the repository that creates a new widget.
A create
function should error out if the assumption that the
widget doesn't yet exist is false.
class WidgetRepository {
async create(
widget: CreatableWidget,
): Promise<Result<Widget, ConstraintError>> {
try {
return Result.okay(newWidget);
} catch (err) {
return Result.error(err);
}
}
}
const createResult = await widgetRepo.create(creatableWidget);
if (createResult.isOkay) {
return createResult.value;
} else {
throw new HttpBadRequest("Widget already exists");
}
const createResult = await widgetRepo.create(creatableWidget);
return createResult.unwrapOrThrow(new HttpBadRequest("Widget already exists"));
return (await widgetRepo.create(creatableWidget)).unwrap();
return Result.unwrap(await widgetRepo.create(creatableWidget));
return (await widgetRepo.create(creatableWidget)).toMaybe();
Result API
There are many functions, both on the Result
instance and as static helper functions in
the Result
namespace:
interface Result<T, E>
extends Iterable<T extends Iterable<infer U> ? U : never> {
readonly isOkay: boolean;
readonly isError: boolean;
[Symbol.iterator](): Iterator<T extends Iterable<infer U> ? U : never>;
unwrap(): T;
unwrapOr<T2>(altValue: T2): T | T2;
unwrapOrElse<T2>(altValueFn: (error: E) => T2): T | T2;
unwrapOrNull(): T | null;
unwrapOrThrow<E2 extends Error>(
altError?: string | Error | ((error: E) => E2),
): T;
assertIsOkay(message?: string): T;
assertIsError(message?: string): E;
or<T2, E2>(other: Result<T2, E2>): OkayResult<T> | Result<T2, E2>;
orElse<T2>(otherFn: (error: E) => OkayResult<T2>): OkayResult<T | T2>;
orElse<E2>(otherFn: (error: E) => ErrorResult<E2>): Result<T, E2>;
orElse<T2, E2>(otherFn: (error: E) => Result<T2, E2>): Result<T | T2, E2>;
and<T2, E2>(other: Result<T2, E2>): ErrorResult<E> | Result<T2, E2>;
andThen<T2>(mapperFn: (value: T) => OkayResult<T2>): Result<T2, E>;
andThen<E2>(mapperFn: (value: T) => ErrorResult<E2>): Result<T, E | E2>;
andThen<T2, E2>(mapperFn: (value: T) => Result<T2, E2>): Result<T2, E | E2>;
map<U>(mapperFn: (value: T) => U): Result<U, E>;
mapOr<U>(mapperFn: (value: T) => U, altValue: U): OkayResult<U>;
mapOrElse<U>(
mapperFn: (value: T) => U,
altValueFn: (error: E) => U,
): OkayResult<U>;
mapError<F>(mapperFn: (error: E) => F): Result<T, F>;
toMaybe(): Maybe<T>;
toString(): string;
}
type PromiseResult<T, E> = Promise<Result<T, E>>;
declare namespace Result {
const OkayVoid: OkayResult<void>;
const okay: <T>(value: T) => OkayResult<T>;
const okayVoid: () => OkayResult<void>;
const error: <E>(error: E) => ErrorResult<E>;
function all<T extends Result<any, any>[]>(
...results: T
): Result<OkayResultTypes<T>, ErrorResultTypes<T>[number]>;
function any<T extends Result<any, any>[]>(
...results: T
): Result<OkayResultTypes<T>[number], ErrorResultTypes<T>>;
const unwrap: <T, E>(result: Result<T, E>) => T;
function wrap<T, E = unknown>(opFn: () => T): Result<T, E>;
function wrapAsync<T, E = unknown>(
opFn: () => Promise<T>,
): PromiseResult<T, E>;
function isResult<T = any, E = any>(val: unknown): val is Result<T, E>;
}
Learn More
- View the Generated API Documentation
- Read full-coverage examples in the Maybe unit test suite and Result unit test suite.
- Functions are named per some foundational concepts:
wrap
wraps up a value
unwrap
means to extract the value
or
performs a boolean or operation between two instances
orElse
lazily gets the second operand for an or operation via a callback function only if needed
and
performs a boolean and operation between two instances
andThen
lazily gets the second operand for an and operation via a callback function only if needed
map
functions transform the value to return a new instance (immutably)
Origin and Alternatives
This implementation is based on ts-results,
which adheres to the Rust API.
This library has more natual word choices, Promise support, additional functions, and other enhancements.
There are many other libraries that do this same thing - just
search NPM for "maybe".
It is up to you to decide which option is best for your project.
The goal of this library is to be featureful, safe, and easy to understand without
a study of functional programming.