loke.dev
Header image for Stop Throwing Exceptions (Return Results Instead)

Stop Throwing Exceptions (Return Results Instead)

If you're still using throw/catch for business logic, you're building a runtime house of cards that TypeScript can't help you fix.

· 3 min read

I recently spent an entire afternoon tracking down a bug where a payment service just... stopped. No logs, no stack trace, just a silent exit because a deeply nested function threw a string instead of an Error object, and a generic catch block upstream didn't know how to handle it. It was a classic "runtime house of cards" moment that made me realize we're using TypeScript all wrong when it comes to failure.

The Invisible GOTO

When you use throw, you’re essentially using a glorified GOTO statement. You’re telling the program to teleport from the current line to some unknown handler potentially dozens of levels up the call stack.

The biggest problem? TypeScript is blind to it.

Look at this function signature:

function findUser(id: string): User {
  const user = db.users.find(u => u.id === id);
  if (!user) {
    throw new Error("User not found");
  }
  return user;
}

If you look at the signature (id: string) => User, it lies to you. It promises a User, but it’s actually a coin flip. It might give you a User, or it might blow up your entire process. TypeScript won’t warn you to wrap this in a try/catch. It just lets you sail right into a runtime exception.

The Result Pattern

Instead of letting functions explode, we can make failure a first-class citizen. We do this by returning a Result type that explicitly says: "This might work, or it might fail."

Here is a simple, lightweight way to define it without pulling in a heavy functional programming library:

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

const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });

Now, let's rewrite that user function:

function findUser(id: string): Result<User, string> {
  const user = db.users.find(u => u.id === id);
  
  if (!user) {
    return err("USER_NOT_FOUND");
  }
  
  return ok(user);
}

Why this changes everything

Now, when you call findUser, TypeScript forces you to deal with the reality of failure. You can't just access user.email immediately.

const result = findUser("123");

// This would fail to compile:
// console.log(result.value.email); 

if (result.ok) {
  // TypeScript narrows the type here. 
  // You can safely access .value
  console.log(result.value.email);
} else {
  // You have to handle the error case
  console.error(result.error);
}

This shifts the mental load from "I hope I remembered to catch everything" to "The compiler won't let me ship this until I handle the errors." It makes your code honest.

Stop treating logic like disasters

We need to distinguish between Expected Failures and Unexpected Disasters.

1. Expected Failures: A user enters a wrong password, a file isn't found, a validation fails. These are part of your business logic. Return a Result.
2. Unexpected Disasters: The database connection is lost, the disk is full, or there's a literal syntax error. These are things your code usually can't recover from anyway. Throw an Exception.

When you use throw for business logic (like "Invalid Email"), you're treating a common user mistake like a catastrophic system failure. It's overkill, and it makes your "real" bugs much harder to find.

Pattern Matching (The "Clean" Way)

If you find the if (result.ok) blocks a bit repetitive, you can create a simple helper to handle the branching logic.

function match<T, E, R>(
  result: Result<T, E>,
  handlers: {
    onSuccess: (value: T) => R;
    onError: (error: E) => R;
  }
): R {
  return result.ok ? handlers.onSuccess(result.value) : handlers.onError(result.error);
}

// Usage
const message = match(findUser("123"), {
  onSuccess: (user) => `Hello, ${user.name}`,
  onError: (error) => `Error: ${error}`
});

The "Gotcha": Don't over-engineer it

I've seen teams go overboard and try to implement a full-blown Monad library with flatMap, mapErr, and bimap. Unless your whole team is deeply into Category Theory, keep it simple. The goal isn't to write Haskell in TypeScript; the goal is to stop your app from crashing because you forgot that getUser might fail.

By returning objects instead of throwing grenades, you build a system that is self-documenting. Anyone reading your code can look at a function signature and know exactly what might go wrong. That’s a lot better than playing "Guess the Exception" at 2:00 AM.