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.
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
taggedunion
. - 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 receivesTarget
and returnsPromise<Result>
Result
isEither<Errors, Connection>
Errors
isLeftOfRet<typeof connect> | LeftOfRet<typeof pingPong>
, or in other words, "errors ofconnect
" and "errors ofpingPong
".
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