DEV Community

날다람쥐
날다람쥐

Posted on

I tried every TypeScript Result library. So I built a better one.

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
Enter fullscreen mode Exit fullscreen mode

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>>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

No prototype, no instanceof, no serialization surprises. It's just data.

2. Zero dependencies

{
  "dependencies": {}
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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),
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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')
  )
Enter fullscreen mode Exit fullscreen mode

Extracting:

result.unwrap()           // value or throws
result.unwrapOr(0)        // value or default
result.match({
  ok: v => `got ${v}`,
  err: e => `failed: ${e}`,
})
Enter fullscreen mode Exit fullscreen mode

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
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode

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)