@shirudo/result
v1.1.1
Published
Robust, type-safe error handling for TypeScript using the Result pattern (Monad)
Maintainers
Readme
@shirudo/result
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.
Installation
npm install @shirudo/result
pnpm add @shirudo/result
yarn add @shirudo/resultQuick Start
import { 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: '[email protected]', 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`,
}),
);Why Result Instead of try/catch?
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.
Why This Library?
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.
When Not to Use It
- 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.
Common Workflows
Catch throwing APIs
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 }),
);Compose with pipe operators
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}`),
);Compose async work
import { Result } from '@shirudo/result';
import { mapAsync, tryMapAsync } from '@shirudo/result/operators';
const db = {
async getUser(id: number) {
return { id, email: '[email protected]' };
},
};
const normalizedEmail = await Result.ok<number, string>(1).pipeAsync(
mapAsync(async id => db.getUser(id)),
tryMapAsync(async user => user.email.toLowerCase()),
);Use task notation for sequential flows
import { Result, task } from '@shirudo/result';
function findUser(id: string) {
return Result.ok({ id, email: '[email protected]' });
}
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);
});Handle error classes exhaustively
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
}Match discriminated-union errors
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}`,
});Imports
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';Documentation
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)
Development
pnpm install
pnpm check
pnpm check:cleanUseful focused commands:
pnpm docs:dev
pnpm docs:check
pnpm test:exportsLicense
MIT
