Skip to content
This repository has been archived by the owner on Jan 21, 2024. It is now read-only.

Latest commit

 

History

History
636 lines (473 loc) · 23.7 KB

README.md

File metadata and controls

636 lines (473 loc) · 23.7 KB

Warning

Hi there! I have decided to formally deprecate this project and publicly archive the repository in favour of my newer and more universal package, unenum. esresult's Result API relies on building non-portable prototypal objects, whereas unenum offers a portable Result type and (optional) runtime API that uses plain objects typed with discriminated unions. I highly recommend using unenum instead of esresult and strongly encourage migrating your codebase to use unenum's equivalent Result type and helpers. See MIGRATION.md for more details. Thanks!

esresult

NPM   •   Issues

Table of Contents


What is esresult?

esresult (ECMA-Script Result) is a tiny, zero-dependency, TypeScript-first, result/error utility.

It helps you easily represent errors as part of your functions' signatures so that:


Why does esresult exist?

You will be writing a lot of functions.

function fn() {
  ...
}

Your functions will often need to return some kind of value.

function fn(): string {
  return value;
}

And will probably need to report errors of some kind.

function fn(): string {
  if (condition) throw new Error("NotFound");
  return value;
}

You will probably have many different types of errors, so you make subclasses of Error.

class NotFoundError extends Error {}
class DatabaseQueryFailedError extends Error {}

function fn(): string {
  if (condition) throw new NotFoundError();
  if (condition) throw new DatabaseQueryFailedError();
  return value;
}

Traditionally, you will use throw to report error; and it would be best to document this behaviour somehow.

class NotFoundError extends Error {}
class DatabaseQueryFailedError extends Error {}

/**
 * @throws {NotFoundError} If the record can't be found.
 * @throws {DatabaseQueryError} If there is an error communicating with the database.
 * @throws {FooError} An error we forgot to remove from the documentation many releases ago.
 */
function fn(): string {
  if (condition) throw new NotFoundError();
  if (condition) throw new DatabaseQueryFailedError();
  return value;
}

If the caller wants to act conditionally for a particular error we also need to import those error classes for comparison.

import { fn, NotFoundError } from "./fn";

try {
    const value = fn();
} catch (e) {
    if (e instanceof NotFoundError) {
        ...
    }
}

If the value returned by fn() (from within the try block) is needed later, the caller needs to use let outside of the try block to then assign it from within.

import { fn, NotFoundError } from "./fn";

let value: string | undefined = undefined;
try {
  value = fn();
} catch (e) {
  if (e instanceof NotFoundError) {
    ...
  }
}

console.log(value);
            ^ // string | undefined

This "simple" function:

  • needs too much boilerplate code to express errors,
  • needs the caller to read the docs to learn of possible error behaviour so that it may safely handle these error-cases,
  • needs the caller to litter their code with let & try/catch blocks to properly scope returned values,
  • needs the caller to perform additional imports of error subclasses just to compare error instances,
  • AND, if the function adds (or removes) error behaviour, static analysis will not notice.

Using esresult instead!

What if we could instead reduce all this into something smaller and more human-friendly with esresult?

  • No error subclasses needed, and are now part of the function's signature.
import Result from "esresult";

function fn(): Result<string, "NotFound" | "DatabaseQueryFailed"> {
  if (condition) return Result.error("NotFound");
  if (condition) return Result.error("DatabaseQueryFailed");
  return Result(value);
}
  • No need to import anything else but the fn itself.
  • No complications with let + try/catch to handle a particular error.
  • All error types can be seen via intelli-sense/autocompletion.
  • Ergonomically handle error cases and default value behaviours.
import { fn } from "./fn"

const $value = fn();
      ^ // ? The Result object that may be of Value or Error.

if ($value.error?.type === "NotFound") {
                  ^ // "NotFound" | "DatabaseQueryFailed" | undefined
}

const value = $value.orUndefined();
      ^ // string | undefined

And if the function doesn't have any known error cases yet (as part of its signature), you can access the successful value directly, without needing to check error (it will always be undefined).

import Result from "esresult";

function fn(): Result<string> {
  return Result(value);
}

const [value] = fn();
       ^ // string

And once you add (or remove) an error case, TypeScript will be able let you know.

import Result from "esresult";

function fn(): Result<string, "Invalid"> {
  if (isInvalid)
    return Result.error("Invalid");
  return Result(value);
}

const [value] = fn();
      ^ // ? Possible ResultError is not iterable! (You must handle the error case first.)

How does esresult work?

esresult default exports Result, which is both a Type and a Function, as explained below.

Result is a type generic that accepts Value and Error type parameters to create a discriminable union of:

  • An "Ok" Result,
    • which will always have a undefined .error property,
  • An "Error" Result,
    • which will always have a non-undefined .error property,
    • and does not have a .value property, therefore an "Ok" Result must be narrowed/discriminated first.

This means that checking for the truthiness of .error will easily discriminate between "Ok" and "Error" Results.

  • If never is given for Result's Value parameter, only a union of "Error" is produced.
  • Vice versa, if never is given for Result's Error parameter, only a union of "Value" is produced.

Result is a function that produces an "Ok" Result object, whereby Error is never.

Result.error is a function that produces an "Error" Result object, whereby Value is never.

"Error" Result's can also contain .meta data about the error (e.g. current iteration index/value, failed input string, etc.).

  • An Error's meta type can be defined via a tuple: Result<never, ["MyError", { foo: string }]>
  • An "Error" Result object can be instantiated similarly: Result.error(["MyError", { foo: "bar" }]);

esresult works with simple objects as returned by Result and Result.error, of which follow a simple prototype chain:

  • "Ok" Result object has, Result.prototype -> Object.prototype
  • "Error" Result object has, ResultError.prototype -> Result.prototype -> Object.prototype

The Result.prototype defines methods such as or(), orUndefined(), and orThrow().


Comparisons

How does esresult compare to other result/error handling libraries?

  • Overall esresult:
    • is mechanically simple to discriminate on a single .error property.
    • supports a simple (and fully typed) error shape mechanism that naturally supports auto-completion.
    • supports causal chaining out-of-the-box so you don't need to use another library.
    • relies on simple functions (or, orUndefined, etc) to reduce value-mapping complexity in favour of native TypeScript control flow.
esresult neverthrow node-verror @badrap/result type-safe-errors space-monad typescript-monads monads ts-pattern boxed
Result discrimination .error .isOk() .isErr() N/A .isOk .isErr as inferred .isOk() .isResult($) .isOk() .isErr() .isOk() .isErr() as inferred .isOk() .isErr()
Free value access if no error def. YES No N/A No (must always discriminate; for errors too!) YES No No No YES No
Error shapes (type/meta) YES No YES No (forces of type Error) No (encourages error instances) No No No No No
Error causal chaining YES No YES No No No No No No No
Error type autocomplete YES No No (relies on throwing) No YES (standard inferred) No No No YES (standard inferred) No
Wrap unsafe functions YES YES N/A No No No No No N/A No
Execute one-off unsafe functions YES No N/A No No No No No N/A No
Async types YES YES N/A No No No No No N/A No
Wrap unsafe async functions YES YES N/A No No No No No N/A No
value access or, orUndefined map, mapErr, orElse (not type restricted) N/A unwrap (could throw if not verbose) map, mapErr map, orElse unwrap unwrapOr unwrap (throws), unwrapOr N/A match (not type restricted)
orThrow (panic) YES No N/A " No No No No YES, (exhaustive) No

Installation

$ npm install esresult

Usage

With no errors

  • A simple function that returns a string without any defined errors.
import Result from "esresult";

function fn(): Result<string> {
  return Result("string");
}
  • Because the Result signature has no defined errors the caller doesn't need to handle anything else.
const [value] = fn();

With one error

  • A function that returns a string or a "NotFound" error.
function fn(): Result<string, "NotFound"> {
  return Result("string");
  return Result.error("NotFound");
}
  • The returned Result may be an error, as determined by its .error property.

... use value, or a default value on error

  • You may provide a default value of matching type to the expected value of the Result.
const valueOrDefault = fn().or("default");

... use value, or undefined on error

  • Or you may default to undefined in the case of an error.
const valueOrUndefined = fn().orUndefined();

... use value, or throw on error

  • Or you may crash your program when in an undefined state that should never happen (e.g. initialisation code).
    • Don't use .orThrow with try/catch blocks as this defeats the purpose of the Result object itself.
const value = fn().orThrow();

... use value, after handling error

  • You can use the Result object directly to handle specific error cases and create error chains.
const $ = fn();
if ($.error) return Result.error("FnFailed", { cause: $ });

const [value] = $;

With many errors

  • You can provide a union of error types to define many possible errors.
function fn(): Result<string, "NotFound" | "NotAllowed"> {
  return Result("string");
  return Result.error("NotFound");
  return Result.error("NotAllowed");
}
const $ = fn();

if ($.error) {
  $.error.type
          ^ // "NotFound" | "NotAllowed"
}

With detailed errors

  • You can add typed meta information to allowing callers to parse more from your error.
    • Provide a tuple with the error type and the meta type/shape to use.
function fn(): Result<
  string,
    | "NotFound"
    | "NotAllowed"
    | ["QueryFailed", { query: Record<string, unknown>; }]
> {
  return Result("string");
  return Result.error("NotFound");
  return Result.error("NotAllowed");
  return Result.error(["QueryFailed", { query: { a: 1, b: 2 } }])
                      ^ // ? Providing a tuple that matches the definition's shape.
}
  • To access the meta property with the correct type, you will need to discriminate by .error.type first.
const $ = fn();

if ($.error) {
  if ($.error.type === "QueryFailed") {
    $.error.meta
            ^ // { query: Record<string, unknown> }
  } else {
    $.error.meta
            ^ // undefined ? Only "QueryFailed" has a meta property definition.
  }
}

Async functions

  • Use Result.Async as a shortcut for Promise<Result>.
async function fn(): Result.Async<string, "Error"> {
  return Result("string");
  return Result.error("Error");
}
  • Results are just ordinary objects that are perfectly compatible with async/await control flows.
const $ = await fn();
const value = $.or("default");
const value = $.orUndefined();

if ($.error) {
  return;
}

const [value] = $;

Chaining errors

  • Often you need will have a function calling another function that could also fail, upon which the caller will fail also.
    • You can provide a cause property to your returned error that will begin to form an error chain of domain-specific errors.
    • Error chains are more useful than a traditional stack-traces because they are specific to your program's domain rather than representing an programming error resulting in undefined program behaviour.
function main(): Result<string, "FooFailed"> {
  const $foo = fn();
        ^ // ? Returns a Result that may be an error.

  if ($foo.error)
    return Result.error("FooFailed", { cause: $foo });

  return Result(value);
}

Wrap throwable functions (.fn)

  • Use Result.fn to wrap unsafe functions (including async functions) that throw.
    • The return type of the wrapped function is correctly inferred as the Value of the Result return signature.
    • If the function throws, the Error is captured in a { thrown: Error } container.
const parse = Result.fn(JSON.parse);
      ^ // (text: string, ...) => Result<unknown, Thrown>

const $ = parse(...);
      ^ // Result<unknown, Thrown>

Execute throwable functions (.try)

  • A shortcut method for Result.fn(() => {})(); offers a simple replacement for a try/catch block.
    • Accepts a function with no arguments and immediately invokes it and forwards its return value (if any) as a Result.
const $ = Result.try(() => {});
      ^ // Result<void, Thrown>

const $ = Result.try(async () => {});
      ^ // Result.Async<void, Thrown>

const $ = Result.try(() => JSON.stringify(...));
      ^ // Result<string, Thrown>

Helpers

JSON

  • The built-in JSON .parse and .stringify methods are frequently used, so esresult offers a pre-wrapped drop-in JSON object replacement.
    • You can achieve the same result with Result.fn(JSON.parse) etc.
import { JSON } from "esresult";

const $ = JSON.parse(...);
      ^ // Result<unknown, Thrown>

const $ = JSON.stringify(...);
      ^ // Result<string, Thrown>

As global definition

You can top-level import Result as a global type and variable, making Result feel as if it were a standard language feature, similar to Promise and Date. This is particularly useful if you don't want to have to import the Result across all your files.

import "esresult/global"

Simply add import "esresult/global" to the top of your project's entrypoint.

  • It should be your first import statement, before all other imports and application code.
  • This declares global TypeScript typings and adds Result to globalThis.
// index.ts (entrypoint)

+ import "esresult/global";

// your code ...
// fn.ts

function fn(): Result<number, "Error"> {
               ^ // Can now use Result without needing to `import` it.
}

License

Copyright (C) 2022 Peter Boyer

esresult is licensed under the MIT License, a short and simple permissive license with conditions only requiring preservation of copyright and license notices. Licensed works, modifications, and larger works may be distributed under different terms and without source code.