ts-safe-result
v2.0.0
Published
A tiny, type-safe Result type for TypeScript with chainable async support. Handle errors as values, not exceptions.
Maintainers
Readme
ts-safe-result
A tiny, type-safe Result<Value, Error> type for TypeScript. Handle errors as values, not exceptions — synchronously and asynchronously.
Why?
TypeScript function signatures lie. async function getUser(id: string): Promise<User> tells you the happy path — it says nothing about what goes wrong.
Every modern language has solved this: Rust has Result, Go has error values, Swift has typed throws. JavaScript still has try/catch with unknown errors.
ts-safe-result brings typed errors to TypeScript:
- Zero dependencies, ~1.3KB gzipped, ESM + CJS, tree-shakable at the top level
- Sync and async —
Resultfor synchronous code,ResultAsyncfor chainable promises - Throws stay inside the chain —
ResultAsynctransforms catch user-callback errors and convert them toErr, soawaitalways resolves to aResult - Familiar API —
map,mapErr,andThen,orElse,match,unwrap,tap,combine,partition,fromThrowable - Type-safe pattern matching — TypeScript ensures every branch is handled
Read the full philosophy: Stop Writing try/catch — Your TypeScript Errors Deserve Types
Install
npm install ts-safe-result
# or
pnpm add ts-safe-result
# or
yarn add ts-safe-result
# or
bun add ts-safe-resultRequires Node 16+ (uses ES2022 Error cause and other modern features).
Quick Start
import { ok, err, tryCatch, tryAsync } from 'ts-safe-result';
import type { Result } from 'ts-safe-result';
// Create typed results
const success = ok(42); // Ok<number, never>
const failure = err('Not found'); // Err<never, string>
// Wrap functions that may throw
const parsed = tryCatch(() => JSON.parse(rawInput));
// Result<unknown, Error>
// Chain async pipelines without unwrapping at every step
const email = await tryAsync(() => fetch('/api/user').then(r => r.json()))
.map(user => user.email)
.flatMap(validateEmail)
.unwrapOr('[email protected]');API
Constructors
ok(value) — Create a successful result
const user = ok({ name: 'Maryan', role: 'admin' });
// Ok<{ name: string; role: string }, never>err(error) — Create a failed result
const failure = err({ code: 'NOT_FOUND', message: 'User does not exist' });
// Err<never, { code: string; message: string }>Methods
Every Result exposes the same methods. The transformation methods are no-ops on the branch they don't apply to, so chains short-circuit naturally and Ok/Err instances are returned unchanged when nothing happens (zero allocations on the no-op path).
.map(transform) — Transform the success value
ok(2).map(count => count * 3);
// Ok(6)
err('not found').map(count => count * 3);
// Err('not found') — transform is skipped.mapErr(transform) — Transform the error value
err('timeout').mapErr(message => new Error(message));
// Err(Error('timeout'))
ok(42).mapErr(message => new Error(message));
// Ok(42) — transform is skipped.flatMap(transform) / .andThen(transform) — Chain dependent results
andThen is an alias for flatMap — use whichever name feels more familiar. Use either when the transform itself returns a Result:
const parseAge = (input: string): Result<number, string> => {
const age = parseInt(input, 10);
return isNaN(age) ? err('Invalid number') : ok(age);
};
ok('25').flatMap(parseAge); // Ok(25)
ok('abc').andThen(parseAge); // Err('Invalid number')
err('missing').flatMap(parseAge); // Err('missing') — skipped.orElse(recover) — Recover from an error
The mirror of flatMap: runs only on Err, lets you fall back to another Result:
const config = readConfig('./app.json')
.orElse(() => readConfig('./default.json'))
.orElse(() => ok(builtInDefaults));
// Tries each source in turn, succeeds with the first available config..tap(sideEffect) — Inspect the value without transforming it
Useful for logging or debugging within a chain:
const user = await tryAsync(() => fetchUser(id))
.tap(user => console.log('Fetched:', user.name))
.map(user => user.email);
// Side effect runs, but the Result stays unchanged.tapErr(sideEffect) — Inspect the error without transforming it
Useful for error reporting within a chain:
const config = tryCatch(() => JSON.parse(rawConfig))
.tapErr(error => Sentry.captureException(error))
.unwrapOr(defaultConfig);
// Error gets reported, but the chain continues.match({ ok, err }) — Exhaustive pattern matching
Handle both cases explicitly — TypeScript ensures you cover both:
const message = result.match({
ok: user => `Welcome, ${user.name}`,
err: error => `Login failed: ${error.message}`,
});.unwrap() — Extract value or throw
Throws an Error whose cause field preserves the original error value, so structured errors aren't flattened to [object Object]:
ok(42).unwrap(); // 42
err('broken').unwrap(); // throws Error('broken')
try {
err({ code: 'NOT_FOUND' }).unwrap();
} catch (e) {
console.log(e.cause); // { code: 'NOT_FOUND' }
}.unwrapOr(fallback) — Extract value or use a fallback
ok(42).unwrapOr(0); // 42
err('broken').unwrapOr(0); // 0.unwrapOrElse(handleError) — Extract value or compute a fallback
err('broken').unwrapOrElse(error => `recovered from: ${error}`);
// 'recovered from: broken'.expect(message) — Extract value or throw with a custom message
Better than unwrap() when debugging — the message points to the failing call site, and the original error is preserved via Error.cause:
const apiKey = readEnv('API_KEY').expect('API_KEY env var must be set');
// throws Error('API_KEY env var must be set: <original message>')
// with .cause set to the original error.isOk() / .isErr() — Type guards for narrowing
const result: Result<User, AppError> = await getUser('123');
if (result.isOk()) {
console.log(result.value.name); // ✅ TypeScript knows .value exists
}
if (result.isErr()) {
console.log(result.error.code); // ✅ TypeScript knows .error exists
}Async — ResultAsync
ResultAsync is a chainable wrapper around Promise<Result<Value, Error>>. It implements PromiseLike, so await resolves directly to a plain Result.
This is the key difference from naive Promise<Result>: you can chain before awaiting, eliminating the unwrap-rewrap-await dance.
import { tryAsync, ok, err } from 'ts-safe-result';
const email = await tryAsync(() => fetchUser(id))
.map(user => user.email) // sync transform on the value
.flatMap(validateEmail) // chain into another Result
.tapErr(e => log.error(e)) // observe failures
.mapErr(e => ({ code: 'INVALID' })) // normalize error type
.unwrapOr('[email protected]'); // resolve with a defaultThrows inside transforms — what happens?
ResultAsync's chainable methods (map, mapErr, flatMap, andThen, orElse) catch both synchronous throws and promise rejections from the user-supplied callback and convert them into an Err. The error type widens to include the standard Error so the chain stays inside Result-land instead of producing an unhandled rejection.
const result = await tryAsync(() => Promise.resolve(42))
.map(n => { throw new Error('boom'); });
// result is Err(Error('boom')) — NOT a thrown exception
// Type: Result<number, Error>tap and tapErr go further: side-effect failures are silently swallowed, since logging or analytics errors must never poison the pipeline.
await tryAsync(() => fetchUser(id))
.tap(user => { throw new Error('logger broken'); }) // silently ignored
.map(user => user.email);
// Chain continues uninterrupted, the original Result is preservedIf a user-supplied errorFn itself throws, the chain falls back to wrapping the original caught value as an Error.
tryAsync(execute, errorFn?) — Wrap an async function that may throw
execute() is invoked synchronously — if it throws before returning a promise, the throw is still captured.
const post = tryAsync(async () => {
const response = await fetch('/api/posts/1');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
});
// ResultAsync<unknown, Error> — chainable
const title = await post.map(p => p.title).unwrapOr('Untitled');
// With a custom error type:
type ApiError = { kind: 'network'; cause: unknown };
const user = tryAsync(
() => fetch('/api/user').then(r => r.json()),
(caught): ApiError => ({ kind: 'network', cause: caught }),
);
// ResultAsync<unknown, ApiError | Error>fromPromise(promise, errorFn?) — Wrap a promise directly
const status = await fromPromise(fetch('/api/health'))
.map(res => res.status)
.unwrapOr(0);okAsync(value) / errAsync(error) — Already-resolved shortcuts
Lighter-weight than ResultAsync.fromResult(ok(value)):
import { okAsync, errAsync } from 'ts-safe-result';
const value = await okAsync(42)
.map(n => n * 2)
.unwrap(); // 84
const fallback = await errAsync<string>('primary failed')
.orElse(() => okAsync('backup'))
.unwrap(); // 'backup'ResultAsync.fromSafePromise(promise) — Promise that won't reject
const start = ResultAsync.fromSafePromise(
Promise.resolve(performance.now()),
);
// ResultAsync<number, never>.catch(handler) / .finally(cleanup) — Promise-style escape hatches
ResultAsync plays nicely with code that expects a Promise:
const result = await tryAsync(() => fetchUser(id))
.finally(() => spinner.stop());ResultAsync exposes the same chainable methods as Result (map, mapErr, flatMap, andThen, orElse, tap, tapErr, match, unwrap, unwrapOr, unwrapOrElse, expect). Mappers may return either sync values or promises — both are awaited automatically.
Utilities
tryCatch(execute, errorFn?) — Wrap a sync function that may throw
const config = tryCatch(() => JSON.parse(rawConfigString));
// Result<any, Error>
// With a custom error mapper:
type ConfigError = { code: 'PARSE_ERROR'; cause: unknown };
const parsed = tryCatch(
() => JSON.parse(rawConfigString),
(caught): ConfigError => ({ code: 'PARSE_ERROR', cause: caught }),
);
// Result<any, ConfigError>Note: Synchronous
Resultmethods (Result.map,Result.flatMap, etc.) do not catch throws from their callbacks — onlyResultAsyncdoes. In sync code, throws are right at the call site, so wrap withtryCatchif you need to convert them. This matches Rust/idiomatic-Result behavior.
fromThrowable(fn, errorFn?) — Lift a throwing function into a Result-returning one
Higher-order version of tryCatch. The original function's signature is preserved:
const safeJsonParse = fromThrowable(JSON.parse);
safeJsonParse('{"a":1}'); // Result<any, Error>
safeJsonParse('not json'); // Err(SyntaxError)
// With a custom error mapper
const safeReadFile = fromThrowable(
fs.readFileSync,
(caught) => ({ code: 'IO_ERROR', cause: caught } as const),
);fromNullable(value, error) — Convert nullable to a Result
const apiKey = fromNullable(
process.env.API_KEY,
'API_KEY environment variable is not set',
);
// Result<string, string>collect(results) — Combine an array of Results, short-circuit on error
Succeeds only if every Result is Ok. Returns the first error otherwise. Best for homogeneous arrays:
const users = collect([ok(alice), ok(bob), ok(charlie)]);
// Ok([alice, bob, charlie])
const users = collect([ok(alice), err('Bob not found'), ok(charlie)]);
// Err('Bob not found')combine(tuple) — Combine a heterogeneous tuple of Results
Type-aware: each slot in the input tuple preserves its specific value/error types in the output. Use this when the Results have different value types:
const fetchUser: () => Result<User, ApiError>;
const fetchPosts: () => Result<Post[], ApiError>;
const fetchTags: () => Result<Tag[], ApiError>;
const combined = combine([fetchUser(), fetchPosts(), fetchTags()]);
// Result<[User, Post[], Tag[]], ApiError>combineWithAllErrors(tuple) — Combine, accumulating ALL errors
Mirror of combine, but never short-circuits — collects every error in input order. Perfect for form validation:
const result = combineWithAllErrors([
validateName(form.name),
validateEmail(form.email),
validateAge(form.age),
]);
result.match({
ok: ([name, email, age]) => save({ name, email, age }),
err: errors => showAllValidationErrors(errors),
// Err(["name too short", "invalid email"]) — every problem reported
});partition(results) — Split an array of Results into oks and errs
Unlike collect, never short-circuits — every Result is inspected:
const [users, errors] = partition([
ok(alice),
err('Bob not found'),
ok(charlie),
err('Dave not found'),
]);
// users = [alice, charlie]
// errors = ['Bob not found', 'Dave not found']isResult(value) / isOk(value) / isErr(value) — Standalone type guards
When you have an unknown and need to ask "is this a Result?":
function handle(value: unknown) {
if (isOk(value)) {
console.log('got an Ok:', value.value);
} else if (isErr(value)) {
console.log('got an Err:', value.error);
}
}isResult works across realms (Workers, iframes) and across multiple copies of the library on the same page, thanks to a globally-registered Symbol brand.
Real-World Patterns
Type-safe API client
Define your error types as a discriminated union, then use .match() to handle every case:
type ApiError =
| { type: 'network'; message: string }
| { type: 'not_found' }
| { type: 'validation'; fields: string[] };
function fetchUser(userId: string) {
return tryAsync(
() => fetch(`/api/users/${userId}`),
(caught): ApiError => ({ type: 'network', message: String(caught) }),
)
.flatMap(res => {
if (res.status === 404) return err<ApiError>({ type: 'not_found' });
if (!res.ok) return err<ApiError>({ type: 'network', message: `HTTP ${res.status}` });
return ok(res);
})
.map(res => res.json() as Promise<User>);
}
// Every error is visible in the type signature and handled explicitly
const userResult = await fetchUser('123');
userResult.match({
ok: user => renderProfile(user),
err: error => {
switch (error.type) {
case 'not_found': return render404();
case 'network': return renderNetworkError(error.message);
case 'validation': return renderFieldErrors(error.fields);
}
},
});Chaining transformations
Chain multiple operations — the pipeline short-circuits on the first error:
const userEmail = tryCatch(() => JSON.parse(rawPayload))
.map(payload => payload.users)
.flatMap(users =>
fromNullable(
users.find(user => user.id === targetId),
'User not found in payload',
),
)
.map(user => user.email)
.unwrapOr('[email protected]');Recovering from failures with orElse
Try a primary source, fall back to a secondary, then a default:
const config = await fromPromise(fetchRemoteConfig())
.orElse(() => fromPromise(readLocalConfig()))
.orElse(() => okAsync(builtInDefaults))
.unwrap(); // safe — at least one branch always succeedsForm validation with combineWithAllErrors
Show every validation error at once instead of one at a time:
const result = combineWithAllErrors([
required(form.name).mapErr(() => 'Name is required'),
email(form.email).mapErr(() => 'Email must be valid'),
minAge(form.age, 18).mapErr(() => 'Must be 18+'),
]);
result.match({
ok: ([name, email, age]) => createAccount({ name, email, age }),
err: errors => setFormErrors(errors), // ['Email must be valid', ...]
});With Zod validation
import { z } from 'zod';
import { fromThrowable } from 'ts-safe-result';
const UserSchema = z.object({ name: z.string(), age: z.number() });
const parseUser = fromThrowable(
(data: unknown) => UserSchema.parse(data),
(caught) => caught as z.ZodError,
);
const validatedUser = parseUser(requestBody);
validatedUser.match({
ok: user => saveToDatabase(user),
err: zodError => respondWithErrors(zodError.flatten()),
});Wrapping a third-party library with fromThrowable
import jwt from 'jsonwebtoken';
const verifyJwt = fromThrowable(
(token: string, secret: string) => jwt.verify(token, secret) as JwtPayload,
(caught): AuthError =>
caught instanceof jwt.TokenExpiredError
? { kind: 'expired' }
: { kind: 'invalid' },
);
const payload = verifyJwt(token, secret); // Result<JwtPayload, AuthError>Migration from v1
v2 is backward-compatible at the call-site level. Two behavioral changes worth knowing:
tryAsync and fromPromise now return ResultAsync
Existing await tryAsync(...) calls keep working unchanged because ResultAsync is PromiseLike. The win is that you can now chain .map, .flatMap, etc. before awaiting.
// v1 — still works in v2
const result = await tryAsync(() => fetchUser(id));
const email = result.map(u => u.email).unwrapOr('none');
// v2 — new capability
const email = await tryAsync(() => fetchUser(id))
.map(u => u.email)
.unwrapOr('none');ResultAsync transforms catch user-callback throws
In v2, ResultAsync.map / mapErr / flatMap / andThen / orElse catch throws from the user callback and place the error in the chain instead of producing an unhandled rejection. The error type widens to include the standard Error. This means await always resolves to a Result, never throws.
If you previously relied on throws inside .map() propagating as exceptions, wrap with tryCatch / tryAsync instead.
unwrapOr accepts any fallback type
// v1: fallback had to match Value type
ok(42).unwrapOr(0); // ✓
ok(42).unwrapOr('zero'); // ✗ type error
// v2: fallback can be any type, return widens to Value | Fallback
ok(42).unwrapOr('zero'); // ✓ — type is number | stringNew in v2
ResultAsync, okAsync, errAsync, andThen, orElse, expect, fromThrowable, partition, combine, combineWithAllErrors, isResult, isOk, isErr, .catch() / .finally() on ResultAsync.
Comparison
| Feature | ts-safe-result | neverthrow | ts-results | fp-ts |
| -------------------- | -------------- | ---------- | ---------- | ---------- |
| Bundle size (gzip) | ~1.3KB | ~3KB | ~2KB | ~15KB |
| Tree-shakable | Yes (top level) | Partial | No | Yes |
| Async chaining | Yes | Yes | No | TaskEither |
| .match() | Yes | Yes | Yes | Via fold |
| andThen / orElse | Yes | Yes | Partial | Yes |
| combine for tuples | Yes | Yes | No | Yes |
| Catches thrown errors in async transforms | Yes | No | n/a | Yes |
| Cross-realm isResult | Yes | No | No | n/a |
| Learning curve | Minimal | Low | Low | Steep |
| Dependencies | 0 | 0 | 0 | 0 |
Philosophy
This library follows three principles:
- Errors are values, not exceptions. If a function can fail, the failure should be part of the return type.
- Impossible states should be impossible. A
Resultis eitherOkorErr— never both, never neither. - Simple beats clever. No monads, no functors, no category theory. Just
ok,err, and methods you already know.
License
MIT
