Skip to main content

A Better Way to Handle Errors in TypeScript: Type-Safe Error Handling with Result

typescriptrustpatterns

Exceptions are broken. Not in the "sometimes buggy" way, but in the fundamental design sense. TypeScript inherited JavaScript's try/catch error handling, and it's time we had a better option.

Today I'm releasing @brettchalupa/result, a TypeScript library that brings Rust's beloved Result<T, E> pattern to the JavaScript ecosystem.

The Problem with Exceptions

Let's be honest: how many times have you looked at a function signature and wondered "does this throw?"

function findUser(id: string): User {
  // Does it throw? Who knows! 🤷
  // You'll have to read the implementation... and all its dependencies
}

Exception-based error handling in TypeScript has four major problems:

1. Invisible Control Flow

You can't tell from a function signature whether it throws. Is findUser() going to throw? What about validateEmail()? The only way to know is to read the source code—and the source code of everything it calls.

function processUser(id: string) {
  const user = findUser(id); // Throws? Maybe?
  const validated = validate(user); // Throws? Who knows?
  return save(validated); // Throws? Probably?
}

2. No Type Safety

When you catch an error, TypeScript types it as unknown. Was it an Error? A string? undefined? A custom error class? TypeScript can't help you.

try {
  const data = JSON.parse(input);
} catch (error) {
  // error is 'unknown' - could be literally anything
  console.error(error.message); // ❌ TypeScript error!
}

3. Difficult to Test

How do you test error paths when you don't know which functions throw? It's easy to miss edge cases and ship untested error handling code to production.

4. Unclear Error Propagation

When an error bubbles up through multiple layers, it's hard to trace where it originated:

function outer() {
  try {
    middle();
  } catch (e) {
    // Did middle() throw? Or did inner() throw?
    // Or maybe something inner() called threw?
    // Good luck debugging this!
  }
}

Learning from Rust

Rust doesn't have exceptions. Instead, it uses the Result<T, E> type—a simple enum that represents either success (Ok(value)) or failure (Err(error)).

Here's what it looks like in Rust:

fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err(String::from("Cannot divide by zero"))
    } else {
        Ok(a / b)
    }
}

// Usage
match divide(10, 2) {
    Ok(result) => println!("Result: {}", result),
    Err(error) => println!("Error: {}", error),
}

The key insight: errors are just values. They're part of the function's return type, visible in the signature, and checked by the compiler.

Introducing @brettchalupa/result

I wanted the same clarity in TypeScript, so I built a Result library that feels natural in the TypeScript ecosystem:

import { err, ok, type Result } from "@brettchalupa/result";

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return err("Cannot divide by zero");
  }
  return ok(a / b);
}

// Usage
const result = divide(10, 2);
if (result.isOk()) {
  console.log("Result:", result.data); // Result: 5
} else {
  console.error("Error:", result.error);
}

Look at that signature: Result<number, string>. You can immediately see:

  • ✅ Success returns a number
  • ✅ Failure returns a string error
  • ✅ No hidden surprises

Installation

The library is published on JSR (JavaScript Registry), which has first-class support for Deno, Node.js, and all modern JavaScript runtimes.

Deno

deno add jsr:@brettchalupa/result

Or import directly without installation:

import { err, ok, Result } from "jsr:@brettchalupa/result";

Node.js / Bun / npm

# npm
npx jsr add @brettchalupa/result

# pnpm 10.9+
pnpm add jsr:@brettchalupa/result

# yarn 4.9+
yarn add jsr:@brettchalupa/result

# bun
bunx jsr add @brettchalupa/result

Real-World Examples

Example 1: Parsing User Input

Instead of throwing on invalid input:

import { err, ok, type Result } from "@brettchalupa/result";

type ValidationError = "EMPTY_NAME" | "INVALID_AGE" | "AGE_OUT_OF_RANGE";

function parseAge(input: string): Result<number, ValidationError> {
  const age = parseInt(input, 10);
  if (isNaN(age)) {
    return err("INVALID_AGE");
  }
  if (age < 0 || age > 150) {
    return err("AGE_OUT_OF_RANGE");
  }
  return ok(age);
}

function createUser(
  name: string,
  ageInput: string,
): Result<{ name: string; age: number }, ValidationError> {
  if (name.trim() === "") {
    return err("EMPTY_NAME");
  }

  const ageResult = parseAge(ageInput);
  if (ageResult.isErr()) {
    return ageResult; // Propagate the error
  }

  return ok({ name, age: ageResult.data });
}

// Usage
const user = createUser("Alice", "30");
if (user.isOk()) {
  console.log("Created user:", user.data);
} else {
  // TypeScript knows error is ValidationError
  switch (user.error) {
    case "EMPTY_NAME":
      console.error("Name cannot be empty");
      break;
    case "INVALID_AGE":
      console.error("Age must be a number");
      break;
    case "AGE_OUT_OF_RANGE":
      console.error("Age must be between 0 and 150");
      break;
  }
}

Example 2: Database Operations

import { err, ok, type Result } from "@brettchalupa/result";

type DbError = "NOT_FOUND" | "CONNECTION_ERROR" | "PERMISSION_DENIED";
type User = { id: string; name: string; email: string };

async function findUser(id: string): Promise<Result<User, DbError>> {
  try {
    const user = await db.query("SELECT * FROM users WHERE id = $1", [id]);

    if (!user) {
      return err("NOT_FOUND");
    }

    return ok(user);
  } catch (error) {
    console.error("Database error:", error);
    return err("CONNECTION_ERROR");
  }
}

// Usage
const result = await findUser("123");
if (result.isOk()) {
  console.log("Found user:", result.data.name);
} else {
  // Handle each error case explicitly
  if (result.error === "NOT_FOUND") {
    console.error("User not found");
  } else {
    console.error("Database error:", result.error);
  }
}

Example 3: Method Chaining

One of my favorite features is the fluent API for transforming results:

import { err, ok } from "@brettchalupa/result";

const result = ok({ name: "Alice", age: 30 })
  .map((user) => user.name)
  .map((name) => name.toUpperCase())
  .map((name) => `Hello, ${name}!`);

if (result.isOk()) {
  console.log(result.data); // "Hello, ALICE!"
}

Errors short-circuit the chain:

const result = err("User not found")
  .map((user) => user.name) // Skipped
  .map((name) => name.toUpperCase()); // Skipped

// result is still err("User not found")

Example 4: Wrapping Throwing Code

For interop with exception-based libraries:

import { Result } from "@brettchalupa/result";

// Synchronous
const parsed = Result.try(() => JSON.parse(jsonString));

// Async
const data = await Result.tryAsync(() => fetch(url).then((r) => r.json()));

Example 5: Combining Multiple Results

import { err, ok, Result } from "@brettchalupa/result";

const results = [
  validateEmail("alice@example.com"),
  validateAge("30"),
  validateName("Alice"),
];

// Succeed only if all succeed
const combined = Result.all(results);
if (combined.isOk()) {
  console.log("All validations passed:", combined.data);
}

// Or partition successes and failures
const [successes, failures] = Result.partition(results);
console.log(`${successes.length} passed, ${failures.length} failed`);

Running with Deno

Here's a complete example you can run with Deno:

// main.ts
import { err, ok, type Result } from "jsr:@brettchalupa/result";

type User = { id: number; name: string };
type UserError = "NOT_FOUND" | "INVALID_ID";

function findUserById(id: number): Result<User, UserError> {
  if (id <= 0) {
    return err("INVALID_ID");
  }

  // Simulate database lookup
  const users = [
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" },
  ];

  const user = users.find((u) => u.id === id);
  if (!user) {
    return err("NOT_FOUND");
  }

  return ok(user);
}

// Try it out
const result1 = findUserById(1);
if (result1.isOk()) {
  console.log("✅ Found:", result1.data.name);
}

const result2 = findUserById(999);
if (result2.isErr()) {
  console.log("❌ Error:", result2.error);
}

const result3 = findUserById(-1);
if (result3.isErr()) {
  console.log("❌ Error:", result3.error);
}

Run it:

deno run main.ts
# ✅ Found: Alice
# ❌ Error: NOT_FOUND
# ❌ Error: INVALID_ID

Why I Built This

I've been writing TypeScript for years, and error handling has always felt broken. After programming Rust, I kept thinking: "Why can't we have this in TypeScript?"

Existing Result libraries either:

  • Were too focused on pure functional programming with terms like "monad"
  • Didn't have method chaining
  • Had complex APIs that felt wrong in TypeScript

I wanted something that:

  • ✅ Feels natural in TypeScript
  • ✅ Has a small, focused API
  • ✅ Provides excellent type inference
  • ✅ Works with modern runtimes (Deno, Bun, Node.js)
  • ✅ Has zero dependencies

So I built it. The entire library is ~500 lines of code with 50 tests and 100% type safety.

Philosophy

This library embraces a few key principles:

  1. Errors are values - They should be visible in types, not hidden in documentation
  2. Explicit is better than implicit - Function signatures should tell you what can fail
  3. TypeScript idioms - Use lowercase ok()/err(), method chaining, and familiar patterns
  4. Zero magic - Simple types, no complex functional programming jargon

What's Next?

The library has been used in production for months. I don't anticipate major API changes because the API is intentionally minimal.

Try It Out

Give it a shot in your next TypeScript project:

deno add jsr:@brettchalupa/result

Check out the full docs and examples on JSR and the GitHub repo.

I'd love to hear what you think! Does explicit error handling resonate with you? Have you tried similar patterns in other languages?


This library is released into the public domain under the Unlicense. Use it however you want—no strings attached.