Skip to content

Instantly share code, notes, and snippets.

@isthatcentered
Created October 27, 2022 07:39
Show Gist options
  • Save isthatcentered/1429f7220f876592db99da56e0714ff6 to your computer and use it in GitHub Desktop.
Save isthatcentered/1429f7220f876592db99da56e0714ff6 to your computer and use it in GitHub Desktop.
Typescript Task/Enhanced promise
class ExpectedError<E> {
readonly _tag = 'ExpectedError';
constructor(public readonly error: E) {}
}
class Die {
readonly _tag = 'Die';
constructor(public readonly reason: string, public readonly data: any) {}
}
/**
* Represents the possible causes of failure for an action
*
* Either an `ExpectedError` that can be handled/communicated/recovered from
* or an unexpected `Die` that signifies no more action should be taken,
* the task is dead and cannot be recovered.
*/
export type Cause<E> = ExpectedError<E> | Die;
// -------------------------------------------------------------------------------------
// Constructors
// -------------------------------------------------------------------------------------
export const failure = <E>(error: E): Cause<E> => new ExpectedError(error);
export const die = (debug: string, data: any): Cause<never> => new Die(debug, data);
// -------------------------------------------------------------------------------------
// Refinements
// -------------------------------------------------------------------------------------
export const isExpectedFailure = <E>(cause: Cause<E>): cause is ExpectedError<E> => cause._tag === 'ExpectedError';
export const isDie = <E>(cause: Cause<E>): cause is Die => cause._tag === 'Die';
class Success<A> {
readonly _tag = 'Success';
constructor(public readonly value: A) {}
}
class Failure<E> {
readonly _tag = 'Failure';
constructor(public readonly error: E) {}
}
/**
* Represents the result of an action
* Did it `Succeed` with a value of type `A`
* or fail with a `Failure` of type `E`
*
* Allows any possible error to be visible and handled
* in a type-safe way (as opposed to throwing)
*/
export type Exit<E, A> = Success<A> | Failure<E>;
// -------------------------------------------------------------------------------------
// Constructors
// -------------------------------------------------------------------------------------
export const succeed = <A>(value: A): Exit<never, A> => new Success(value);
export const fail = <E>(error: E): Exit<E, never> => new Failure(error);
// -------------------------------------------------------------------------------------
// Refinements
// -------------------------------------------------------------------------------------
export const isSuccess = <E, A>(exit: Exit<E, A>): exit is Success<A> => exit._tag === 'Success';
export const isFailure = <E, A>(exit: Exit<E, A>): exit is Failure<E> => exit._tag === 'Failure';
class Some<E> {
readonly _tag = 'Some';
constructor(public readonly value: A) {}
}
class None {
readonly _tag = 'None';
}
/**
* Represents the absence (`None`thing) or presence (`Some`thing) of a value
* Especially useful where `undefined` or `null` would be a valid value
*/
export type Option<A> = Some<A> | None;
// -------------------------------------------------------------------------------------
// Constructors
// -------------------------------------------------------------------------------------
export const some = <A>(value: A): Option<A> => new Some(value);
export const none = new None();
// -------------------------------------------------------------------------------------
// Refinements
// -------------------------------------------------------------------------------------
export const isSome = <A>(option: Option<A>): option is Some<A> => option._tag === 'Some';
export const isNone = <A>(option: Option<A>): option is None => option._tag === 'None';
import * as Bluebird from 'bluebird';
import * as Option from './Option';
import * as Exit from './Exit';
import * as Cause from './Cause';
Bluebird.config({ cancellation: true });
type Result<E, A> = Exit.Exit<Cause.Cause<E>, A>;
/*
* An enhanced Promise that can either
* - Succeed with a value `A`
* - Fail with an expected error `E`
* - Die with an unexpected error
*
* The ability to cancel the Task ensures any delayed/timeout action
* will never execute if it has been discarded
*/
export class Task<E, A> {
constructor(private readonly run: () => Bluebird<Result<E, A>>) {}
/**
* Transform the value of a Task
*/
map<B>(transform: (value: A) => B): Task<E, B> {
return this.then((value) => Task.resolve(transform(value)));
}
/**
* Matches Promise.then
*
* Run a `Task` after the current one succeeds
*/
then<E2, B>(next: (a: A) => Task<E2, B>): Task<E | E2, B> {
return new Task<E | E2, B>(() =>
// We run the current Task
this.run()
// Then check Its result result/exit
.then((exit) => {
// If it failed, abort the next acction
if (!Exit.isSuccess(exit)) return Task.reject(exit.error).run();
// If it succeeded, then we can trigger the next action
return (next(exit.value) as Task<E | E2, B>).run();
}),
);
}
/**
* Matches Promise.catch
*
* Recover the current `Task` if it fails
*/
catch<E2, B>(recover: (e: E) => Task<E2, B>): Task<E2, A | B> {
return new Task<E2, A | B>(() =>
// Run the current Task
this.run()
// Then check its result/exit
.then((exit) => {
// The Task suceeded, all good, nothing to recover from
if (Exit.isSuccess(exit)) return Task.resolve(exit.value).run();
// The Task failed with an unexpected/untyped error.
// We can't do anything about it, just pass it on
if (!Cause.isExpectedFailure(exit.error)) return Task.reject<never>(exit.error).run();
// The Task failed with an expected/recoverable error
// handle it
return (recover(exit.error.error) as Task<E2, A | B>).run();
}),
);
}
/**
* Delay the execution of a Task by x milliseconds
*/
delay(delayMs: number): Task<E, A> {
// Bluebird is not lazy, if we don't wait THEN trigger the task
// it will be triggered immediately (only the result will be delayed)
return Task.wait(delayMs).then(() => this);
}
/**
* Repeat a Task until it fails or its value is accepted
*/
repeatUntil<B>(accept: (a: A, retryCount: number) => Option.Option<B>, delayMs: number): Task<E, B> {
const loop = (retryCount: number): Task<E, B> => {
const delay = retryCount > 0 ? delayMs : 0; // The first try should be ran immediately
return this.delay(delay)
.map((result) => accept(result, retryCount))
.then((maybeResult) => {
// The result is empty
// AKA it hasn't been accepted and we should retry
if (!Option.isSome(maybeResult)) return loop(retryCount + 1);
// The result contains something
// AKA it has been accepted, we can complete
return Task.resolve(maybeResult.a);
});
};
return loop(0);
}
/**
* Complete with the first completed Task
* and cancel the other one
*/
race<E2, B>(second: Task<E2, B>): Task<E | E2, A | B> {
return Task.make((res, registerCancel) => {
const firstPromise = this.run().then((exit) => {
secondPromise.cancel();
res(exit);
});
const secondPromise = second.run().then((exit) => {
firstPromise.cancel();
res(exit);
});
registerCancel(() => {
firstPromise.cancel();
secondPromise.cancel();
});
});
}
timeout<E2, B>(delayMs: number, onTimeout: () => Task<E2, B>): Task<E | E2, A | B> {
const timeout = Task.wait(delayMs).then(onTimeout);
return this.race(timeout);
}
static make<E, A>(
callback: (
resolve: (exit: Exit.Exit<Cause.Cause<E>, A>) => void,
registerOnCancel: (callback: () => void) => void,
) => void,
): Task<E, A> {
return new Task<E, A>(() =>
new Bluebird<Exit.Exit<Cause.Cause<E>, A>>((res, _reject, onCancel) => callback(res, onCancel!))
// Don't let uncaught exceptions bubble ip and kill important actions (ex: rollback)
.catch((e) => Exit.fail(Cause.die('Uncaught exception', e))),
);
}
/**
* Creates a Task that succeeds immediately with a value
*/
static resolve<A>(value: A): Task<never, A> {
return new Task(() => Bluebird.resolve(Exit.succeed(value)));
}
/**
* Creates a Task that fails immediately with a cause
*/
static reject<E>(cause: Cause.Cause<E>): Task<E, never> {
return new Task(() => Bluebird.resolve(Exit.fail(cause)));
}
/**
* Creates a Task that succeeds after x milliseconds
*/
static wait(ms: number): Task<never, any> {
return new Task(() => Bluebird.resolve(Exit.succeed(undefined)).delay(ms));
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment