# Upshot ☯
npm install @mobile-club/upshot> Upshot - _noun_ / the final or eventual outcome or conclusion of a discussion, action, or series of events.
When consuming APIs such as JSON.parse: any or findUser(): Promise, 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 data type (also known as Either or Result in other systems) and is defined as the following :
``typescript`
type Upshot
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)
⚠️ Package is not published yet
`bash`
yarn add @mobile-club/upshot
`bash`
yarn test
Wraps a value T into an Upshot
`typescript
import { ok } from "@mobile-club/upshot";
const x = ok(42); // Upshot
`
Wraps a value E into an Upshot
`typescript
import { ko } from "@mobile-club/upshot";
const x = ko(42); // Upshot<42, never>
`
Checks if an Upshot can be narrowed to Ok
`typescript
import { ok } from "@mobile-club/upshot";
const x = ok(42); // Upshot
if (isOk(x)) {
x; // Ok<42>
x.value; // 42
}
`
Checks if an Upshot can be narrowed to Ko
`typescript
import { ko } from "@mobile-club/upshot";
const x = ko(42); // Upshot<42, never>
if (isOk(x)) {
x; // Ko<42>
x.value; // 42
}
`
Checks if a value is an AnyUpshot
`typescript
import { isUpshot } from "@mobile-club/upshot";
const x = // ...;
if (isUpshot(x)) {
x // AnyUpshot
x.value // any
}
`
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).
`typescript
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) :
`typescript
// 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 :
`typescript
// 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>
`
Apply function to an upshot only if isOk and returns a new Upshot based on the function's return type. It returns
identity otherwise.
`typescript
import { ok, ko, mapOk } from "@mobile-club/upshot";
const addOne = (x) => x + 1;
const addOneAsync = async (x) => x + 1;
const x = ok(42); // Upshot
const x2 = mapOk(x, addOne); // Upshot
const x3 = mapOk(x, addOneAsync); // Upshot
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 (+Upshot
1). But what if the function returns a new ? It works exactly the same. Upshots get automatically flattened,
meaning that :
`typescript
import { ok, mapOk } from "@mobile-club/upshot";
const x1 = mapOk(ok(42), () => 43); // Upshot
const x2 = mapOk(ok(42), () => ok(43)); // Upshot
const x3 = mapOk(ok(42), () => ko(43)); // Upshot<43, never>
const x4 = mapOk(ok(42), async () => 43); // Upshot
const x5 = mapOk(ok(42), async () => ko(43)); // Upshot<43, never> | Promise
`
Chaining computations with mapOk will also merge error types :
`typescript
import { Upshot, mapOk, pipe } from "@mobile-club/upshot";
declare const findUser: (id: number) => Promise
declare const makeUserAdmin: (
user: User
) => Promise
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>
`
Apply function to an upshot only if isKo and returns a new Upshot based on the function's return type. Itidentity
returns otherwise.
`typescript
import { ok, ko, mapOk } from "@mobile-club/upshot";
const addOne = (x) => x + 1;
const x = ok(42); // Upshot
const y = ko(42); // Upshot<42, never>
const x2 = mapKo(x, addOne); // Upshot
const y2 = mapKo(x, addOne); // Upshot<43, never>
`
Chaining computations with mapKo provides the capability to :
- Override an error with another
`typescript`
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
`typescript`
const x1 = ko(42); // Upshot<42, never>
const x2 = mapKo(x1, () => ok(43)); // Upshot
Matches against Upshot and call the associated function with the unwrapped value.
- If it's Ok it will call the given ok function with value R as a parameterKo
- If it's it will call the given ko function with value E as a parameter
`typescript
import { fold, Upshot } from "@mobile-club/upshot";
declare const x: Upshot<"loose", "win">;
fold(x, {
ok: (x) => You ${x},You ${x}
ko: (x) => ,`
}); // "You loose" | "You win"
Matches against Upshot and call the associated function with the unwrapped value. Unlike fold the returned value
is ignored. A good use case is typically logging.
`typescript
import { tap, Upshot } from "@mobile-club/upshot";
declare const x: Upshot<"loose", "win">;
tap(x, {
ok: (x) => console.log(You ${x}),You ${x}
ko: (x) => console.log(),
}); // Upshot<"loose", "win">
// Will print either "You loose" or "You win". Upshot remains unchanged.
`
The code above is equivalent to
`typescript
import { ok, fold, Upshot } from "@mobile-club/upshot";
declare const x: Upshot<"loose", "win">;
fold(x, {
ok: (x) => {
console.log(You ${x});You ${x}
return ok(x)
},
ko: (x) => {
console.log();`
return ko(x);
},
});
Unwraps upshot. If it's an Ok, it will return the underlying value. If it's a Ko, it will return the default
value
`typescript
import { ok, ko, getOrElse } from "@mobile-club/upshot";
const x = getOrElse(ok(42), 43); // 42;
const y = getOrElse(ko(42), 43); // 43
`
Wraps an optional (null or undefined) value R into an Upshot where E is this error type for when R is null or undefined
`typescript
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>;
`
Shorthand for option(undefined) which wraps value R into an Upshot.
This can be compared to the type Maybe in other languages/libraries where Maybe = A | Nothing
`typescript
import { maybe } from "@mobile-club/upshot";
declare const user: User | undefined | null;
const x1 = maybe(user); // Upshot
const x2 = maybe(null); // Upshot
const x3 = maybe(undefined); // Upshot
const x4 = maybe(42); // Upshot
`
Takes a list of AnyUpshot and merges it into a single Upshot. It will concat error and success types.Ko
If there's one in the list, the resulting type will always be Ko.
#### signature
`typescript
import { merge } from "@mobile-club/upshot";
merge([ok(45), ok(44), ko(43), ko(42)]); // Upshot
merge([ok(45), ok(44)]); // Upshot
`
Same as merge, but it will retain only the first Ko instead of concatenating errors
`typescript
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
`
Unwraps an Upshot.
- If it's Ok, returns underlying value RKo
- If it's , throws underlying value E
`typescript
import { unsafeValue } from "@mobile-club/upshot";
unsafeValue(ok(42)); // 42
unsafeValue(ko(42)); // will throw 42
`
Allows to write throwable code that is caught and mapped to an Upshot
`typescript
import { safe } from "@mobile-club/upshot";
safe({
try: () => JSON.parse("<"),
}); // Upshot
safe({
try: () => JSON.parse("<"),
catch: (error / unknown/) => "PARSING_ERROR",
}); // Upshot<"PARSING_ERROR", any>
declare const findUser: () => User;
safe({
try: findUser,
}); // Upshot
safe({
try: findUser,
catch: (error / unknown/) => "FIND_USER_ERROR",
}); // Upshot<"FIND_USER_ERROR", User>
declare const findUserAsync: () => Promise
safe({
try: findUserAsync,
}); // Promise
safe({
try: findUserAsync,
catch: (error / unknown/) => "FIND_USER_ERROR",
}); // Promise
declare const findUserUpshot: () => Upshot<"USER_NOT_FOUND", User>;
safe({
try: findUserUpshot,
}); // Promise
safe({
try: findUserUpshot,
catch: (error / unknown/) => "UNKNOWN_ERROR",
}); // Promise
`
As you can see, when catch is not provided, the resulting error type will always be unknown (Upshot). 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 provide an imperative style mechanism to work with Upshot.
It leverages generators in order to interrupt & early return when a Ko is encountered during a computation.
`typescript
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
`typescript
import { sequence } from "@mobile-club/upshot";
declare const findUser: () => Promise
declare const makeUserAdmin: (
user: User
) => Promise
sequence(async function* () {
const user = yield* await findUser();
const admin = yield* await makeUserAdmin(user);
return admin;
}); // Promise
`
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:
`typescript`
// Let's say we have these two functions
declare const findUser: (id: number) => Upshot
declare const findUserPosts: (user: User) => Upshot
And we need to implement the following function:
`typescript`
type FindUserWithTotalUpvotes = (
id: number
) => Upshot
Naturally we would first think of chaining computation with mapOk :
`typescript
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 :
`typescript
import { pipe, mapOk, isKo } from "@mobile-club/upshot";
const findUserWithTotalUpvotes: FindUserWithTotalUpvotes = (id: number) => pipe(
findUser(id),
mapOk(user => {
const posts = findUserPosts(user); // Upshot
// 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.
`typescript
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.
`typescript
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.
`typescript
class Foo {
method1 = () => "bar";
method2 = () =>
sequence(function* (this: Foo) { // <-- annotate this type herethis
// ...
this.method1(); // ✅
}, this); // <-- pass value here.``
}