Skip to content

Result is a JS built-in 🀯 ​

My weird epiphany ​

I had the weirdest epiphany this night: Result is a JS built-in, it is just disguised as a Promise.

If I rephrase what a Result is in Promise terms, my function will promise that my computation went well (fulfilled) or reject otherwise. Fulfilled = Success and Rejected = Failure. The only issue we have with Promises is the lack of error typing. That can be overcome.

The best part: it fixes the coloring problem of async functions (see below). Well kind-of, coloring is now about Result, yet I prefer that rather than promises.

How I re-typed Promise into Result

The JS file:

js
export class Result extends Promise {}

The .d.ts file:

ts
export declare const Result: ResultConstructor;

interface ResultConstructor {
  /**
     * A reference to the prototype.
     */
    readonly prototype: Result<unknown, unknown>;

    /**
     * Creates a new Promise.
     * @param executor A callback used to initialize the promise. This callback is passed two arguments:
     * a resolve callback used to resolve the promise with a value or the result of another promise,
     * and a reject callback used to reject the promise with a provided reason or error.
     */
    new <Reason, Value>(executor: (resolve: (value: Value | Result<Reason, Value>) => void, reject: (reason: Reason) => void) => void): Result<Reason, Value>;

    /**
     * Creates a Promise that is resolved with an array of results when all of the provided Promises
     * resolve, or rejected when any Promise is rejected.
     * @param values An array of Promises.
     * @returns A new Promise.
     */
    all<Results extends readonly unknown[] | []>(values: Results): Result<
      { -readonly [P in keyof Results]: ReasonOf<Results[P]>; }[keyof Results],
      { -readonly [P in keyof Results]: Awaited<Results[P]>; }
    >;

    // see: lib.es2015.iterable.d.ts
    all<Reason, Value>(values: Iterable<Value | PromiseLike<Value> | Result<Reason, Value>>): Promise<
      Result<Reason, Awaited<Value>[]>
    >;

    /**
     * Creates a Promise that is resolved with an array of results when all
     * of the provided Promises resolve or reject.
     * @param values An array of Promises.
     * @returns A new Promise.
     */
    allSettled<Results extends readonly unknown[] | []>(values: Results): Result<never, { -readonly [P in keyof Results]: SettledResult<ReasonOf<Results[P]>, Awaited<Results[P]>>; }>;

    /**
     * Creates a Promise that is resolved or rejected when any of the provided Promises are resolved
     * or rejected.
     * @param values An array of Promises.
     * @returns A new Promise.
     */
    race<T extends readonly unknown[] | []>(values: T): Result<ReasonOf<T[number]>, Awaited<T[number]>>;

    // see: lib.es2015.iterable.d.ts
    // race<T>(values: Iterable<T | PromiseLike<T>>): Promise<Awaited<T>>;

    /**
     * Creates a new rejected promise for the provided reason.
     * @param reason The reason the promise was rejected.
     * @returns A new rejected Promise.
     */
    reject(): Result<void, never>;
    reject<Reason>(reason: Reason): Result<Reason, never>;

    /**
     * Creates a new resolved promise.
     * @returns A resolved promise.
     */
    resolve(): Promise<void>;
    /**
     * Creates a new resolved promise for the provided value.
     * @param value A promise.
     * @returns A promise whose internal state matches the provided promise.
     */
    resolve<T>(value: T): Result<never, Awaited<T>>;
    /**
     * Creates a new resolved promise for the provided value.
     * @param value A promise.
     * @returns A promise whose internal state matches the provided promise.
     */
    resolve<T>(value: T | PromiseLike<T>): Result<never, Awaited<T>>;
}

export type ReasonOf<T> = T extends null | undefined
  ? T
  : // special case for `null | undefined` when not in `--strictNullChecks` mode
  T extends object & { then(onfulfilled: any, onrejected: infer F): any; }
  ? // `await` only unwraps object types with a callable `then`. Non-object types are not unwrapped
  F extends ((value: infer V, ...args: infer _) => any)
  ? // if the argument to `then` is callable, extracts the first argument
    ReasonOf<V>
  : // recursively unwrap the value
    never
  : // the argument to `then` was not callable
    T; // non-object or non-thenable

export interface FulfilledResult<Value> {
    status: "fulfilled";
    value: Value;
}

export interface RejectedResult<Reason> {
    status: "rejected";
    reason: Reason;
}

export type SettledResult<Reason, Value> = FulfilledResult<Value> | RejectedResult<Reason>;
    
export interface Result<Reason, Value> extends Promise<Value> {
    /**
     * Attaches callbacks for the resolution and/or rejection of the Promise.
     * @param onfulfilled The callback to execute when the Result is resolved.
     * @param onrejected The callback to execute when the Result is rejected.
     * @returns A Result for the completion of which ever callback is executed.
     */
    then<Value1 = Value, Reason1 = never, Value2 = never, Reason2 = Reason>(
      onfulfilled?: ((value: Value) => Value1 | PromiseLike<Value1> | Result<Reason1, Value1>) | undefined | null,
      onrejected?: ((reason: Reason) => Value2 | PromiseLike<Value2> | Result<Reason2, Value2>) | undefined | null
    ): Result<Reason1 | Reason2, Value1 | Value2>;

    /**
     * Attaches a callback for only the rejection of the Promise.
     * @param onrejected The callback to execute when the Promise is rejected.
     * @returns A Promise for the completion of the callback.
     */
    catch<Value1 = never, Reason1 = never>(onrejected?: ((reason: Reason) => Value1 | PromiseLike<Value1> | Result<Reason1, Value1>) | undefined | null): Result<Reason1, Value | Value1>;
}

Demo time ​

Let’s a make a comparison of Promise vs typed-Promise (AKA Result):

ts
const promise = new Promise<number>((resolve, reject) => {
  const number = randomNumberBetween(0, 100)
  number <= 50 ? resolve(number) : reject('too_low')
})
promise.then(
  (value) => {}, // value: number βœ…
  (reason) => {}, // reason: any ❌
)
promise.catch((reason) => {}) // reason: any ❌

Now with typed-Promise (Result):

ts
const result = new Result<'too_low', number>((resolve, reject) => {
  const number = randomNumberBetween(0, 100)
  number <= 50 ? resolve(number) : reject('too_low')

  reject('too_big') // not allowed by TS, doesn't match type `'too_low'`
})

result.then(
  (value) => {}, // value: number βœ…
  (reason) => {}, // reason: 'too_low' βœ…
)
result.catch((reason) => {}) // `reason` is inferred as `'too_low'` βœ…

TIP

Other Promise static & instance methods can be re-typed, it works like a charm.

I published this experimentation as an NPM package: outputs-not-outcomes

Real-world use-case: registration ​

Picture a register workflow, we will:

  1. Check that the input is valid – synchronous result.
  2. Check that no account exists with this email – asynchronous result.
  3. Create the account – asynchronous result. Steps 1) and 2) can be achieved in parallel or sequence, it influences nothing.

If I write this workflow naively with throws:

ts
import {
  validatePassword,
  assertNoAccountExistsForEmail,
  createAccount,
} from 'anywhere'

function register(email: string, password: string) {
  validatePassword(password) // throws if password is invalid

  return assertNoAccountExistsWithEmail(email) // throws if account exists
    .then(() => createAccount(email, password))
}

Now, let’s picture a world where we can use Result instead of Promise:

ts
declare function validatePassword(
  password: string,
): Result<InvalidPassword, void>

declare function assertNoAccountExistsWithEmail(
  email: string,
): Result<DbError | AccountExistsWithEmail, void>

declare function createAccount(
  email: string,
  password: string,
): Result<DbError, Account>

Then the final code would look like this:

ts
import {
  validatePassword,
  assertNoAccountExistsForEmail,
  createAccount,
} from 'anywhere'

// v1, with step 1) and 2) performed sequentially:
function register(email: string, password: string) {
  return validatePassword(password)
    .then(() => assertNoAccountExistsWithEmail(email))
    .then(() => createAccount(email, password))
  // return type is:
  // Result<
  //   InvalidPassword | AccountExistsWithEmail | DbError,
  //   Account
  // >
}

And this is all just native JS code, I only changed the types here:

ts
const result = register('toto@example.com', 'holy molly')

result
  .then((account) => {}) // account: Account
  .catch((error) => {}) // error: InvalidPassword | AccountExistsWithEmail | DbError

For instance, we can then use it in an API handler:

ts
app.post('/register', (request) => {
  const { email, password } = request.body
  return register(email, password)
    .then((account) => {
      return { status: 201, body: { account } }
    })
    .catch((error) => {
      if (error instanceof InvalidPassword)
        return { status: 400, body: { error } }

      if (error instanceof AccountExistsWithEmail)
        return { status: 403, body: { message: 'account already exists' } }

      // all other cases: here only DbError
      return { status: 500, body: { error } }
    })
})

How cool is that ??

Caveats & Limitations ​

Capturing runtime errors in the type system ​

The only limitations I see so far are runtime errors. How to deal with that:

ts
const result = new Result((resolve, reject) => {
  resolve(a.b) // a is not defined, a.b results in a TypeError which cannot be capture in the type system.
})

The only idea I had so far was to capture those unexpected errors and wrap them in a RuntimeError cause, that way Result can be typed properly again:

ts
new Result<'too_low', number>((resolve) => {
  // …
  resolve(1)
})
// Result<RuntimeError | 'too_low', number>
// Result is added the error `RuntimeError` because a runtime error can slip in!

Result.resolve(1) // Result<never, number>, here we are sure there cannot be any runtime error.
  .then(…) // Result<RuntimeError, …>, here we add the `RuntimeError` because the .then onfulfilled callback may contain a runtime error.

NOTE

There always is the risk of a residual unknown error that cannot be typed nor caught into RuntimeError.

If anyone has an idea about how to deal with that, please reach out (you can create an issue)

Capturing thrown errors in the type system ​

There is no working around this. It requires discipline from developers to never throw any error they want to see typed, and always favor Result.reject(myError) over throw.