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

Source of Complication: Throw

When you meet an unhappy path, simply throw.

if (connectionFailed) 
  throw new Error("cannot connect to server");
// do some business
if (httpStatusCode < 200 || httpStatusCode >= 300)
  throw new Error("result is bad");
// do some business
if (parseResult.failed()) 
  throw new Error("parse failed");
if (parseResult.value === "operation-needs-to-be-cancelled")
  throw new Error("operation-needs-to-be-cancelled")
// do some business

It is convenient! Your function stops and the problem now belongs to whoever is calling it.

But will this function throw? Should I wrap this in a try-catch?

unfamiliarFunction();

// or:

try {
  unfamiliarFunction();
} catch (e) {}

Unless YOU will call it yourself, then it is technically a debt because you put away the responsibility of error handling for the future you to handle.

From the caller's site, a throw is much harder to anticipate because the function signature does not have any marker indicating whether it will throw or not

Catch is Untyped

try {
  someThrowingFunction();
} catch (error) { /* error is of type `unknown` */ }

TypeScript won't know the caught object's type. It can be anything. It can be an undefined, Symbol, BigInt, or random class instance.

Errors can be handled this way:

let attempt = 0;
while (attempt < 3) {
  attempt+=1;
  try { ... }
  catch (error) {
    if (error.message !== "cannot connect to servr") {
      await sleepForAwhile();
    }
    if (error.message !== "operation-needs-to-be-cancelled") {
      await rollback();
      break;
    }
    throw error
  }
}

Although, first we have a typo there "cannot connect to servr". Due to how try-catch is designed, TypeScript does not assist with type analysis of the error, thus accidents such as a typo often happen.

What if someone throws an 'undefined' somewhere?

Second, the catch block must be impervious to accidental throws. Otherwise, you need another try-catch block to wrap it. If error is somehow undefined or null then accessing a property of error will throw a TypeError.

A thrown entity is not your problem until it becomes one.

Implementation Detail

The uncertainty of whether a function throws induces some anxieties:

  • will this function throw?
  • what error type will this function throw?
  • will my catch block throw?
try {
} catch (error) {
  try {
    /* handle error here */
  } catch (error) {
    /* just in any case... */
  }
}

You want to avoid writing the above ridiculous pattern. For that, you need an answer to the three questions above. You peek into the implementation details of the function you call inside the try-block.

The problem with HAVING TO KNOW the implementation detail is that it is a waste of time. A separate function is often written as an abstraction (of knowledge), but it is leaky and involves unintended consequences that it cannot be fully trusted.

The more complex a codebase is the more implementation details you have to scrutiny. You need something better if your code involves more diverse error-handling mechanisms.

On a meta-level, I enjoy this paradox where the try-catch-throw, which is supposed to help in reducing "complexity" fails to do so at a certain level of complexity. It is nice to see someone screwed by this because then I see a comrade-in-arms.

Acceptable Throws

Update 2023-10-16

A throw is fine when it does not mistify the programmer. The throw's timing and reason, the try's scope and the catch's mechanism must be obvious.

The simplest form of an obvious throw is when it is declared.

/**
 * @throws OperationException
 */
export function anOperationThatMayThrow(){
}

Another form of obvious throw is when it is allowed in a certain way.

import { withRetry, Retry } from "./retry"

let attempt = 0;
const result = await withRetry(async () => {
  const res = await executeSomeOps();
  if (res.shouldRetry) throw Retry
  return res;
});

// Where the usage guide of of withRetry is explicitly written like so:
/**
 * @usage
 * await withRetry(async () => {
 *   if (await notIdeal()) throw Retry;
 *   // more long lines of code
 * });
 */

throw is very convenient to stop an operation early and to tell the compiler to not care about the return type of a certain branch. However, the challenge of documentation is always documentation rot because documentation isn't checked by the compiler and may require a custom linter to ensure its correctness.

prelude
complication