This article is a part of the systemic-ts series.
complication
systemic

SystemicTS: Known vs Unknown Errors, The Throwless Pact

This is only the solution's first sentence and yet I decree probably one of the most radical and controversial approaches in programming: get rid of throw.

"How can you communicate errors then?" you might say.

The answer: return and union!

But before that, take a detour into the philosophical consideration of this approach.

Known vs Unknown Errors

Unknown errors are, literally, unknown. Unknown's core essence involves you not knowing if it exists. If it exists at all, you have not identify what, how, when, or where it will happen.

Happening upon a previously unknown error is mystifying and often stressful. Understanding an error is dialectic, but it is only powerful if the knowledge is preserved so that someone in the future benefits from it.

Known errors are errors that you have encountered and those you have decided to not forget. An error becomes truly known when it is signified (e.g. by being written as code).

It also makes no sense to blur a known error, particularly of when it happens. Therefore, throwing it is not a choice because it blurs the timing aspect of an error.

Managing knowledge of errors and communicating it are two entangled problems. Therefore, I present a solution that solves both: The Throwless Pact.

The Throwless Pact

The pact's core tenets are:

  • Known errors within the codebase are well-signified as a uniquely identified type.
  • Known errors and successes are communicated and signified as a returned tagged union.
  • Thrown errors from outside the codebase (e.g. those thrown by third-party dependencies) are caught and converted into a union as early as possible.
  • Only unknown errors may be thrown, convert them once identified

What we want from this is, first, transparent and immediate information about a function's behavior in the form of signature. One that explies all result variants and implies all possibilities within.

With all possibilities implied, you will need less to look into the function's implementation detail. This very effectively combats the try-catch anxiety when throws exist in a codebase. As a result, the whole development process needs less cognitive burden so there is cognitive space for thinking at a higher-level.

Of course, the direct impact of not having throw is the robust and reliable appearance. In a React application, for example, throw triggers a crash. Even if caught with an ErrorBoundary it does not beat the elegant facade of an application that does not throw.

Overall, you will be able to trust your code more.

The Implementation

Now we will see how The Throwless Pact efficiently implements a growing requirement. We will compare the added values and also the extra price we have to pay (e.g. line of code, bootstrap code) for going against the grain. Usually, the bigger the codebase, the more the added values justify the extra price.

In this section, I will use my preferred tools/libraries. However, it should not prevent you from using other tools that achieve a similar purpose.

Return Communication

For result variants to be return-ed and interpreted unambiguously, we need a tagged union. My favorite tool is fp-ts. The Either data structure can represent two mutually exclusive states Left and Right. As a convention, we will use Left for failures and Right for successes.

Let us dive into the example.

You must write a function that connects to a target, ping-pong (ping and then let the recipient ping back) the target using the connection ten times, and then return the connection to the call site.

import * as E from "fp-ts/Either";
import { pingPong, connect, Target } from "./some-connection-module";

type Errors = E.LeftOfRet<typeof connect> | E.LeftOfRet<typeof pingPong>;

// Returns either the accumulated error type of `connect` and `pingPong`
// or the success type which is `Connection`
type Result = E.Either<Errors, Connection>;

const connectAndWaitForTenPings = async (target: Target): Promise<Result> => {
  const connectRes = await connect(); // Return Either<ConnectionsError, Connections>
  if (E.isLeft(connectRes)) return connectRes; // connection failure, return
  const connection = connectRes.right;

  // ping pong 10 times
  let count = 0;
  while (count < 10) {
    const res = await pingPong(connection);
    if (E.isLeft(res)) return res; // pingPong failure, return
    count += 1;
  }

  return E.right(connection); // success, return
};

// type utilities, should be put in a common module

type LeftOf<T> = T extends E.Left<infer x> ? x : never;
type LeftOfRet<T extends Function> = LeftOf<ReturnType<T>>;

From the code:

  • connectAndWaitForTenPings is a function that receives Target and returns Promise<Result>
  • Result is Either<Errors, Connection>
  • Errors is LeftOfRet<typeof connect> | LeftOfRet<typeof pingPong>, or in other words, "errors of connect" and "errors of pingPong".

In natural language, connectAndWaitForTenPings is a function that receives a Target and returns Either a Connection if successful or the errors of either connect or pingPong if failing.

The type signature and the natural language shares a lot of similarity!

We can also be sure that there will be no unaccounted possibilities except unknown errors, which we know we can't predict in any way.

Error Signification

If you don't need serialization, Error. Otherwise, for inter-process errors, I prefer a custom error factory utilizing io-ts.

The factory is simple and does only three things: 1.) make an error instance, 2.) expose codec and sign for pattern matching.

// file: s-error.ts
import * as t from "io-ts";

type SignedError<T extends string, P extends unknown> = {
  sign: T;
  data: P;
  stack: string | undefined;
};
type Factory<T extends string, P extends unknown> = {
  readonly sign: T;
  readonly codec: t.Type<SignedError<T, P>>;
  readonly make: (p: P) => SignedError<T, P>;
};

const Stack = t.union([t.string, t.undefined]);

export const design = <T extends string, P extends unknown>(
  sign: T,
  Payload: t.Type<P>,
): Factory<T, P> => {
  const codec = t.type({ sign: t.literal(sign), data: Payload, stack: Stack });

  const make = (data: P): SignedError<T, P> => {
    const error = new Error();
    error.name = sign;
    const stack = error.stack;
    return { sign, data, stack };
  };

  return { sign, codec, make };
};

export type TypeOf<F extends Factory<any, any>> = F extends Factory<
  infer T,
  infer P
>
  ? SignedError<T, P>
  : never;

With the factory, we can create many error "classes".

import { design, TypeOf } from "path/to/serror.ts";

const ConnectionError = design("ConnectionError", t.null);
type ConnectionError = TypeOf<typeof ConnectionError>;

const PingPongError = design("PingPongError", t.null);
type PingPongError = TypeOf<typeof PingPongError>;

With these errors "classes" you can now do several things:

First, obviously, you can create an error:

ConnectionError.make(null); // null maps to the type we inputted in the design phase

Second, on a union of errors, you can do pattern matching:

const error: ConnectionError | PingPongError = receiveSomeError();
if (error.sign === ConnectionError.sign) {
  // error type is narrowed into ConnectionError | PingPongError
}

Third, on an unknown data, the factory codec can be used to determine its structure.

const maybeError: unknown = receiveExternalError();
if (ConnectionError.codec.is(maybeError)) {
  // maybeError is narrowed into ConnectionError
}

Fourth, codec combinations can narrow variables into a wider type of errors.

const ConnectionOrPingPongError = t.union([
  ConnectionError,
  PingPongError
])

const maybeError: unknown = receiveExternalError();

if (ConnectionOrPingPongError.is(maybeError)) {
  // maybeError is narrowed into ConnectionError | PingPongError
}

TypeScript and io-ts do the heavy lifting of structure validation and narrowing for all these features. Additionally, IDEs and editors that supports TypeScript will provide useful type hints for variables inside these if blocks.

Applying The Arsenal

Let us assume, in the first example, that connectAndWaitForTenPings forwards ConnectionError and PingPongError from connect and pingPong respectively.

Now, let there be a new requirement. We must write a function that connects and waits for ten pings. But if connect fails, the program must retry several times before failing.

For the retry mechanism, a while and an attempt counter suffice. To detect errors, we can use the explicitly returned error type. We know that one possibility from connectAndWaitForTenPings is Left<ConnectionError>. We can target that possibility specifically from outside the function.

const connectWithRetry = async (
  target: Target
): ReturnType<typeof connectAndWaitForTenPings> => {
  let attempt = 0;
  while (true) {
    const res = await connectAndWaitForTenPings(target);
    if (
      attempt < 3 &&
      E.isLeft(res) &&
      res.left.sign === ConnectionError.sign
    ) {
      attempt += 1;
      continue;
    }
    return res;
  }
};

You can almost hear what the code says: "Keep repeating, if ConnectionError and attempt is not 3, otherwise return whatever result you receive."

The narrative and self-documenting feel of the snippet will justify the little noise---e.g. res.left.sign, ConnectionError.sign, E.isLeft(res)---that is caused by how Either and our error is structured.

Exception

Update 2023-10-16

See the acceptable throw section in this article

complication
systemic