@mobile-club/upshot v0.3.0
⚠️ 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 givenok
function with valueR
as a parameter - If it's
Ko<E>
it will call the givenko
function with valueE
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 valueR
- If it's
Ko<E>
, throws underlying valueE
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.
}