crab-salad
v4.0.0
Published
A lightweight TypeScript library for Result<T, E> and Option<T>, inspired by Rust.
Maintainers
Readme
Crab Salad
A lightweight TypeScript library for
Result<T, E>andOption<T>, inspired by Rust.
No dependencies. Full type safety. Chainable API.
Disclaimer
This project provides a beautiful and expressive way to handle errors and optional values using functional-style constructs like Result and Option. It aims to improve code readability and robustness in a way inspired by languages like Rust.
However, this approach does not follow the idiomatic or native error-handling patterns of JavaScript/TypeScript, such as exceptions and nullable types.
Therefore, while it's a useful learning tool or for internal experimentation, I do not recommend using this in production environments.
Use it to explore better patterns -- but weigh it against the expectations and conventions of the JS/TS ecosystem.
Installation
npm install crab-saladResult<T, E>
Represents either a successful value (Ok) or an error (Err).
Chaining
import { Result, Ok, Err } from 'crab-salad';
type Config = { host: string; port: number };
const parseConfig = (raw: string): Result<Config, string> =>
Result.tryCatch(
() => JSON.parse(raw) as Record<string, unknown>,
() => 'Invalid JSON',
)
.andThen((obj) =>
typeof obj.host === 'string' && typeof obj.port === 'number'
? Ok(obj as unknown as Config)
: Err('Missing fields'),
)
.andThen((cfg) =>
cfg.port > 0 && cfg.port <= 65535
? Ok(cfg)
: Err(`Port ${cfg.port} out of range`),
)
.inspectOk((cfg) => console.log(`Loaded config: ${cfg.host}:${cfg.port}`))
.inspectErr((e) => console.warn(`Config error: ${e}`));
const address = parseConfig('{"host":"localhost","port":8080}')
.map((cfg) => `https://${cfg.host}:${cfg.port}`) // Result<string, string>
.mapErr((e) => new Error(e)) // Result<string, Error>
.match({
ok: (url) => url,
err: (e) => `https://fallback.local (${e.message})`,
});
console.log(address); // "https://localhost:8080"Wrapping and unwrapping
Convert freely between Result and native types like Promise and try/catch.
import { Result } from 'crab-salad';
// try/catch -> Result -> transform -> back to value
const parsed: object = Result.tryCatch(
() => JSON.parse(rawInput),
(e) => `Invalid JSON: ${String(e)}`,
)
.map((obj) => obj as object) // Result<object, string>
.inspectErr((e) => console.warn(e))
.unwrapOr({}); // object
// Promise -> Result -> transform -> back to Promise
const name: string = await Result.fromPromise(
fetch('/api/users/1').then((r) => r.json()),
(e) => `Network error: ${e}`,
)
.then((result) =>
result
.map((u) => u.name as string) // Result<string, string>
.toPromise(), // -> Promise<string>
);
// async function -> Result -> transform -> back to Promise
const rows = await Result.fromAsyncFn(
async () => db.query('SELECT * FROM users'),
(e) => `DB error: ${String(e)}`,
);
await rows
.map((r) => r.rows) // Result<Row[], string>
.toPromise(); // -> Promise<Row[]>API
result.and(other)result.andThen(fn)result.err()result.expect(msg)result.expectErr(msg)result.flatten()result.inspectOk(fn)result.inspectErr(fn)result.isErrresult.isErrAnd(fn)result.isOkresult.isOkAnd(fn)result.map(fn)result.mapErr(fn)result.mapOr(other, fn)result.mapOrElse(otherFn, fn)result.ok()result.or(other)result.orElse(fn)result.transpose()result.unwrap()result.unwrapErr()result.unwrapOr(other)result.unwrapOrElse(fn)
Result.fromPromise(promise, mapErr)Result.fromAsyncFn(fn, mapErr)result.match({ ok, err })result.toPromise()Result.tryCatch(fn, mapErr)result.unzip()result.zip(other)result.zipWith(other, fn)
Option<T>
Represents an optional value: Some(value) or None.
Chaining
import { Option, Some, None } from 'crab-salad';
type User = { name: string; email: string | null; age: number };
const users: User[] = [
{ name: 'Alice', email: '[email protected]', age: 30 },
{ name: 'Bob', email: null, age: 17 },
];
const greet = (name: string): string =>
Option.fromNullable(users.find((u) => u.name === name))
.filter((u) => u.age >= 18) // Option<User> -> Option<User>
.andThen((u) => Option.fromNullable(u.email)) // Option<User> -> Option<string>
.map((email) => email.split('@')[0]) // Option<string> -> Option<string>
.inspectSome((id) => console.log(`Resolved: ${id}`))
.match({
some: (id) => `Hello, ${id}!`,
none: () => 'Hello, guest!',
});
console.log(greet('Alice')); // "Hello, alice!"
console.log(greet('Bob')); // "Hello, guest!" (underage)
console.log(greet('Eve')); // "Hello, guest!" (not found)Wrapping and unwrapping
Convert freely between Option and nullable/undefined values.
import { Option } from 'crab-salad';
// string | null -> Option -> transform -> back to string | null
const header: string | null = request.headers.get('Authorization');
const token: string | null = Option.fromNullable(header)
.map((h) => h.replace('Bearer ', ''))
.filter((t) => t.length > 0)
.toNullable(); // Option<string> -> string | null
// string | undefined -> Option -> transform -> back to string | undefined
const rawPort: string | undefined = process.env.PORT;
const port: string | undefined = Option.fromNullable(rawPort)
.filter((p) => /^\d+$/.test(p))
.toUndefined(); // Option<string> -> string | undefined
// Option -> Result (bridge the two types)
const validPort = Option.fromNullable(process.env.PORT)
.map(Number)
.filter((n) => n > 0 && n <= 65535)
.okOr('PORT is missing or invalid'); // Option<number> -> Result<number, string>
console.log(validPort.unwrapOr(3000)); // numberAPI
option.and(other)option.andThen(fn)option.expect(msg)option.filter(fn)option.flatten()option.inspectSome(fn)option.isNoneoption.isNoneOr(fn)option.isSomeoption.isSomeAnd(fn)option.map(fn)option.mapOr(other, fn)option.mapOrElse(fn, fn)option.okOr(err)option.okOrElse(fn)option.or(other)option.orElse(fn)option.transpose()option.unwrap()option.unwrapOr(other)option.unwrapOrElse(fn)option.unzip()option.xor(other)option.zip(other)option.zipWith(other, fn)
Result + Option together
The two types compose naturally:
import { Result, Option, Ok, Err } from 'crab-salad';
const parsePort = (raw: string): Result<number, string> =>
Result.tryCatch(() => Number(raw), () => 'Not a number')
.andThen((n) => (n > 0 && n <= 65535 ? Ok(n) : Err(`${n} out of range`)));
const findHost = (name: string): Option<string> =>
Option.fromNullable(name.trim() || null)
.filter((h) => h.includes('.'));
const url = parsePort('443')
.ok() // Result<number, string> -> Option<number>
.zip(findHost('example.com')) // Option<[number, string]>
.map(([port, host]) => `https://${host}:${port}`)
.okOr('Invalid server configuration') // Option<string> -> Result<string, string>
.mapErr((e) => new Error(e)) // Result<string, Error>
.unwrapOr('https://fallback.local');
console.log(url); // "https://example.com:443"Docs
See Docs for full working examples of each method.
Running Tests
npm run testUses Vitest for fast TypeScript unit testing.
Changelog
See CHANGELOG.md for a list of changes.
License
MIT
