A Result<T, E> type for TypeScript. Functions return their failures instead of throwing them.
function loadUser(id: string): Result<User, UserError>The failure is part of the signature: callers see exactly what can go wrong, the compiler insists both cases are handled, and value/error are only accessible after narrowing. What used to be a forgotten catch is now a type error.
The rest of the package is tooling around that one type: pipe operators, async variants, generator-based do-notation, exhaustive error matching, and collection helpers. The library has zero dependencies, ships as ESM and CJS, and runs on Node 20+ and edge runtimes.
npm install @shirudo/result
pnpm add @shirudo/result
yarn add @shirudo/resultimport { err, ok, type Result } from '@shirudo/result';
import { map, match } from '@shirudo/result/operators';
type User = { id: string; email: string; active: boolean };
type UserError =
| { code: 'not-found'; id: string }
| { code: 'inactive'; id: string };
const users = new Map<string, User>([
['1', { id: '1', email: 'ada@example.com', active: true }],
]);
function loadUser(id: string): Result<User, UserError> {
const user = users.get(id);
if (!user) return err({ code: 'not-found', id });
if (!user.active) return err({ code: 'inactive', id });
return ok(user);
}
// Narrow explicitly:
const result = loadUser('1');
if (result.isOk()) {
console.log(result.value.email); // `value` is only accessible in this branch
}
// Or compose and resolve in one expression. `map` only runs on Ok,
// and the error keeps its type all the way to `match`:
const message = loadUser('1').pipe(
map(user => `Welcome back, ${user.email}`),
match({
ok: greeting => greeting,
err: error =>
error.code === 'not-found'
? `No user with id ${error.id}`
: `User ${error.id} is deactivated`,
}),
);TypeScript cannot type a catch block: every thrown value arrives as unknown, and nothing in a function's signature reveals that it throws at all. Callers either remember to catch, or they find out in production.
A Result<User, UserError> puts the failure into the signature. The compiler forces both states to be handled, narrows value and error access to the matching state (as in the Quick Start above), and keeps the error type intact across every transformation.
There are several Result implementations for TypeScript. This one is built around a few hard guarantees:
- Error types that cannot lie. Declaring an explicit error type requires an error mapper:
fromPromise<User, ApiError>(promise)without one is a compile error, soEnever silently holds an unmappedunknown. And bugs inside the mapper itself are rethrown instead of being disguised asErrvalues. - Exhaustive matching, checked at compile time.
matchError().when(NotFoundError, ...).run()only compiles once every error case is handled, andmatchTagdoes the same for discriminated unions. Add a new error variant, and every unhandled match site turns red. - Four interchangeable styles, one type. Explicit
isOk()/isErr()checks,pipe/pipeAsyncoperator chains, generator-based do-notation (task), and builder-based error matching all work on the same immutable, frozenResult. Use whichever style fits each call site. - Safe at runtime boundaries.
isResult()validates an internal brand plus payload shape instead of accepting lookalike objects, andtoSerialized()/fromSerialized()round-trip Results through JSON without ambiguity. - Zero dependencies, runs anywhere. The package ships ESM and CJS builds with tree-shakeable subpath exports, and it runs on Node 20+ and in edge runtimes.
- Verified, not promised. Every TypeScript snippet in this README and the docs is compile-checked in CI, the API is covered by 400+ runtime tests plus compile-time type tests, and the package exports are verified for ESM, CJS, and TypeScript consumers.
- If your codebase is already built on Effect, you do not need this package. Its
Either/Exittypes come with the surrounding ecosystem. - For short scripts and prototypes, plain
try/catchis often the simpler tool. The value of typed errors grows with the number of call sites that must handle them. - Keep throwing for programmer errors. Broken invariants and failed assertions should crash loudly;
Resultis for failures the caller is expected to handle.
import { Result } from '@shirudo/result';
const parseJson = Result.fromThrowable(
JSON.parse,
error => ({ code: 'parse' as const, cause: error }),
);
const parsed = parseJson('{"valid": true}');
const response = await Result.fromPromise(
Promise.resolve({ ok: true }),
error => ({ code: 'network' as const, cause: error }),
);import { Result } from '@shirudo/result';
import { filter, map, mapErr } from '@shirudo/result/operators';
const processed = Result.ok<number, string>(10).pipe(
map(value => value * 2),
filter(
value => value > 25,
() => 'too small',
),
mapErr(error => `Validation failed: ${error}`),
);import { Result } from '@shirudo/result';
import { mapAsync, tryMapAsync } from '@shirudo/result/operators';
const db = {
async getUser(id: number) {
return { id, email: 'ada@example.com' };
},
};
const normalizedEmail = await Result.ok<number, string>(1).pipeAsync(
mapAsync(async id => db.getUser(id)),
tryMapAsync(async user => user.email.toLowerCase()),
);import { Result, task } from '@shirudo/result';
function findUser(id: string) {
return Result.ok({ id, email: 'ada@example.com' });
}
function ensureEmail(user: { id: string; email?: string }) {
return user.email
? Result.ok(user.email)
: Result.err({ code: 'missing-email' as const, id: user.id });
}
const emailResult = await task(function* () {
const user = yield* findUser('1');
return yield* ensureEmail(user);
});import { Result } from '@shirudo/result';
class NotFoundError extends Error {
readonly code = 'not-found';
}
class RateLimitError extends Error {
readonly code = 'rate-limited';
constructor(readonly retryAfter: number) {
super(`rate limited, retry in ${retryAfter}s`);
}
}
const result: Result<string, NotFoundError | RateLimitError> = Result.err(new RateLimitError(30));
if (result.isErr()) {
const message = result
.matchError()
.when(NotFoundError, () => 'No such record')
.when(RateLimitError, error => `Retry in ${error.retryAfter}s`)
.run(); // run() only compiles because every error class is handled
}import { Result, matchTag } from '@shirudo/result';
type DomainError =
| { code: 'network'; retryAfter: number }
| { code: 'validation'; field: string };
const failed = Result.err<DomainError>({ code: 'network', retryAfter: 30 });
const message = matchTag(failed, 'code', {
network: error => `Retry in ${error.retryAfter}s`,
validation: error => `Invalid field: ${error.field}`,
});Everything is available from the package root:
import { Result, err, ok, task } from '@shirudo/result';Focused subpath exports are available for clearer imports and package-level checks:
import { UnwrapOnErrError } from '@shirudo/result/errors';
import { flatMapAsync, map } from '@shirudo/result/operators';
import { sequence, sequenceRecord } from '@shirudo/result/collections';The full documentation lives in docs/ and is built with VitePress.
- Getting Started
- Pipelines
- Task Notation
- Pattern Matching
- Result API
- Operators
- Collections
- Error Classes
- Version 1 Migration
- Design Decisions (Defensive State Checks)
The repository ships an agent skill for AI coding assistants in skills/result-ts/. Copy the folder into your project's skill directory (for example .claude/skills/ for Claude Code or .agents/skills/ for other agents). It gives your assistant the API cheatsheet, refactoring patterns, and best practices for @shirudo/result; every snippet in it is compile-checked in this repository's CI.
pnpm install
pnpm check
pnpm check:cleanUseful focused commands:
pnpm docs:dev
pnpm docs:check
pnpm test:exportsMIT