DEV Community

Wiljeder Filho
Wiljeder Filho

Posted on

2 1

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

What TypeScript infers automatically:

function getRandomNumber(): Err<never, "Invalid user ID"> | Ok<number, never>
Enter fullscreen mode Exit fullscreen mode
  • Ok<number, never> -> If successful, it holds a number.
  • Err<never, "Invalid user ID"> -> 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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Heroku

Deploy with ease. Manage efficiently. Scale faster.

Leave the infrastructure headaches to us, while you focus on pushing boundaries, realizing your vision, and making a lasting impression on your users.

Get Started

Top comments (4)

Collapse
 
pengeszikra profile image
Peter Vivo

Thx for this post, I never try to give type for a throw based solution (maybe I never used). This is show TypeScript is not finished yet, because don't have option to handle throw error situation which sometimes is important.

I also missing from typescript a function purity check.

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

Thrown errors are not typed because the return value is never returned. Throwing something takes an entirely different path that has nothing to do with typing the return value.

Collapse
 
pengeszikra profile image
Peter Vivo

Yes you right, I know how works of thrown. I know that is not return type, instead that is will be a thrown type, because that is descrebe more precise the behaviour of function.

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

Out of the 3 reasons in the Why is this a problem? section, only the third is valid. The first one is not because throwing doesn't affect the return type of the function. When something is thrown from a function, there is no return value. This is also not an unexpected crash. After all you did write the code with the throw statement in it, right? How can it be unexpected?

The more accurate reasons to pick branching over throwing are:

  1. Throwing is not a branching statement. Don't use exceptions to drive logic.
  2. Throwing is a costly operation. It will reduce the performance of the "branch" in 40% or so.

Overall, yes, branching is better than throwing. The golden rule is: You only throw if you cannot write code for the case. This is almost exclusively contained in the realm of libraries, where the library must cater for the majority of its users, for the vast majority of the use cases. Application code can most likely be free of thrown errors because we usually know what to do when a user ID is not found. At the very minimum, we can exchange one piece of UI for another.

👋 Kindness is contagious

If this post resonated with you, feel free to hit ❤️ or leave a quick comment to share your thoughts!

Okay