0.3.0 • Published 6 months ago

@mobile-club/upshot v0.3.0

Weekly downloads
-
License
MIT
Repository
github
Last release
6 months ago

⚠️ Documentation is WIP

Upshot ☯

Upshot - noun / the final or eventual outcome or conclusion of a discussion, action, or series of events.

Description

When consuming APIs such as JSON.parse: any or findUser(): Promise<User>, we make the false assumption that they are not going to throw (as per their signature), and even if they do, we don't know what type of errors it's going to yield. It's even more true when consuming third-party libraries. This leads to unsafe code with uncaught exceptions all over the place.

In order to write better and safer software, we can leverage a well-known tactical pattern to better handle errors or apply and chain computations on "eventual" values or errors.

Let's first note that not ALL program exceptions should be avoided. We divide errors into 2 types :

  • Systemic errors: Out-of-memory errors, no more space on disk, maybe even some infrastructure errors (db down), etc
  • Application errors: User with specified id was not found, A post can't be liked twice by a user, A banned user cannot do action X, etc.

Systemic errors are often not recoverable, and it's fine (or wanted) if an exception bubbles up your stack and triggers the red bell of your monitoring systems.

Application errors are part of the life of your system. They are expected and thus should be treated as any other value, they should be explicitly exposed by the underlying APIs that could return them (in their signature), so that they can be handled in place instead of leaking uncaught exceptions up to the root stack.


This library exposes Upshot<E, R> data type (also known as Either or Result in other systems) and is defined as the following :

type Upshot<E, R> = Ko<E> | Ok<R>;

It provides a set of functions to apply computations over this data type.

All the functions :

  • Provide both curried and uncurried signatures
  • Work with both sync and async operations (no dedicated data structures to work with async code)

The goals of the library are :

  • As much minimal as possible (do not go beyond the scope of error management)
  • Easy to use (compact features with polymorphic return types)
  • Provide a good DX (Expose the same APIs for both sync & async operations)

Table of contents

Installation

⚠️ Package is not published yet

yarn add @mobile-club/upshot

Test

yarn test

Documentation

ok

Wraps a value T into an Upshot<never, T>

import { ok } from "@mobile-club/upshot";

const x = ok(42); // Upshot<never, 42>

ko

Wraps a value E into an Upshot<E, never>

import { ko } from "@mobile-club/upshot";

const x = ko(42); // Upshot<42, never>

isOk

Checks if an Upshot<any, R> can be narrowed to Ok<R>

import { ok } from "@mobile-club/upshot";

const x = ok(42); // Upshot<never, 42>

if (isOk(x)) {
  x; // Ok<42>
  x.value; // 42
}

isKo

Checks if an Upshot<E, any> can be narrowed to Ko<E>

import { ko } from "@mobile-club/upshot";

const x = ko(42); // Upshot<42, never>

if (isOk(x)) {
  x; // Ko<42>
  x.value; // 42
}

isUpshot

Checks if a value is an AnyUpshot

import { isUpshot } from "@mobile-club/upshot";

const x = // ...;

if (isUpshot(x)) {
  x       // AnyUpshot
  x.value // any
}

pipe

pipe does not deal specifically with Upshot, it's a utility function we use very often in order to compose multiple functions together. pipe takes a variadic amount of parameters. The first parameter is a value, the rest parameter is a list of functions. The first function will be called with the value as a parameter, the second function will be called with the result of the previous computation as a parameter, and so on...

We can pass both sync and async functions. If at least one function is async, the result with always be async (Promise).

const x1 = pipe(42, (x) => x + 1); // 43

const x2 = pipe(
  42,
  (x) => x + 1,
  (x) => x + 1
); // 44

const x3 = pipe(
  42,
  (x) => x + 1,
  async (x) => x + 1
); // Promise<44>

const x4 = pipe(
  42,
  async (x) => x + 1,
  (x) => x + 1
); // Promise<44>

Why not use an already existing ramda pipe for instance? because it does not work when mixing sync and async functions out of the box (without using R.andThen) :

// ramda
const x = pipe(
  42,
  (x) => x + 1,
  (x) => x + 1
); // 44

// upshot (same)
const x = pipe(
  42,
  (x) => x + 1,
  (x) => x + 1
); // 44

when using async :

// ramda
const x = pipe(
  42,
  async (x) => x + 1,
  andThen((x) => x + 1)
); // Promise<44>

// upshot
const x = pipe(
  42,
  async (x) => x + 1,
  (x) => x + 1
); // Promise<44>

mapOk

Apply function to an upshot only if isOk and returns a new Upshot based on the function's return type. It returns identity otherwise.

import { ok, ko, mapOk } from "@mobile-club/upshot";

const addOne = (x) => x + 1;
const addOneAsync = async (x) => x + 1;

const x = ok(42);               // Upshot<never, 42>
const x2 = mapOk(x, addOne);      // Upshot<never, 43>
const x3 = mapOk(x, addOneAsync); // Upshot<never, 43> | Promise<Upshot<never, 43>>

const y = ko(42);               // Upshot<42, never>
const y2 = mapOk(y, addOne);      // Upshot<42, never>

As you can see in the above example, mapOk() takes a function that takes the unwrapped value, and returns a new value (+ 1). But what if the function returns a new Upshot? It works exactly the same. Upshots get automatically flattened, meaning that :

import { ok, mapOk } from "@mobile-club/upshot";

const x1 = mapOk(ok(42), () => 43);           // Upshot<never, 43>
const x2 = mapOk(ok(42), () => ok(43));       // Upshot<never, 43>
const x3 = mapOk(ok(42), () => ko(43));       // Upshot<43, never>

const x4 = mapOk(ok(42), async () => 43);     // Upshot<never, 43> | Promise<Upshot<never, 43>>
const x5 = mapOk(ok(42), async () => ko(43)); // Upshot<43, never> | Promise<Upshot<43, never>>

Chaining computations with mapOk will also merge error types :

import { Upshot, mapOk, pipe } from "@mobile-club/upshot";

declare const findUser: (id: number) => Promise<Upshot<"USER_NOT_FOUND", User>>;
declare const makeUserAdmin: (
  user: User
) => Promise<Upshot<"USER_ALREADY_ADMIN", AdminUser>>;

const result = await findUser(42).then(mapOk(makeUserAdmin)); // Upshot<"USER_NOT_FOUND" | "USER_ALREADY_ADMIN", AdminUser>

// or

const result = await pipe(findUser(42), mapOk(makeUserAdmin)); // Upshot<"USER_NOT_FOUND" | "USER_ALREADY_ADMIN", AdminUser>

// or

const user = await findUser(42);               // Upshot<"USER_NOT_FOUND", User>
const result = await mapOk(user, makeUserAdmin); // Upshot<"USER_NOT_FOUND" | "USER_ALREADY_ADMIN", AdminUser>

mapKo

Apply function to an upshot only if isKo and returns a new Upshot based on the function's return type. It returns identity otherwise.

import { ok, ko, mapOk } from "@mobile-club/upshot";

const addOne = (x) => x + 1;

const x = ok(42); // Upshot<never, 42>
const y = ko(42); // Upshot<42, never>

const x2 = mapKo(x, addOne); // Upshot<never, 42>
const y2 = mapKo(x, addOne); // Upshot<43, never>

Chaining computations with mapKo provides the capability to :

  • Override an error with another
const x1 = ko(42);                  // Upshot<42, never>
const x2 = mapKo(x1, () => 43);     // Upshot<43, never>
const x3 = mapKo(x1, () => ko(43)); // Upshot<43, never>
  • Recover from an error
const x1 = ko(42);                  // Upshot<42, never>
const x2 = mapKo(x1, () => ok(43)); // Upshot<never, 43>

fold

Matches against Upshot<E, R> and call the associated function with the unwrapped value.

  • If it's Ok<R> it will call the given ok function with value R as a parameter
  • If it's Ko<E> it will call the given ko function with value E as a parameter
import { fold, Upshot } from "@mobile-club/upshot";

declare const x: Upshot<"loose", "win">;

fold(x, {
  ok: (x) => `You ${x}`,
  ko: (x) => `You ${x}`,
}); // "You loose" | "You win"

getOrElse

Unwraps upshot. If it's an Ok<R>, it will return the underlying value. If it's a Ko<E>, it will return the default value

import { ok, ko, getOrElse } from "@mobile-club/upshot";

const x = getOrElse(ok(42), 43); // 42;
const y = getOrElse(ko(42), 43); // 43

option

Wraps an optional (null or undefined) value R into an Upshot<E, NonNullable<R>> where E is this error type for when R is null or undefined

import { option } from "@mobile-club/upshot";

declare const user: User | undefined | null;

const x1 = option(user, "Some Error");      // Upshot<"Some Error", User>;
const x2 = option("Some Error")(user);      // Upshot<"Some Error", User>;

const x3 = option(null, "Some Error");      // Upshot<"Some Error", never>;
const x4 = option(undefined, "Some Error"); // Upshot<"Some Error", never>;
const x5 = option(42, "Some Error");        // Upshot<"Some Error", 42>;

maybe

Shorthand for option(undefined) which wraps value R into an Upshot<Nothing, NonNullable<R>>.

This can be compared to the type Maybe<A> in other languages/libraries where Maybe<A> = A | Nothing

import { maybe } from "@mobile-club/upshot";

declare const user: User | undefined | null;

const x1 = maybe(user);      // Upshot<Nothing, User>;
const x2 = maybe(null);      // Upshot<Nothing, never>;
const x3 = maybe(undefined); // Upshot<Nothing, never>;
const x4 = maybe(42);        // Upshot<Nothing, 42>;

merge

Takes a list of AnyUpshot and merges it into a single Upshot. It will concat error and success types. If there's one Ko in the list, the resulting type will always be Ko.

signature

import { merge } from "@mobile-club/upshot";

merge([ok(45), ok(44), ko(43), ko(42)]); // Upshot<Array<42 | 43>, [44, 45]>
merge([ok(45), ok(44)]);                 // Upshot<never, [44, 45]>

all

Same as merge, but it will retain only the first Ko<E> instead of concatenating errors

import { all } from "@mobile-club/upshot";

all([ok(45), ok(44), ko(43), ko(42)]); // Upshot<42 | 43, [44, 45]>
all([ok(45), ok(44)]);                 // Upshot<never, [44, 45]>

unsafeValue

Unwraps an Upshot.

  • If it's Ok<R>, returns underlying value R
  • If it's Ko<E>, throws underlying value E
import { unsafeValue } from "@mobile-club/upshot";

unsafeValue(ok(42)); // 42
unsafeValue(ko(42)); // will throw 42

safe

Allows to write throwable code that is caught and mapped to an Upshot

import { safe } from "@mobile-club/upshot";

safe({
  try: () => JSON.parse("<"),
}); // Upshot<unkown, any>

safe({
  try: () => JSON.parse("<"),
  catch: (error /* unknown*/) => "PARSING_ERROR",
}); // Upshot<"PARSING_ERROR", any>

declare const findUser: () => User;

safe({
  try: findUser,
}); // Upshot<unkown, User>

safe({
  try: findUser,
  catch: (error /* unknown*/) => "FIND_USER_ERROR",
}); // Upshot<"FIND_USER_ERROR", User>

declare const findUserAsync: () => Promise<User>;

safe({
  try: findUserAsync,
}); // Promise<Upshot<unkown, User>>

safe({
  try: findUserAsync,
  catch: (error /* unknown*/) => "FIND_USER_ERROR",
}); // Promise<Upshot<"FIND_USER_ERROR", User>>

declare const findUserUpshot: () => Upshot<"USER_NOT_FOUND", User>;

safe({
  try: findUserUpshot,
}); // Promise<Upshot<unkown, User>>

safe({
  try: findUserUpshot,
  catch: (error /* unknown*/) => "UNKNOWN_ERROR",
}); // Promise<Upshot<"FIND_USER_ERROR" | "UNKNOWN_ERROR", User>>

As you can see, when catch is not provided, the resulting error type will always be unknown (Upshot<unknown, R>). It's because we cannot actually infer which type will be the error if an actual exception is thrown in the try.

Even for declare const findUserUpshot: () => Upshot<"USER_NOT_FOUND", User>, we know that it can return USER_NOT_FOUND, but if the method throws? then the error would be USER_NOT_FOUND | unknown, which typescript will infer narrow to unknown.

sequence

sequence provide an imperative style mechanism to work with Upshot.

It leverages generators in order to interrupt & early return when a Ko<E> is encountered during a computation.

import { sequence } from "@mobile-club/upshot";

declare const findUser: () => Upshot<"USER_NOT_FOUND", User>;
declare const makeUserAdmin: (
  user: User
) => Upshot<"USER_ALREADY_ADMIN", AdminUser>;

sequence(function* () {
  const user = yield* findUser();
  const admin = yield* makeUserAdmin(user);

  return admin;
}); // Upshot<"USER_NOT_FOUND" | "USER_ALREADY_ADMIN", AdminUser>

sequence also allows generators to be async

import { sequence } from "@mobile-club/upshot";

declare const findUser: () => Promise<Upshot<"USER_NOT_FOUND", User>>;
declare const makeUserAdmin: (
  user: User
) => Promise<Upshot<"USER_ALREADY_ADMIN", AdminUser>>;

sequence(async function* () {
  const user = yield* await findUser();
  const admin = yield* await makeUserAdmin(user);

  return admin;
}); // Promise<Upshot<"USER_NOT_FOUND" | "USER_ALREADY_ADMIN", AdminUser>>

The sequence is convenient if you prefer to reason in a more imperative style but is also very useful when you want to chain computation that depends on previous computations, and would like to return an aggregate of those computations.

Let's see an example.

Those 2 functions exist:

// Let's say we have these two functions
declare const findUser: (id: number) => Upshot<Error, User>;
declare const findUserPosts: (user: User) => Upshot<Error, Post[]>;

And we need to implement the following function:

type FindUserWithTotalUpvotes = (
  id: number
) => Upshot<Error, { user: User; totalUpvotes: number }>;

Naturally we would first think of chaining computation with mapOk :

import { pipe, mapOk } from "@mobile-club/upshot";

const findUserWithTotalUpvotes: FindUserWithTotalUpvotes = (id: number) => pipe(
  findUser(id),
  mapOk(findUserPosts),
  mapOk(posts => {
    // Problem here, we only have access to posts (Post[]) in the current scope.
    // We don't have access to the user (User)
    return {
      user: ???
      totalUpvotes: posts.mapOk(...)
    }
  })
)

findUserPosts depends on findUser because it needs a User, altought we need both User and Post[] to compute the return value.

We will need to do it in one mapOk :

import { pipe, mapOk, isKo } from "@mobile-club/upshot";

const findUserWithTotalUpvotes: FindUserWithTotalUpvotes = (id: number) => pipe(
  findUser(id),
  mapOk(user => {
    const posts = findUserPosts(user); // Upshot<Error, Post[]>;

    // We have to check if previous function was Ko and early return
    if (isKo(posts)) {
      return posts;
    }

    return {
      user,
      totalUpvotes: posts.value.map(...)
    }
  })
)

Instead, we can leverage sequence so that the code has a nicer flow.

import { sequence } from "@mobile-club/upshot";

const findUserWithTotalUpvotes: FindUserWithTotalUpvotes = (id: number) => sequence(function* () {
  const user = yield* findUser(id); // User
  const posts = yield* findUserPosts(user); // Post[]

  return {
    user,
    totalUpvotes: posts.map(...)
  }
})

⚠️ Generators cannot be defined as arrow functions, thus they have their own binding to this.

class Foo {
  method1 = () => "bar";

  method2 = () =>
    sequence(function* () {
      // ...
      this.method1(); // This won't work. The compiler will yield the error : "TS2683: 'this' implicitly has type 'any' because it does not have a type annotation."
    });
}

You need to explicitly pass this and type it properly in order to work.

class Foo {
  method1 = () => "bar";

  method2 = () =>
    sequence(function* (this: Foo) { // <-- annotate `this` type here
      // ...
      this.method1(); // ✅
    }, this); // <-- pass `this` value here.
}
0.3.0

6 months ago

0.2.3

7 months ago

0.2.2

7 months ago

0.2.1

8 months ago

0.2.0

9 months ago

0.1.0

9 months ago

0.0.0-development

9 months ago