A Better Way to Handle Errors in TypeScript: Type-Safe Error Handling with Result
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
stringerror - ✅ 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/resultOr 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/resultReal-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_IDWhy 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:
- Errors are values - They should be visible in types, not hidden in documentation
- Explicit is better than implicit - Function signatures should tell you what can fail
- TypeScript idioms - Use lowercase
ok()/err(), method chaining, and familiar patterns - 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/resultCheck 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.