@punpun-dev/ts-result
v0.1.3
Published
A tiny TypeScript Result type for explicit error handling without exceptions
Maintainers
Readme
ts-result
A tiny TypeScript Result type for explicit error handling without exceptions. Inspired by Rust's Result<T, E>.
Philosophy
Why Result Types?
Exceptions in JavaScript/TypeScript are implicit, untyped, and easily forgotten. A function can throw without its type signature indicating it, making error handling opt-in rather than explicit.
Result types flip this: errors become part of the return type, forcing callers to acknowledge them at compile time.
The Problems with Exceptions
- Invisible in types —
fetchUser(id)tells you nothing about what could go wrong - Async/await breaks try/catch — Promises that reject require separate handling from sync errors
- Forgotten handling — It's easy to forget a
try/catch, leading to uncaught exceptions in production - No error shape guarantee — Thrown values can be anything: strings, Error objects, or random values
How Result Helps
// With exceptions — easy to forget, invisible in types
function getUser(id: string) {
const user = db.find(id);
if (!user) throw new Error("Not found");
return user;
}
// With Result — explicit in type, must be handled
function getUser(id: string): Result<User, AppError> {
const user = db.find(id);
if (!user) return Result.err({ kind: "not_found", resource: "user" });
return Result.ok(user);
}Core Principles
- Explicit over implicit — Errors are part of the return type
- Type-safe errors — Use custom error types (not just
Errororstring) - Composable — Chain operations with
map,flatMap, andtapwithout nesting try/catch blocks - Exhaustive matching —
match()forces you to handle both success and failure cases
When to Use Result
- Use Result for expected errors — validation failures, not found, unauthorized (these are part of normal program flow)
- Use exceptions for unexpected errors — bugs, impossible states, programmer errors (things that shouldn't happen)
Inspiration
This library is inspired by Rust's Result<T, E> type, which makes error handling a first-class part of the type system rather than an afterthought. The API is object-oriented: Result is an abstract base class with two concrete subclasses, Ok and Err, and all operations are methods you chain directly on the result instance.
Usage
import { Result, ok, err } from "@punpun-dev/ts-result";
// Create results — use ok()/err() helpers or Result.ok()/Result.err()
const success = ok(42);
const failure = err("Something went wrong");
// Check result type with type-narrowing methods
if (success.isOk()) {
console.log(success.value); // 42
}
// Pattern matching — branches can return different types
const message = failure.match({
ok: (value) => `Success: ${value}`,
err: (error) => `Error: ${error}`,
});
// Chain operations fluently
const doubled = ok(21)
.map((x) => x * 2) // Ok(42)
.flatMap((x) => ok(x + 1)) // Ok(43)
.unwrapOr(0); // 43
// Wrap throwing code (sync or async)
const result = await Result.handle(async () => {
const res = await fetch("/api/user");
if (!res.ok) throw new Error("Request failed");
return res.json();
});Installation
npm install @punpun-dev/ts-result
# or
pnpm add @punpun-dev/ts-result
# or
yarn add @punpun-dev/ts-resultAPI
Result<T, E> (abstract base class)
| Method | Description |
|--------|-------------|
| isOk(): this is Ok<T> | Narrow to Ok (type guard) |
| isErr(): this is Err<E> | Narrow to Err (type guard) |
| unwrap(): T | Returns value or throws if Err |
| unwrapOr(fallback: T): T | Returns value or fallback if Err |
| unwrapOrElse(fn: (error: E) => T): T | Returns value or computes fallback from error |
| match<U, V>(cases): U \| V | Exhaustive match with ok and err branches (can return different types) |
| map<U>(fn: (value: T) => U): Result<U, E> | Transform the success value |
| mapErr<F>(fn: (error: E) => F): Result<T, F> | Transform the error value |
| flatMap<U>(fn: (value: T) => Result<U, E>): Result<U, E> | Chain result-returning functions (no nesting) |
| tap(fn: (value: T) => void): this | Side-effect on Ok, returns same instance |
| tapErr(fn: (error: E) => void): this | Side-effect on Err, returns same instance |
| toNullable(): T \| null | Returns value or null |
Static Members
| Method | Description |
|--------|-------------|
| Result.ok<T>(value: T): Ok<T> | Creates a successful result |
| Result.err<E>(error: E): Err<E> | Creates a failed result |
| Result.handle<T>(fn: () => T \| Promise<T>): Promise<Result<T, unknown>> | Wraps sync/async throwing code |
Helper Functions
Shorthand alternatives to Result.ok(), Result.err(), and Result.handle() — useful when you prefer not to reference the Result class directly.
| Function | Description |
|----------|-------------|
| ok<T>(value: T): Ok<T> | Same as Result.ok(value) |
| err<E>(error: E): Err<E> | Same as Result.err(error) |
| handle<T>(fn: () => T \| Promise<T>): Promise<Result<T, unknown>> | Same as Result.handle(fn) |
import { ok, err, handle } from "@punpun-dev/ts-result";
const success = ok(42);
const failure = err("oops");
// Wrap throwing code
const result = await handle(() => fetchUser(userId));Ok<T> and Err<E>
These are the concrete subclasses of Result. You typically don't construct them directly — use Result.ok() and Result.err().
Ok<T>exposes avalue: Tproperty (access after narrowing withisOk())Err<E>exposes anerror: Eproperty (access after narrowing withisErr())
Fullstack Examples
Shared Types (frontend + backend)
import { Result } from "@punpun-dev/ts-result";
export type AppError =
| { kind: "not_found"; resource: string }
| { kind: "unauthorized" }
| { kind: "validation"; fields: Record<string, string> }
| { kind: "internal"; message: string };
export type ApiResponse<T> = Result<T, AppError>;Backend: Express Route Handler
// server/routes/user.ts
import { Result } from "@punpun-dev/ts-result";
import express from "express";
const router = express.Router();
function findUserById(id: string): Result<User | null, Error> {
return Result.handle(() => db.users.findById(id));
}
function updateUserEmail(user: User, email: string): Result<User, AppError> {
if (!email.includes("@")) {
return Result.err({ kind: "validation", fields: { email: "Invalid email format" } });
}
return Result.ok({ ...user, email });
}
router.patch("/users/:id/email", async (req, res) => {
const result = await Result.handle(async () => {
const { id } = req.params;
const { email } = req.body;
const userResult = findUserById(id).flatMap((user) =>
user ? Result.ok(user) : Result.err({ kind: "not_found", resource: "user" } as const)
);
return userResult.flatMap((user) => updateUserEmail(user, email));
});
result.match({
ok: (user) => res.json({ success: true, data: user }),
err: (error) => {
switch (error.kind) {
case "not_found":
res.status(404).json({ error: "User not found" });
break;
case "validation":
res.status(400).json({ error: "Validation failed", fields: error.fields });
break;
default:
res.status(500).json({ error: "Internal server error" });
}
},
});
});Backend: Database Transaction
// server/services/order.ts
import { Result } from "@punpun-dev/ts-result";
export async function createOrder(
userId: string,
items: CartItem[]
): Promise<Result<Order, AppError>> {
// Validate all items — collect first error if any
for (const item of items) {
if (item.stock <= 0) {
return Result.err({
kind: "validation",
fields: { [item.id]: "Out of stock" },
});
}
}
// Run async database transaction
const result = await Result.handle(async () => {
return db.transaction(async (tx) => {
const order = await tx.orders.create({ userId, status: "pending" });
await tx.orderItems.createMany(
items.map((item) => ({ orderId: order.id, ...item }))
);
return order;
});
});
// Map unexpected errors to our AppError type
return result.mapErr((err): AppError => ({
kind: "internal",
message: err instanceof Error ? err.message : String(err),
}));
}Frontend: Type-Safe API Client
// client/api/client.ts
import { Result } from "@punpun-dev/ts-result";
import type { ApiResponse } from "shared/types";
type FetchError = { status: number; message: string };
async function apiFetch<T>(url: string, options?: RequestInit): Promise<ApiResponse<T>> {
const result = await Result.handle(async () => {
const res = await fetch(url, {
...options,
headers: { "Content-Type": "application/json", ...options?.headers },
});
if (!res.ok) {
throw { status: res.status, message: res.statusText };
}
return res.json() as Promise<T>;
});
// Map fetch errors to our AppError type
return result.mapErr((err): AppError => {
if (typeof err === "object" && err !== null && "status" in err) {
if (err.status === 401) return { kind: "unauthorized" };
if (err.status === 404) return { kind: "not_found", resource: "resource" };
}
return { kind: "internal", message: String(err) };
});
}
// Usage in a React component
async function loadUserProfile(userId: string) {
const result = await apiFetch<User>(`/api/users/${userId}`);
return result.match({
ok: (user) => ({ type: "success" as const, user }),
err: (error) => ({
type: "error" as const,
message:
error.kind === "not_found"
? "User not found"
: error.kind === "unauthorized"
? "Please log in"
: "Something went wrong",
}),
});
}Frontend: Form Validation
// client/components/SignupForm.tsx
import { Result } from "@punpun-dev/ts-result";
function validateSignup(data: {
email: string;
password: string;
confirmPassword: string;
}): Result<typeof data, string> {
if (!data.email.includes("@")) return Result.err("Invalid email");
if (data.password.length < 8) return Result.err("Password must be at least 8 characters");
if (data.password !== data.confirmPassword) return Result.err("Passwords do not match");
return Result.ok(data);
}
// In your form submit handler
function handleSubmit(e: FormEvent) {
e.preventDefault();
const formData = getFormData();
const validation = validateSignup(formData);
validation.match({
ok: (data) => submitToApi(data),
err: (error) => setError(error), // Show the error message
});
}Generic Error Types
By default, errors are string. You can use custom error types for richer error handling:
import { Result } from "@punpun-dev/ts-result";
// Custom error class
class ValidationError {
constructor(public field: string, public message: string) {}
}
// Create result with custom error
const result = Result.err(new ValidationError("email", "Invalid format"));
// result type: Result<never, ValidationError>
// Result.handle preserves thrown error types
const parsed = await Result.handle(() => JSON.parse("invalid"));
// parsed.error is `unknown` — narrow as needed
// Transform errors with mapErr
const transformed = parsed.mapErr((err) =>
err instanceof Error
? { code: "PARSE_ERROR", message: err.message }
: { code: "UNKNOWN", message: "Unknown" }
);
// Pattern matching with custom error
const message = transformed.match({
ok: (value) => `Success: ${JSON.stringify(value)}`,
err: (error) => `Error ${error.code}: ${error.message}`,
});License
MIT
