TypeScript is lying to you

Why type-safety doesn't mean runtime-safety and what to do about it

What would you expect the inferred type of doublePositive to be in this TypeScript code?

const doublePositive = (num: number) => {
  if (num < 0) {
    throw new Error("Negative number");
  }
  return num * 2;
};

Spoiler: it’s (num: number) => number. Yes, the return type is just number.

This function can throw, but TypeScript has no idea!

Somehow, exceptions are invisible to TypeScript’s type system.

In a strongly-typed language, this should be criminal!

And yet throw is all that TypeScript gives us. Every time you throw, you’re assuming that someone, somewhere up the stack will catch. But in a growing codebase, that assumption gets weaker with every new layer of abstraction and every new developer added. Throwable functions become landmines… undetectable until something explodes in production.

Even when developers handle these functions, try/catch blocks ruin readability. Like goto statements from the ‘70s, they scatter control flow, making programs harder to reason about.

class NegativeNumberError extends Error {
  constructor() {
    super("Number cannot be negative");
    this.name = "NegativeNumberError";
  }
}

// type: number -- no type indication that this could throw
const doublePositive = (num: number) => {
  if (num < 0) {
    throw new NegativeNumberError();
  }
  return num * 2;
};

// Caller has to know to wrap in try/catch
try {
  const result = doublePositive(-5);
  console.log(result);

  // nested try/catch to handle separate error… 🤮
  try {
    anotherThrowableFn(result);
  } catch (error) {
    return { ok: false, error: "DifferentError" };
  }

  return { ok: true, result };
} catch (error) {
  // TypeScript types this as `unknown`!
  captureError(error);
  return { ok: false, error };
}

What if there were a better way?

Let’s take a look at a familiar data type: Promises.

// A Promise is basically a “box” containing a future value
const box = new Promise<string>((resolve) => {
  resolve("Hello, world!");
});
// Can't access value directly
const length = box.length; // Error!
// // We have to “open” the box to use it
box.then((value) => {
  console.log(value.length); // 13
});
//  // Or with async/await
const getLength = async () => {
  const value = await box;
  return value.length; // 13
};

Promises force you to acknowledge that the value isn’t ready yet. What if we had a similar “box” for operations that might fail?

Let’s call it Result<T, E>, a box that contains either a successful value of type T, or an error of type E.

With a Result type, error handling becomes explicit and type-safe. Let’s refactor the initial example using a hypothetical Result type:

const safeDoublePositive = (num: number) => {
  if (num < 0) {
    return Result.Error(new NegativeNumberError());
  }
  return Result.Ok(num * 2);
};

// No try/catch needed
// type: Result<number, NegativeNumberError>
const result = safeDoublePositive(-5);
// Transform success case only
// type: Result<number, NegativeNumberError | DifferentError>
const mappedResult = result.map((x) => anotherSafeFn(x));

You can still manipulate/transform the success value using .map, just like with JavaScript arrays. If the Result holds an error, nothing happens. The error is preserved and passed along unchanged. No branching or conditional guards needed!

Unboxing

Because the actual value is within the Result box, you’re forced to address the non-happy path when you want access to the value.

// Handle both cases explicitly
const actualVal = result.match({
  Ok: (value) => ({ ok: true, value }),
  Error: (error) => ({ ok: false, error }),
});

// Or get a default/fallback
const valueOrDefault = result.getOr(0);

Takeaway

Result makes error handling an explicit part of your type system. No more try/catch duct-taped across your logic. Errors are surfaced at compile time rather than production because you’re confronted with edge cases by design, not as an afterthought.

And this isn’t just about human developers! Imagine an LLM agent making recursive function calls as it runs unsupervised 24/7. Do you want it crashing or passing around boxed errors to get handled explicitly?

Result is your first line of defense. Before you reach for retries or rate limits, you need to know: did the thing succeed or not? It’s a small abstraction, but it lays the groundwork for much more. We’ll get there, but it starts with a box!