Handling Errors in TypeScript: Stop Throwing, Start Returning

In TypeScript, we often assume that functions will always return their expected values. But what happens when something goes wrong? Many functions can throw errors, yet TypeScript still types them as if they always succeed. This can lead to unexpected crashes and misleading type information. Let’s break this down with an example and see how neverthrow fixes it—without even needing explicit types! The problem: Hidden Exceptions Let’s say we have a function that fetches a user’s age from a database. If the user ID is invalid, it throws an error. function getUserAge(userId: number) { if (userId | Ok Ok -> If successful, it holds a number. Err -> If it fails, it holds the exact error string. Why is this better? No more hidden exceptions: We never throw errors, so our program never crashes unexpectedly. More precise error types: Instead of using generic string or Error, TypeScript automatically infers the exact error values. Explicit, safe handling: Since Result forces us to handle both cases, we write clearer and safer code: if (ageResult.isErr()) { console.error("Error:", ageResult.error); // Typed as "Invalid user ID" } else { console.log("User age:", ageResult.value); // Typed as number } Even better: functional handling Instead of using if statements, we can chain the result with .map() and .mapErr(): ageResult .map(age => console.log("User age:", age)) // Runs if success .mapErr(error => console.error("Error:", error)); // Runs if failure This makes our code cleaner and easier to read! Final takeaway Using neverthrow eliminates hidden exceptions and makes code more predictable. Instead of throwing errors, we return them explicitly, ensuring TypeScript correctly infers both success and failure cases. This approach prevents misleading types and unexpected crashes while forcing proper error handling. TypeScript naturally infers precise types, leading to cleaner, safer, and more reliable applications.

Mar 21, 2025 - 04:07
 0
Handling Errors in TypeScript: Stop Throwing, Start Returning

In TypeScript, we often assume that functions will always return their expected values. But what happens when something goes wrong? Many functions can throw errors, yet TypeScript still types them as if they always succeed. This can lead to unexpected crashes and misleading type information.

Let’s break this down with an example and see how neverthrow fixes it—without even needing explicit types!

The problem: Hidden Exceptions

Let’s say we have a function that fetches a user’s age from a database. If the user ID is invalid, it throws an error.

function getUserAge(userId: number) {
    if (userId < 0) throw new Error("Invalid user ID");

    return 25; // Fake age for demo
}

const age = getUserAge(-1); // This might throw an error, yet it's typed as number
console.log("User age:", age);

Why is this a problem?

  1. Misleading type: TypeScript infers getUserAge() as returning number, even though it can throw an error.
  2. Unexpected crashes: If an invalid user ID is passed, the function never actually returns, and throws instead!
  3. No type safety for errors: There’s nothing in the function signature that warns us an error might occur.

When you hover over getUserAge, TypeScript incorrectly shows:

function getRandomNumber(): number

The solution: Using neverthrow

Instead of throwing errors, let's use neverthrow to return them explicitly:

import { ok, err } from "neverthrow";

function getUserAge(userId: number) {
    if (userId < 0) return err("Invalid user ID"); // Explicit error

    return ok(25); // Success case
}

const ageResult = getUserAge(-1);

What TypeScript infers automatically:

function getRandomNumber(): Err<never, "Invalid user ID"> | Ok<number, never>
  • Ok -> If successful, it holds a number.
  • Err -> If it fails, it holds the exact error string.

Why is this better?

  1. No more hidden exceptions: We never throw errors, so our program never crashes unexpectedly.
  2. More precise error types: Instead of using generic string or Error, TypeScript automatically infers the exact error values.
  3. Explicit, safe handling: Since Result forces us to handle both cases, we write clearer and safer code:
if (ageResult.isErr()) {
    console.error("Error:", ageResult.error); // Typed as "Invalid user ID"
} else {
    console.log("User age:", ageResult.value); // Typed as number
}

Even better: functional handling

Instead of using if statements, we can chain the result with .map() and .mapErr():

ageResult
    .map(age => console.log("User age:", age)) // Runs if success
    .mapErr(error => console.error("Error:", error)); // Runs if failure

This makes our code cleaner and easier to read!

Final takeaway

Using neverthrow eliminates hidden exceptions and makes code more predictable. Instead of throwing errors, we return them explicitly, ensuring TypeScript correctly infers both success and failure cases.

This approach prevents misleading types and unexpected crashes while forcing proper error handling. TypeScript naturally infers precise types, leading to cleaner, safer, and more reliable applications.