DEV Community

Cover image for I ported Rust's Result and Option types to TypeScript
MadKarma ✭
MadKarma ✭

Posted on

I ported Rust's Result and Option types to TypeScript

If you've used Rust, you know how nice it is to have Result<T, E> and Option<T> types that make failure and absence explicit instead of something that blows up at runtime. I wanted that in TypeScript, so I built results-ts.

It's not a novel idea. There are a lot of similar libraries out there. But I wanted something that felt close to the real Rust API, worked with async code without being awkward, and didn't cut corners on type safety.


Installation

npm install results-ts
# or
bun add results-ts
# or
pnpm add results-ts
# or
deno add results-ts
# or
yarn add results-ts
Enter fullscreen mode Exit fullscreen mode

Result

Result<T, E> is either Ok(value) or Err(error). You get a concrete type for both sides, which means TypeScript can actually help you when you handle them.

import { Ok, Err } from 'results-ts';

const parseUserId = (id: string) => {
    const parsed = parseInt(id, 10);
    if (isNaN(parsed))
        return Err({ code: 'INVALID_INPUT', message: 'ID must be a valid number' } as const);
    if (parsed <= 0)
        return Err({ code: 'INVALID_ID', message: 'ID must be positive' } as const);
    return Ok(parsed);
};
Enter fullscreen mode Exit fullscreen mode

From there you can chain operations. .map() transforms the Ok value, .andThen() lets you sequence two fallible operations, and .match() handles both branches:

const fetchUser = (id: number) => {
    if (id === 13) return Err({ code: 'NOT_FOUND', message: 'User not found' } as const);
    return Ok({ id, name: 'Alice', role: 'admin' });
};

const message = parseUserId('10')
    .map((id) => id + 3)
    .andThen(fetchUser)
    .match({
        Ok: (user) => `Welcome, ${user.role} ${user.name}!`,
        Err: (error) => {
            if (error.code === 'NOT_FOUND') return `Database Error: ${error.message}`;
            return `Validation Error: ${error.message}`;
        }
    });
Enter fullscreen mode Exit fullscreen mode

Because as const is used on the error objects, TypeScript knows every possible code value and will complain if you miss one in .match().


Option

Option<T> is Some(value) or None(). It's basically T | null | undefined but with methods on it, so you don't have to break out of the chain to check for emptiness.

import { Some, None } from 'results-ts';

const parseNickname = (nickname?: string) => {
    if (!nickname) return None();
    const trimmed = nickname.trim();
    return trimmed.length > 0 ? Some(trimmed) : None();
};

const displayName = parseNickname('  Ada  ')
    .map((name) => name.toUpperCase())
    .match({
        Some: (name) => name,
        None: () => 'ANONYMOUS'
    });

console.log(displayName); // "ADA"
Enter fullscreen mode Exit fullscreen mode

Wrapping code that throws

You can't always rewrite everything. catchUnwind wraps a throwing function so it returns a Result instead:

import { catchUnwind } from 'results-ts';

const safeParse = catchUnwind(
    JSON.parse,
    (thrown) => thrown instanceof Error ? thrown.message : 'parse error'
);

safeParse('{"a":1}'); // Ok({ a: 1 })
safeParse('{bad');    // Err('Unexpected token ...')
Enter fullscreen mode Exit fullscreen mode

The second argument maps the thrown value to an error type of your choice. Leave it out and the error type becomes unknown, since JS lets you throw anything, that's the honest type.

For async functions there's catchUnwindAsync, which catches both sync throws and rejected promises.


Async

AsyncResult<T, E> and AsyncOption<T> are promise wrappers that keep the same chainable API. No need to await in the middle of a chain just to call .map().

import { catchUnwindAsync } from 'results-ts';

const safeFetch = catchUnwindAsync(
    async (url: string) => {
        const res = await fetch(url);
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
    },
    (thrown) => (thrown instanceof Error ? thrown.message : 'request failed')
);

const result = await safeFetch('https://api.example.com');
//    ^? AsyncResult<unknown, string>
Enter fullscreen mode Exit fullscreen mode

On .unwrap() and panics

Methods like .unwrap() and .expect() deliberately "panic" (throw), same as Rust. The idea is they're for cases where you're certain something is Ok, and if it isn't, you want a loud failure rather than a silent wrong value. They're not for normal error handling.

If a non-panic error comes out of the library, that's a bug on the call site (garbage data, type system bypass, etc.), not something to catch.


Performance

Overhead is minimal, full numbers in BENCHMARKS.md.


Browser / no bundler

It's an ES module, so you can import it straight from a CDN:

<script type="module">
    import { Ok } from 'https://unpkg.com/results-ts/dist/index.js';
    console.log(Ok(1).map((x) => x + 1).unwrap()); // 2
</script>
Enter fullscreen mode Exit fullscreen mode

That's about it. If you try it out, feedback is welcome, issues and PRs are open on GitHub.

Top comments (0)