Your function signature says it returns a User. It doesn’t. It returns a User or it explodes. The type system just doesn’t know about the second branch.

This is the fundamental dishonesty of exception-based error handling. Every throw is a control flow path that your compiler cannot see, cannot check, and cannot enforce. You end up with code that type-checks perfectly and still crashes in production because someone forgot a try/catch three call layers up.

Explicit error returns fix this. They make failure a first-class citizen of your type system. The question is whether the ergonomics cost is worth the honesty.

What Type-Level Correctness Means for Errors

Type-level correctness means your types tell the truth about what a function can produce. If a function can fail, the return type should say so. If it can’t, the return type should guarantee success.

Rust encodes this with Result<T, E>:

fn parse_port(s: &str) -> Result<u16, ParseIntError> {
    s.parse()
}

The signature is complete. Callers know there are two outcomes. The compiler forces them to handle both.

TypeScript, by default, does not. A function that throws looks identical to one that doesn’t:

function parsePort(s: string): number {
  const n = parseInt(s, 10);
  if (isNaN(n) || n < 0 || n > 65535) {
    throw new Error(`Invalid port: ${s}`);
  }
  return n;
}

The return type says number. It should say number | Error, or more accurately, Result<number, Error>. But it doesn’t, so callers have no signal that they need to defend against failure.

How Thrown Errors Hide in Your Call Graph

The damage compounds as your call stack deepens. A thrown error in a leaf function forces every intermediate layer to either catch it or implicitly propagate it. None of those propagation paths appear in any type signature.

Consider a config loader that calls a parser that calls a validator. If the validator throws, the parser and loader both become throwable too. But their types don’t change. You can’t look at loadConfig() and know it might fail. You have to read every line of every dependency, or wait for a runtime crash.

This is the exact opposite of how types are supposed to work. Types exist so you don’t have to read the implementation to understand the contract.

Explicit error returns invert this. The failure path is visible at every layer:

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

function parsePort(s: string): Result<number, string> {
  const n = parseInt(s, 10);
  if (isNaN(n) || n < 0 || n > 65535) {
    return { ok: false, error: `Invalid port: ${s}` };
  }
  return { ok: true, value: n };
}

function loadConfig(raw: Record<string, string>): Result<Config, string> {
  const portResult = parsePort(raw.port);
  if (!portResult.ok) return portResult;

  const host = raw.host || "localhost";

  return { ok: true, value: { port: portResult.value, host } };
}

Now loadConfig’s signature tells the whole story. You know it can fail before you read a single line of the body.

The Real Trade-Off Is Ergonomics, Not Expressiveness

The honest approach is more verbose. No one enjoys writing { ok: false, error: ... } instead of throw new Error(...). The visual noise adds up, especially when you’re chaining multiple fallible operations.

This is why most languages with checked exceptions abandoned them. Java’s checked exceptions tried to enforce explicit handling at the type level, but the syntax was punitive. Rust’s ? operator and Result combinators like map and and_then recover most of that ergonomics. TypeScript doesn’t have a ? operator, but you can get close with helper functions:

function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
  return result.ok ? { ok: true, value: fn(result.value) } : result;
}

function flatMap<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => Result<U, E>
): Result<U, E> {
  return result.ok ? fn(result.value) : result;
}

It’s still clunkier than Rust. If you’re writing TypeScript, libraries like neverthrow provide a Result type with chainable methods that feel almost native. The overhead is real, but it’s smaller than it looks once you have helpers in place.

There is another cost. Stack traces from thrown errors are rich and automatic. Returned errors are just values. If you want a stack trace, you have to construct it yourself. For debugging, this matters. For predictable error handling in business logic, it usually doesn’t.

When Throwing Still Makes Sense

We’re not advocating for zero exceptions. Some failures are truly exceptional and shouldn’t be part of your normal error model. A missing file during config loading is an expected failure. An out-of-memory error is not.

The rule of thumb: if a failure is part of your domain, return it. If it’s a programming bug or a catastrophic system failure, throw it. JSON.parse throwing on malformed input is annoying because invalid JSON is common. Array.prototype.map throwing on a null array is fine because calling .map on null is a bug.

In practice, this means your I/O boundaries, parsers, validators, and business rules should return errors. Your invariants, assertions, and truly catastrophic conditions can still throw.

Start With Your API Boundaries

You don’t need to refactor your entire codebase. The highest-value places to adopt explicit error returns are your public APIs and your I/O layers. These are the boundaries where callers need to know what can go wrong.

Pick one module. Change its return types to Result<T, E>. Update its callers. See how it feels. The pattern is addictive in a specific way: once you stop hunting through stack traces for missing try/catch blocks, you realize how much mental overhead thrown exceptions were costing you.

Type-level correctness for errors isn’t about writing perfect code. It’s about writing code that lies less.