Type-safe error handling for TypeScript, inspired by Rust's Result type.
Method chaining, functional composition with pipe(), automatic async
propagation, and optional dependency injection.
# Deno
deno add jsr:@thefridge/skyr
# Bun
bunx jsr add @thefridge/skyr
# pnpm
pnpm add jsr:@thefridge/skyr
# npm
npx jsr add @thefridge/skyr
# Yarn
yarn add jsr:@thefridge/skyrA Result<T, E> is either ok (containing a value of type T) or an err
(containing a structured error with a typed code, a message, and an optional
cause). This replaces try/catch with values you can inspect, transform, and
compose.
import * as R from "@thefridge/skyr";
function validateEmail(email: string) {
if (!email.includes("@")) {
return R.err("INVALID_EMAIL", "Email must contain @");
}
return R.ok(email);
}
const result = validateEmail("user@example.com");
if (result.isOk()) {
console.log(result.value); // "user@example.com"
} else {
console.log(result.code); // "INVALID_EMAIL"
console.log(result.message); // "Email must contain @"
}Error codes are string literals tracked by the type system. TypeScript knows exactly which errors a function can produce and autocompletes them for you.
Results are plain objects with a _tag discriminant:
ok(value)creates{ _tag: "Ok", value, ... }err(code, message, cause?)creates{ _tag: "Err", code, message, cause, ... }
The cause field is undefined when not provided.
Every Result comes with methods for transforming and inspecting it. Type . in
your IDE and see what's available.
There's a subtlety with the example above. Without an explicit return type
annotation, TypeScript infers the return type as
Result<string, never> | Result<never, "INVALID_EMAIL">, a union of two
separate Result types rather than a single unified
Result<string, "INVALID_EMAIL">.
You could fix this by adding a return type annotation, but it's generally safer to let TypeScript infer return types whenever possible. Annotations can drift out of sync with the implementation and mask bugs.
Instead, wrap the function with fn():
import * as R from "@thefridge/skyr";
const validateEmail = R.fn((email: string) => {
if (!email.includes("@")) {
return R.err("INVALID_EMAIL", "Email must contain @");
}
return R.ok(email);
});
// (email: string) => Result<string, "INVALID_EMAIL">fn() collapses all the Result branches into a single, clean Result<T, E>
type. No annotation needed; the ok value type and all possible error codes are
inferred automatically.
This is the simplest use of fn(). It also supports
generator functions for railway-style programming and dependency injection,
covered later.
Results have .isOk() and .isErr() methods that act as type guards:
if (result.isOk()) {
result.value; // T
} else {
result.code; // E
result.message; // string
result.cause; // unknown | undefined
}Standalone functions isOk(), isErr(), and isResult() are also available:
R.isOk(result); // narrows to Ok<T>
R.isErr(result); // narrows to Err<E>
R.isResult(value); // checks if any unknown value is a ResultisResult(value) checks whether any unknown value is a Result. A value is
considered a Result if it's a non-null object with _tag equal to "Ok" or
"Err".
Every Result has methods for transformation, error handling, and value extraction. Chain them directly, no imports or special syntax needed:
const message = validateEmail("User@Example.com")
.map((email) => email.toLowerCase().trim())
.map((email) => `Welcome, ${email}!`)
.match({
ok: (greeting) => greeting,
err: (e) => `Error: ${e.message}`,
});
console.log(message); // "Welcome, user@example.com!"Methods skip over errors automatically. If validateEmail returns an error, the
.map() calls are never executed and the error flows straight to .match().
When any step returns a Promise, the result becomes an AsyncResult, a
wrapper around Promise<Result> with the same methods. This is called async
poison: once async, always async (until you await).
const result = validateEmail("user@example.com") // Result<string, ...>
.map((email) => fetchUser(email)) // returns Promise → AsyncResult
.map((user) => user.name); // still async, still chainable
// Type: AsyncResult<string, ...>
const finalResult = await result;
// Type: Result<string, ...> (back to sync)AsyncResult is PromiseLike, so you can await it to get back a sync
Result with all its methods.
For those who prefer a functional style, pipe() threads a value through a
sequence of functions left-to-right. All methods are also available as
standalone operators:
import * as R from "@thefridge/skyr";
const message = R.pipe(
validateEmail("User@Example.com"),
R.map((email) => email.toLowerCase().trim()),
R.map((email) => `Welcome, ${email}!`),
R.match({
ok: (greeting) => greeting,
err: (e) => `Error: ${e.message}`,
}),
);Standalone operators accept both Result and Promise<Result> as input and
propagate async automatically and work interchangeably with both styles.
If your code calls something that might throw or reject, or you're unsure
whether it could, wrap it with fromThrowable() to convert it into a Result
safely. This is the recommended approach for any code you don't fully control.
If a callback passed to map, mapErr, or match throws synchronously without
being wrapped, a Panic is thrown, halting execution immediately unless caught:
R.ok(42).map(() => {
throw new Error("oops");
});
// Throws: Panic("map() callback threw — use fromThrowable() for unsafe code")
// cause: Error("oops")Panic extends Error, so you get a full stack trace. Catch it at the top
level with instanceof R.Panic if needed.
Async functions (Promises) returned from callbacks are automatically wrapped
with fromThrowable implicitly, so rejections become UNKNOWN_ERR results
instead of Panics. However, if you use multiple async functions, all their
errors will share the same UNKNOWN_ERR code, making it impossible to
distinguish between them. Wrapping each one with fromThrowable and a dedicated
error code is the recommended approach.
Every Result has the following methods. AsyncResult has the same methods,
but they always return AsyncResult (or Promise for terminal operations).
Transforms the ok value. Skips if the result is an error.
R.ok(5)
.map((n) => n * 2)
.map((n) => `Value: ${n}`);
// Result<string, never> → Ok("Value: 10")If fn returns a Result, it's automatically flattened (no nested Results). If
it returns a Promise, the result becomes an AsyncResult. The Promise is
automatically handled like fromThrowable: resolved values become ok, rejected
Promises become UNKNOWN_ERR.
Transforms or recovers from errors. Has two forms:
Function form - transform all errors:
R.err("NOT_FOUND", "User not found")
.mapErr((e) => R.err("DEFAULT_ERROR", e.message));
// Result<never, "DEFAULT_ERROR">Handler object - handle specific error codes with autocomplete:
type AppError = "NOT_FOUND" | "TIMEOUT" | "AUTH_FAILED";
declare function fetchUser(id: string): R.Result<User, AppError>;
const result = fetchUser("123").mapErr({
NOT_FOUND: () => R.ok(guestUser), // recover with ok()
TIMEOUT: () => defaultUser, // recover with plain value (same as ok())
// AUTH_FAILED not listed → passes through unchanged
});
// Result<User, "AUTH_FAILED">Handlers get autocomplete for the available error codes. Each handler receives
the narrowed Err<"CODE"> and can:
- Return
ok(value)or a plain value to recover (both treated as success) - Return
err(code, message)to transform the error
Unhandled codes pass through unchanged.
Pattern match both cases and leave the Result world:
const label = R.ok(42).match({
ok: (n) => `Got ${n}`,
err: (e) => `Error: ${e.code}`,
});
// "Got 42"If either handler returns a Result, the output is a Result. Otherwise it's a
plain value. On AsyncResult, .match() returns a Promise.
Run side effects (logging, metrics) without changing the Result:
validateEmail("user@example.com")
.inspect((email) => console.log("Valid:", email))
.inspectErr((e) => console.error("Failed:", e.code))
.map((email) => email.toLowerCase());The callback's return value is ignored; the original Result is always returned unchanged. If the callback throws or the returned Promise rejects, the error is silently swallowed and the original Result passes through. Side effects should never break the pipeline.
Extract values from Results:
// unwrap() extracts the ok value, or returns undefined on error
const value = R.ok(42).map((n) => n * 2).unwrap();
// number | undefined → 84
const missing = R.err("NOT_FOUND", "gone").unwrap();
// undefined
// Works great with optional chaining
fetchUser("123").unwrap()?.name;
// Or non-null assertion when you know it's Ok
R.ok(42).unwrap()!;
// unwrapOr() extracts the ok value, or returns the default on error
const fallback = R.err("ERROR", "Something went wrong").unwrapOr(0);
// 0On AsyncResult, .unwrap() returns Promise<T | undefined> and
.unwrapOr(default) returns Promise<T | D>.
Convert code that throws (or Promises that reject) into Results:
// Wrap a function call
const result = R.fromThrowable(
() => JSON.parse('{"name": "Alice"}'),
(err) => R.err("PARSE_ERROR", "Invalid JSON", err),
);
// Result<any, "PARSE_ERROR">
// Wrap a Promise
const response = await R.fromThrowable(
fetch("https://api.example.com"),
(err) => R.err("FETCH_ERROR", "Request failed", err),
);
// Result<Response, "FETCH_ERROR">Without a mapper, errors become "UNKNOWN_ERR".
The function overload calls the function synchronously and catches any thrown error. If you have a Promise, use the Promise overload directly.
Like fromThrowable, but returns a reusable wrapper function:
const safeParse = R.wrapThrowable(
(str: string) => JSON.parse(str),
(err) => R.err("PARSE_ERROR", "Invalid JSON", err),
);
safeParse('{"valid": true}'); // Ok({valid: true})
safeParse("nope"); // Err("PARSE_ERROR")fn() is a function builder. You pass it a generator and get back a function
you can call just like any other. The difference is that inside the generator
you get two superpowers:
-
Dependency requests -
yield* R.use(Dep)gives you an implementation of a dependency without worrying about how to acquire it. Think of it like a function parameter, except you don't have to pass it in at the call site. When onefn()function calls another, unmet dependencies propagate up automatically, no prop drilling required. You can also choose to supply some dependencies but not all, and the rest keep propagating. -
Result unwrapping -
yield* someResultextracts the success value from anyResultorAsyncResult. If it's an error, the function short-circuits (early return) and the error propagates to the caller, just like the non-generator version (fn(() => ...)).
The return type of an fn() function is Fn<Args, Success, Errors, Deps>:
- Args - the parameter list of your generator (e.g.
(email: string)→[string]) - Success - the unwrapped success type
- Errors - a union of all error codes, accumulated from every
yield*call - Deps - a union of all unmet dependencies, accumulated from every
yield* R.use()call
The Fn you get back is only callable once all dependencies are injected (via
.inject()), at which point the Deps part of the type becomes never.
const Database = R.dependency<{
findUser: (email: string) => Promise<User | null>;
}>()("database");
const Logger = R.dependency<{
info: (msg: string) => void;
}>()("logger");Convention: Dependencies and functions that still need injection use PascalCase. After injection, use camelCase to signal "ready to call."
const GetUser = R.fn(function* (email: string) {
const db = yield* R.use(Database);
const logger = yield* R.use(Logger);
logger.info(`Looking up ${email}`);
const validEmail = yield* validateEmail(email);
const user = yield* R.fromThrowable(db.findUser(validEmail));
if (!user) return R.err("NOT_FOUND", "User not found");
return R.ok(user);
});
// Type: Fn<[string], User, "INVALID_EMAIL" | "NOT_FOUND" | "UNKNOWN_ERR", Database | Logger>Key points:
yield* R.use(Database)- acquires a dependency from the DI contextyield* validateEmail(email)- unwraps a Result; short-circuits on failureyield* R.fromThrowable(...)- catches throws/rejections, converting them to a Result, then unwraps it- Error types accumulate automatically across all
yield*calls - Dependency types accumulate automatically across all
yield* R.use()calls
Use .inject() to provide implementations:
const getUser = GetUser.inject(
Database.impl({ findUser: async (email) => db.query(email) }),
Logger.impl({ info: console.log }),
);
// All dependencies satisfied, now callable
const result = await getUser("user@example.com");If you try to call a function before all dependencies are injected, TypeScript shows an error:
ERROR - Missing dependencies: database, logger. Use inject() first.
At runtime, calling with missing dependencies throws an Error with a message
like Missing dependency: "database". Use inject() to provide this dependency.
Injection can be done incrementally:
const withDb = GetUser.inject(Database.impl({/* ... */}));
// Still needs Logger
const getUser = withDb.inject(Logger.impl({/* ... */}));
// Fully callableThe standalone inject() operator works the same way inside pipe():
const getUser = R.pipe(
GetUser,
R.inject(
Database.impl({ findUser: async (email) => db.query(email) }),
Logger.impl({ info: console.log }),
),
);When one fn() uses another via yield* R.use(ChildFn), the child's
dependencies are inherited by the parent:
const CheckPermissions = R.fn(function* (userId: string) {
const db = yield* R.use(Database);
// ...
return R.ok(canAccess);
});
const LoginUser = R.fn(function* (email: string, password: string) {
const logger = yield* R.use(Logger);
const user = yield* getUser(email);
const checkPerms = yield* R.use(CheckPermissions);
const canAccess = yield* checkPerms(user.id);
return R.ok(user);
});
// Dependencies: Logger | Database (Database inherited from CheckPermissions)The two-step pattern, yield* R.use(Fn) then yield* callable(args), separates
dependency resolution from execution, keeping the control flow explicit.
| Function | Description |
|---|---|
ok(value) |
Create an Ok result with methods |
err(code, message, cause?) |
Create an Err result with methods |
| Function / Method | Description |
|---|---|
.isOk() |
Narrow to Ok<T> (method) |
.isErr() |
Narrow to Err<E> (method) |
isOk(result) |
Narrow to Ok<T> (standalone) |
isErr(result) |
Narrow to Err<E> (standalone) |
isResult(value) |
Check if value has _tag of "Ok" or "Err" |
| Method | Description |
|---|---|
.map(fn) |
Transform ok value; Panics on sync throw |
.mapErr(fn) |
Transform error; plain values treated as recovery |
.mapErr({ CODE: handler }) |
Handle specific error codes; Panics on sync throw |
.match({ ok, err }) |
Pattern match both cases; Panics on sync throw |
.inspect(fn) |
Side effect on ok; errors silently swallowed |
.inspectErr(fn) |
Side effect on error; errors silently swallowed |
.unwrap() |
Extract ok value or return undefined |
.unwrapOr(default) |
Extract ok value or return default |
AsyncResult<T, E> wraps a Promise<Result<T, E>> and exposes the same methods
as Result. All methods return AsyncResult (async poison), except terminal
operations (.match(), .unwrap(), .unwrapOr()) which return Promise.
AsyncResult is PromiseLike; await it to get a sync Result.
| Operator | Description |
|---|---|
map(fn) |
Transform ok value; Panics on sync throw |
mapErr(fn) |
Transform error; plain values treated as recovery |
mapErr({ CODE: handler }) |
Handle specific error codes; Panics on sync throw |
match({ ok, err }) |
Pattern match both cases; Panics on sync throw |
inspect(fn) |
Side effect on ok; errors silently swallowed |
inspectErr(fn) |
Side effect on error; errors silently swallowed |
unwrap |
Extract ok value or return undefined |
unwrapOr(default) |
Extract ok value or return default |
| Function | Description |
|---|---|
fromThrowable(fn, mapper?) |
Convert throwing function to Result |
fromThrowable(promise, mapper?) |
Convert Promise to async Result |
wrapThrowable(fn, mapper?) |
Wrap function to return Results |
| Type | Description |
|---|---|
Panic |
Thrown on sync throw in operator callbacks; extends Error |
| Function | Description |
|---|---|
dependency<T>()(key) |
Declare a dependency type |
fn(generator) |
Create function with DI and Result unwrapping |
fn(func) |
Unify Result return type |
use(dep) |
Acquire dependency inside a generator |
use(Fn) |
Get contextualized callable for nested Fn |
.inject(...impls) |
Provide dependency implementations (method) |
inject(...impls) |
Provide dependency implementations (operator) |
MIT