I've been writing TypeScript for years, and try/catch has always bothered me.
Not because error handling is hard — but because errors are invisible in the type system. A function that says Promise<User> might throw. Or might not. You genuinely can't tell without reading the implementation.
So I went looking for a library that solves this properly.
I tried them all. None of them were quite right.
So I built verdict-ts.
The problem, quickly
// This function signature is lying to you
async function getUser(id: string): Promise<User>
// It can actually do this at runtime
// Uncaught Error: Network timeout
// Uncaught SyntaxError: Unexpected token in JSON
// Uncaught TypeError: Cannot read properties of null
The solution Rust came up with: make failure part of the return type.
// This function tells the truth
async function getUser(id: string): Promise<Result<User, ApiError>>
Now the compiler won't let you access the user without handling the error case first. Failures are visible. The type doesn't lie.
Why not the existing libraries?
Great question — and honestly the main reason I'm writing this post. Let's go through them.
neverthrow
The most popular option, and for good reason — it works well and is actively maintained. But it has one fundamental design choice I kept bumping into:
It's class-based.
import { ok, err } from 'neverthrow';
const result = ok(42);
result instanceof ResultOk; // true
Classes mean prototype chains, and prototype chains cause real problems:
// ❌ Breaks across Worker boundaries
const worker = new Worker('./worker.js');
worker.postMessage(result); // structured clone strips the prototype
// Other side receives a plain object — methods are gone
// ❌ Breaks across iframes
// ❌ JSON.stringify loses the methods
// ❌ structuredClone loses the methods
// This silently fails at runtime even though TypeScript says it's fine
If you're building for Cloudflare Workers, Next.js Edge Runtime, or anything that crosses a serialization boundary — classes are a footgun.
Also, at 112KB unpacked, it's larger than I'd want for a utility that goes into other packages as a dependency.
true-myth
Solid functional programming library. If you want Maybe<T> alongside Result<T, E>, it's excellent.
But: 793KB unpacked. That's not a typo.
It also requires you to buy into its full worldview — Maybe, Task, the whole functional ecosystem. If you just want Result, you're bringing in a lot you won't use.
ts-results
Spiritually the closest to what I wanted. But:
- Last published: May 2022. Three years without an update.
- TypeScript has changed a lot since then — inference has gotten smarter, and ts-results doesn't take advantage of it.
- Issues have been piling up without responses.
- Weaker tuple inference in
combine().
result.ts
Has a dependency (maybe.ts). That immediately ruled it out — if I'm adding this as a dependency to my own packages, I don't want transitive deps creeping in.
What verdict-ts does differently
1. Plain objects, not classes
const result = ok(42);
// Literally: { ok: true, value: 42 }
JSON.stringify(result); // ✅ works
structuredClone(result); // ✅ works
postMessage(result); // ✅ works across Workers
No prototype, no instanceof, no serialization surprises. It's just data.
2. Zero dependencies
{
"dependencies": {}
}
When verdict-ts goes into your SDK as a dependency, nothing comes with it.
3. 491 bytes gzipped
For comparison: neverthrow is ~4KB gzipped, true-myth is ~12KB. verdict-ts is smaller than most SVG icons (491B — yes, really).
4. Proper tuple inference in combine()
Every Result library has combine(). Most of them return Result<T[], E>, which loses the tuple type:
// Other libraries:
combine([ok(1), ok("hello")])
// Result<(number | string)[], Error> ← types are merged, index info lost
// verdict-ts:
combine([ok(1), ok("hello")])
// Result<[number, string], Error> ← tuple preserved, index 0 is number, index 1 is string
This matters for validation:
const result = combine([
validateEmail(email), // Result<string, ValidationError>
validateAge(age), // Result<number, ValidationError>
validateUsername(name), // Result<string, ValidationError>
]);
result.match({
ok: ([email, age, username]) => {
// ^^^^^ string ^^^ number ^^^^^^^^ string
// TypeScript knows all three types at each index
createUser({ email, age, username });
},
err: (e) => showError(e.message),
});
5. AsyncResult<T, E> type alias
A small thing that makes async code much cleaner:
import type { AsyncResult } from 'verdict-ts';
// Instead of this
async function getUser(id: string): Promise<Result<User, ApiError>>
// Write this
async function getUser(id: string): AsyncResult<User, ApiError>
The full API
import {
ok, err, // constructors
trySync, // wrap synchronous throwables
tryAsync, // wrap async throwables
combine, // merge multiple Results
isOk, isErr, // type guards
} from 'verdict-ts';
import type { Ok, Err, Result, AsyncResult } from 'verdict-ts';
Creating Results:
ok(42) // Ok<number>
err(new Error('oops')) // Err<Error>
err({ code: 404 }) // Err<{ code: number }>
trySync(() => JSON.parse(str)) // Result<unknown, Error>
await tryAsync(() => fetch(url)) // Result<Response, Error>
Transforming:
result
.map(value => value * 2) // transform Ok value
.mapErr(e => new AppError(e.message)) // transform Err
.flatMap(value => // Ok → another Result
value > 0 ? ok(value) : err('negative')
)
Extracting:
result.unwrap() // value or throws
result.unwrapOr(0) // value or default
result.match({
ok: v => `got ${v}`,
err: e => `failed: ${e}`,
})
Real-world example: API client
This is the pattern that made me want to build this. When you write an SDK, your functions should tell the truth about what can fail:
import { tryAsync, ok, err } from 'verdict-ts';
import type { AsyncResult } from 'verdict-ts';
type ApiError =
| { kind: 'network'; message: string }
| { kind: 'not_found'; id: string }
| { kind: 'unauthorized' }
async function getUser(id: string): AsyncResult<User, ApiError> {
const response = await tryAsync(() => fetch(`/api/users/${id}`));
return response
.mapErr(e => ({ kind: 'network' as const, message: e.message }))
.flatMap(res => {
if (res.status === 401) return err({ kind: 'unauthorized' as const });
if (res.status === 404) return err({ kind: 'not_found' as const, id });
return tryAsync(() => res.json()).mapErr(e => ({
kind: 'network' as const,
message: e.message,
}));
});
}
// Caller gets full type safety on the error
const result = await getUser('123');
result.match({
ok: (user) => renderProfile(user),
err: (e) => {
switch (e.kind) {
case 'network': return showNetworkError(e.message);
case 'not_found': return showNotFound(e.id);
case 'unauthorized': return redirectToLogin();
}
// TypeScript ensures all cases are handled
},
});
No try/catch. No unknown errors. Every failure mode is in the type.
Quick comparison table
| verdict-ts | neverthrow | true-myth | ts-results | |
|---|---|---|---|---|
| Size (gzipped) | 491B | ~4KB | ~12KB | ~3KB |
| Dependencies | 0 | 0 | 0 | 0 |
| Class-based | ❌ | ✅ | ✅ | ✅ |
| JSON-serializable | ✅ | ❌ | ❌ | ❌ |
| Tuple inference | ✅ | ❌ | ❌ | ❌ |
| AsyncResult type | ✅ | ✅ | ✅ | ❌ |
| Active maintenance | ✅ | ✅ | ✅ | ❌ |
| Edge Runtime safe | ✅ | ⚠️ | ⚠️ | ⚠️ |
Install
npm install verdict-ts
import { ok, err, tryAsync } from 'verdict-ts';
const result = await tryAsync(() =>
fetch('https://api.github.com/users/torvalds').then(r => r.json())
);
const name = result
.map(user => user.name)
.unwrapOr('unknown');
console.log(name); // "Linus Torvalds"
npm: npmjs.com/package/verdict-ts
If this was useful, a ⭐ on GitHub goes a long way — it helps other developers find the project when they're searching for exactly this kind of library.
👉 github.com/flyingsquirrel0419/verdict-ts
What's your current approach to error handling in TypeScript? Still on try/catch, or have you switched to Result types? Would love to hear in the comments 👇
Top comments (0)