typed-error-execution
Errors that live in the type system. A
Result<T, E> either succeeded with a
T or failed with one of the tagged errors in
E.
Errors are ordinary classes carrying a literal _tag, so
TypeScript tracks exactly which failures a call can still produce —
and handleError() removes them from that union
one at a time until nothing is left. When the union
reaches never, the compiler knows the value is safe.
Inspired by Effect and neverthrow, but deliberately small: one type, one error convention, no generators, no runtime, no fibers.
Getting started
Install#
npm i @dmytromykhailiuk/typed-error-execution
No peer dependencies, no runtime to configure, nothing to register at startup. ESM and CJS builds ship side by side with separate declaration files.
TypeScript 5.0 or newer. The library leans hard on inference, and all() uses const type parameters, which landed in 5.0. Everything else works further back, but 5.0 is the supported floor.
Motivation
The idea#
Here is a signature from a real service. It tells you nothing about the five ways it can fail, and nothing breaks when a sixth is added:
async function registerUser(input: SignUpInput): Promise<User>;
Somewhere inside it there is a uniqueness check, a password policy, a database write and a call to a payment provider. Each can fail. The caller finds out by reading the implementation — or in production.
This library moves failure into the return type, where the compiler can see it:
function registerUser(
input: SignUpInput,
): AsyncResult<User, ValidationFailed | EmailAlreadyRegistered | DatabaseUnavailable>;
Three properties follow, and they are the whole point.
Adding a plan check to registerUser shows up as a type change at every call site. There is no equivalent of a throw that quietly travels four frames up and lands in a framework error handler as a 500.
Every handleError() call removes exactly the classes you named from the union. When your HTTP layer has mapped the last one, the union is never and the compiler knows the response is total — you did not assert that, you proved it.
Failures are ordinary class instances. Matching is instanceof. A Result is an object with a boolean and two slots. The whole implementation reads in one sitting, which matters when it sits between you and every request your service serves.
What it deliberately is not#
No effect system, no dependency injection, no generator syntax, no retry or scheduling combinators, no runtime. If you want those, Effect is excellent and this is not trying to replace it — this is the layer underneath: typed errors, and nothing else.
The design is borrowed openly. The Result shape comes from neverthrow, the tagged-error convention from Effect. The contribution here is subtractive handleError: handling failures by class and watching the union shrink to never.
Quick start#
A sign-up flow, end to end: declare the failures, register the function, chain it, then map what is left onto an HTTP response.
import { Result, Tagged, TaggedError } from "@dmytromykhailiuk/typed-error-execution";
// 1. Failures are classes with a literal tag, carrying the data the caller
// needs to render a message or make a decision.
class ValidationFailed extends Tagged("ValidationFailed") {
constructor(
readonly field: string,
readonly reason: string,
) {
super();
}
}
class EmailAlreadyRegistered extends Tagged("EmailAlreadyRegistered") {
constructor(readonly email: string) {
super();
}
}
// An infrastructure failure extends the native Error, so it has a stack and
// logs usefully. A domain failure does not need one.
class DatabaseUnavailable extends TaggedError("DatabaseUnavailable") {}
// 2. registerExecution freezes the error union into the signature.
const registerUser = Result.registerExecution(async (input: SignUpInput) => {
if (input.password.length < 12) {
return Result.err(new ValidationFailed("password", "must be at least 12 characters"));
}
const existing = await db.users.findByEmail(input.email);
if (existing) return Result.err(new EmailAlreadyRegistered(input.email));
return Result.ok(await db.users.insert(input));
});
// (input: SignUpInput) => AsyncResult<User, ValidationFailed | EmailAlreadyRegistered>
// 3. Chain, then map the union onto a response.
app.post("/signup", async (req, res) => {
const settled = await registerUser(req.body)
.tap((user) => analytics.track("signup", { userId: user.id }))
.getResult(); // a chain resolves to a Result; the terminals live there
const response = settled.match({
ok: (user) => ({ status: 201, body: publicProfile(user) }),
err: (e) => {
switch (e._tag) {
case "ValidationFailed":
return { status: 422, body: { field: e.field, reason: e.reason } };
case "EmailAlreadyRegistered":
return { status: 409, body: { error: "that email is already in use" } };
}
},
});
res.status(response.status).json(response.body);
});
The synchronous half looks the same, minus the await. Nothing about the API changes between the two:
const parsePlan = Result.registerExecution((raw: string) => {
if (raw === "free" || raw === "pro") return Result.ok(raw);
return Result.err(new ValidationFailed("plan", `unknown plan: ${raw}`));
});
// (raw: string) => Result<"free" | "pro", ValidationFailed>
const plan = parsePlan(req.query.plan).unwrapOr("free"); // no await, no promise
Core concepts
Declaring failures#
A failure is a class extending Tagged(tag). The tag is a string literal, and that literal is what makes the union in Result<T, E> meaningful.
import { Tagged } from "@dmytromykhailiuk/typed-error-execution";
class UserNotFound extends Tagged("UserNotFound") {
constructor(readonly userId: string) {
super();
}
}
class SubscriptionRequired extends Tagged("SubscriptionRequired") {
constructor(
readonly currentPlan: string,
readonly requiredPlan: string,
) {
super();
}
}
class RateLimited extends Tagged("RateLimited") {
constructor(readonly retryAfterSeconds: number) {
super();
}
}
Carry whatever the caller will need. RateLimited.retryAfterSeconds becomes a Retry-After header; SubscriptionRequired.requiredPlan becomes the upgrade button. That data stays fully typed inside a handler at the far end of the chain, which is the difference between a useful failure and a log line.
Tagged vs TaggedError#
Two factories, and the choice comes down to whether anyone will ever read a stack trace for this failure.
Tagged | TaggedError | |
|---|---|---|
Extends Error | no | yes |
message / stack | no | yes |
| Construction cost | a plain object | captures a stack trace |
| Reach for it when | the failure is an expected outcome | the failure means something is broken |
| Typical example | UserNotFound, RateLimited | DatabaseUnavailable, PaymentGatewayError |
import { TaggedError } from "@dmytromykhailiuk/typed-error-execution";
class DatabaseUnavailable extends TaggedError("DatabaseUnavailable") {}
class PaymentGatewayError extends TaggedError("PaymentGatewayError") {}
const err = new DatabaseUnavailable("connection pool exhausted after 5000ms");
err._tag; // "DatabaseUnavailable"
err.name; // "DatabaseUnavailable"
err.message; // "connection pool exhausted after 5000ms"
err.stack; // a real stack trace
err instanceof Error; // true — Sentry, pino and friends handle it correctly
logger.error({ err }, "query failed"); // serialises like any other Error
Capturing a stack trace is by far the most expensive part of creating an error. This user does not exist is not an exceptional event — it is a Tuesday — and paying for a stack on every 404 in a hot path is waste. A database that will not answer is a different matter: you want the trace, and you want it in your error reporter.
Each call to Tagged() returns a distinct class. Two failures that happen to share a tag string are still separate under instanceof, so handleError will never confuse them.
Subclasses inherit the parent's tag. class CardExpired extends PaymentDeclined {} has _tag === "PaymentDeclined". Since matching is instanceof, handling PaymentDeclined also handles CardExpired — usually what you want for a family of related failures. Give the subclass its own Tagged("CardExpired") base when the two must be told apart in a switch.
Creating results#
Result.ok(user); // Result<User, never>
Result.ok(); // Result<void, never> — a command succeeded
Result.empty(); // Result<null, never> — nothing to return
Result.err(new UserNotFound("u_8123")); // Result<never, UserNotFound>
ok() and empty() differ in intent: void is the absence of a value — a DELETE that worked — while null is a value you can branch on, such as a cache lookup that legitimately found nothing.
const revokeSession = Result.registerExecution(async (sessionId: string) => {
const removed = await db.sessions.delete(sessionId);
if (!removed) return Result.err(new SessionNotFound(sessionId));
return Result.ok(); // nothing to hand back, and that is the point
});
const cached = Result.registerExecution(async (key: string) => {
const hit = await redis.get(key);
return hit ? Result.ok(JSON.parse(hit)) : Result.empty(); // a real "no value"
});
The literal-tag rule#
Result.err() rejects a failure whose _tag has widened to string, because a widened tag silently collapses the union and switches error tracking off entirely:
class HandRolled {
readonly _tag: string = "HandRolled"; // ← widened, not a literal
}
Result.err(new HandRolled());
// Argument of type 'HandRolled' is not assignable to parameter of type
// '{ ERROR: "_tag must be a string literal — declare the class as
// `class X extends Tagged('X') {}`" }'
Extending Tagged() always produces a literal, so in practice you never meet this rule — it exists to catch a hand-rolled failure shape before it quietly breaks inference across a whole service.
The dispatch rule
One method, sync or async#
There is no mapValueAsync, no tryAsync, no registerAsyncExecution. Every method takes a callback and looks at what the callback actually returned:
The chain stays synchronous. No promise is created, nothing to await, and the value is readable on the next line — what you want for parsing a request body or validating a form.
The rest of the chain is asynchronous. Finish it with getResult() or a terminal. This is the shape of anything that touches a database, a queue or an HTTP API.
// validating a request body: no I/O, so no promise anywhere
const plan = parsePlan(req.body.plan).unwrapOr("free");
// loading a user: I/O, so the chain is asynchronous from here on
const profile = await loadUser(userId)
.mapValue((user) => Result.ok(publicProfile(user)))
.getResult();
// It is about the value, not the keyword — anything promise-shaped counts.
Result.ok(userId).mapValue((id) => db.users.findById(id)); // async
Result.ok(userId).mapValue((id) => loadUser(id)); // async: loadUser is
The same rule governs every entry point:
| Call | Synchronous when | Asynchronous when |
|---|---|---|
mapValue · mapError · handleError | the callback returns a result | it returns a promise or an async chain |
tap · tapError | the effect returns nothing | the effect returns a promise (which is awaited) |
Result.try | the body returns a value | the body returns a promise |
Result.registerExecution | the body returns a result | the body is async |
Result.all · Result.collect | every member is synchronous | any member is asynchronous |
Types follow exactly the same rule, so the editor agrees with the runtime. A callback with one synchronous branch and one asynchronous branch counts as asynchronous — the safe reading.
// a cache read that only hits the network on a miss
const config = Result.ok(key).mapValue((k) =>
memory.has(k) ? Result.ok(memory.get(k)!) : fetchConfig(k),
);
// asynchronous — because it might be
Skipped steps#
A chain short-circuits: Result.err(e).mapValue(fn) never calls fn. There is no returned value to inspect, so the step reads the callback itself — an async function is identifiable at runtime, and the chain becomes a real asynchronous one even though nothing ran.
const chain = Result.err(new UserNotFound("u_1")).mapValue(async (u: User) =>
Result.ok(await enrich(u)),
);
// an async chain, in the type and at runtime — enrich was never called
// a synchronous callback keeps the step synchronous, so the value is right here
Result.err(new UserNotFound("u_1")).mapValue((u: User) => Result.ok(u.email)).error;
The key trick
Registering executions#
Left alone, TypeScript infers a function with several return branches as a union of results:
const chargeSubscription = async (userId: string, cents: number) => {
const user = await db.users.findById(userId);
if (!user) return Result.err(new UserNotFound(userId));
if (!user.paymentMethodId) return Result.err(new NoPaymentMethod(userId));
const charge = await stripe.charges.create({ amount: cents, customer: user.stripeId });
if (charge.status === "failed") return Result.err(new PaymentDeclined(charge.failureCode));
return Result.ok(charge);
};
// Promise<Result<never, UserNotFound> | Result<never, NoPaymentMethod>
// | Result<never, PaymentDeclined> | Result<Charge, never>>
That type is nearly impossible to work with: mapValue on a union of results has to typecheck against every member. registerExecution collapses it into one result whose error parameter is the union — which is what you meant all along:
const chargeSubscription = Result.registerExecution(
async (userId: string, cents: number) => {
const user = await db.users.findById(userId);
if (!user) return Result.err(new UserNotFound(userId));
if (!user.paymentMethodId) return Result.err(new NoPaymentMethod(userId));
const charge = await stripe.charges.create({ amount: cents, customer: user.stripeId });
if (charge.status === "failed") return Result.err(new PaymentDeclined(charge.failureCode));
return Result.ok(charge);
},
);
// (userId: string, cents: number)
// => AsyncResult<Charge, UserNotFound | NoPaymentMethod | PaymentDeclined>
A synchronous body needs no different call — it simply yields a Result instead of a chain:
const parseWebhookEvent = Result.registerExecution((raw: unknown) => {
if (typeof raw !== "object" || raw === null) {
return Result.err(new MalformedWebhook("body is not an object"));
}
if (!("type" in raw)) return Result.err(new MalformedWebhook("missing 'type'"));
return Result.ok(raw as StripeEvent);
});
// (raw: unknown) => Result<StripeEvent, MalformedWebhook>
It does not catch exceptions. A throw inside the body still propagates, and an async body still rejects. That is deliberate: a throw is a bug, a failure is an outcome. Use Result.try when you want to convert one into the other — which is exactly what you do at the edge of an SDK that throws.
Chaining
Transforming#
mapValue — the workhorse#
Runs on success, passes failures straight through. The callback returns a result, so it may introduce new failures — those are added to the union.
loadUser(userId) // AsyncResult<User, UserNotFound>
.mapValue((user) => requirePlan(user, "pro")) // + SubscriptionRequired
.mapValue((user) => loadWorkspace(user.orgId)) // + WorkspaceArchived
.mapValue((ws) => Result.ok(serialise(ws))); // no new failures
// AsyncResult<WorkspaceDTO,
// UserNotFound | SubscriptionRequired | WorkspaceArchived>
A failure short-circuits everything after it. If the user does not exist, requirePlan and loadWorkspace never run — no wasted query, no null check.
mapError#
Runs on failure, passes successes through. It sees the whole union at once, so the resulting error type is replaced, not narrowed. Useful at a boundary where the caller has no business knowing your internals:
// a public SDK method: collapse everything into one documented failure
const fetchInvoice = Result.registerExecution(async (id: string) =>
loadInvoice(id)
.mapError((e) => {
logger.warn({ tag: e._tag }, "invoice lookup failed");
return Result.err(new InvoiceUnavailable(id));
})
.getResult(),
);
// AsyncResult<Invoice, InvoiceUnavailable>
To deal with specific failures and leave the rest alone, reach for handleError instead.
tap and tapError#
Side effects that do not touch the value — logging, metrics, audit trails. The callback's return value is ignored, but if it is a promise the chain turns asynchronous and waits for it.
placeOrder(cart)
.tap((order) => metrics.increment("orders.placed", { plan: order.plan }))
.tapError((e) => logger.warn({ tag: e._tag, cartId: cart.id }, "checkout failed"))
.tap(async (order) => await audit.record("order.created", order.id)); // awaited
tapError is where infrastructure failures earn their TaggedError base: logger.warn({ err }) gets a real stack trace for a DatabaseUnavailable, and a compact object for a UserNotFound that never needed one.
Handling failures#
handleError takes one or more classes followed by a handler, and removes exactly those from the union.
// AsyncResult<Dashboard, UserNotFound | SubscriptionRequired | DatabaseUnavailable>
const dashboard = loadDashboard(userId)
.handleError(UserNotFound, () => Result.ok(emptyDashboard))
// AsyncResult<Dashboard, SubscriptionRequired | DatabaseUnavailable>
.handleError(SubscriptionRequired, (e) => Result.ok(upsellDashboard(e.requiredPlan)))
// AsyncResult<Dashboard, DatabaseUnavailable>
.handleError(DatabaseUnavailable, () => Result.ok(staleDashboardFromCache(userId)));
// AsyncResult<Dashboard, never> ← nothing left to handle
const view = (await dashboard.getResult()).unwrap(); // safe, and the compiler knows it
The handler's parameter is narrowed to the classes you listed, so the data the failure carries is right there:
callExternalApi(request)
.handleError(RateLimited, async (e) => {
// e.retryAfterSeconds: number
await sleep(e.retryAfterSeconds * 1000);
return callExternalApi(request).getResult();
})
.handleError(PaymentDeclined, CardExpired, (e) => {
// e: PaymentDeclined | CardExpired
return Result.err(new CheckoutFailed(e._tag));
});
A handler may also convert one failure into another. The new one lands back in the union — this is how a layer re-tags what it cannot fix:
loadRow(id).handleError(DatabaseUnavailable, (e) => Result.err(new ServiceDegraded(e)));
// AsyncResult<Row, RowNotFound | ServiceDegraded>
Three rules worth knowing#
Handling a class also handles every subclass of it. One handleError(PaymentDeclined, …) covers CardExpired, InsufficientFunds and any other member of that family.
Converting DatabaseUnavailable into ServiceDegraded and handling ServiceDegraded later in the chain works exactly as written. There is no loop.
If you list both a class and its subclass and the instance matches both, the handler still runs a single time.
// instanceof matching: a subclass is handled by its parent's entry
class CardExpired extends PaymentDeclined {}
Result.err(new CardExpired("exp_2019"))
.handleError(PaymentDeclined, () => Result.ok(retryWithBackupCard())); // ✓ handled
// but not the other way around
Result.err(new PaymentDeclined("do_not_honour"))
.handleError(CardExpired, () => Result.ok(promptForNewCard())); // ✗ passes through
// no re-entry: the second handler never fires
Result.err(new DatabaseUnavailable("timeout"))
.handleError(DatabaseUnavailable, (e) => Result.err(new ServiceDegraded(e)))
.handleError(DatabaseUnavailable, () => Result.ok(cached)); // unreachable
Async
Asynchronous chains#
An asynchronous chain has a deliberately small surface: the five chaining methods, plus getResult(). Nothing reads a value — there is nothing to read until the chain settles. See the dispatch rule for how a chain becomes asynchronous in the first place.
| Member | on Result | on an async chain |
|---|---|---|
mapValue · mapError · handleError · tap · tapError | yes | yes — identical signature |
getResult() | yes | yes — identical signature |
match() · unwrap() · unwrapError() · unwrapOr() · unwrapOrElse() | yes | no — call getResult() first |
value · error · isOk · isErr | yes | no — nothing to read yet |
toAsync() | yes | no — already one |
Read the table as a subset: every member an asynchronous chain exposes also exists on Result, with the same signature. That is not a coincidence — it is what makes a short-circuited step safe, and it is asserted by a test.
Finishing a chain#
A chain is not a thenable. Finish it with getResult(), or with a terminal — those resolve on their own.
const result = await loadUser(userId).getResult(); // Result<User, UserNotFound>
// the terminals live on the Result, so resolve first
const user = result.unwrapOr(guestUser);
const status = result.match({ ok: () => 200 as const, err: () => 404 as const });
await loadUser(userId); // ✗ not thenable — hands back the chain
await loadUser(userId).unwrapOr(x); // ✗ a chain has no terminals
Being a thenable would mean an async function returning a chain silently unwraps it, and a chain sitting in Promise.all resolves to something other than what you wrote. Keeping it a plain object makes getResult() the single, visible boundary between the chain and the promise world.
A whole request handler stays flat — you never await in the middle of it:
const checkout = Result.registerExecution(async (cartId: string) =>
loadCart(cartId)
.mapValue((cart) => requireNonEmpty(cart)) // sync step
.mapValue(async (cart) => reserveInventory(cart)) // async step
.tap(async (cart) => await audit.record("inventory.reserved", cart.id))
.mapValue((cart) => chargeSubscription(cart.userId, cart.totalCents))
.handleError(RateLimited, async (e) => {
await sleep(e.retryAfterSeconds * 1000);
return Result.err(new CheckoutBusy());
})
.getResult(),
);
toAsync#
Occasionally one branch is synchronous while its siblings are not, and you want a single uniform return type. toAsync() is the explicit lift.
const resolveTenant = (req: { headers: Record<string, string | undefined> }) =>
req.headers["x-tenant"]
? lookupTenant(String(req.headers["x-tenant"])) // already a chain
: Result.err(new TenantMissing()).toAsync(); // lifted, so both branches match
A rejection stays a rejection. If the underlying promise rejects — an SDK threw — the chain rejects too; it does not silently convert the rejection into an error branch. Wrap the throwing part in Result.try if that is what you want.
Combining results#
Result.all turns a tuple of results into a result of a tuple, failing with the first error in argument order. It accepts synchronous results, asynchronous chains and bare promises of results, in any mix — which is what a dashboard load actually looks like.
const page = await Result.all([
loadUser(userId), // an async chain
loadSubscription(userId), // an async chain
loadRecentOrders(userId), // an async chain
parseViewOptions(req.query), // a plain Result — no I/O
]).getResult();
// Result<
// [User, Subscription, Order[], ViewOptions],
// UserNotFound | SubscriptionMissing | DatabaseUnavailable | ValidationFailed
// >
const view = page.mapValue(([user, sub, orders, opts]) =>
Result.ok(renderDashboard(user, sub, orders, opts)),
);
If every member is synchronous you get a Result straight back, with no promise involved. If any member is asynchronous the whole call is, and every member runs concurrently — three queries take as long as the slowest, not the sum.
Order is deterministic. The reported failure is the first one in argument order, not the first to settle in time. Two runs of the same failing request report the same failure, which is the difference between a reproducible bug and a heisenbug.
collect — every failure, not just the first#
Result.all tells you that something failed. Result.collect tells you which things failed. It takes the same input and produces the same tuple of values on success; on failure the error is a tuple the same length as the input, holding each member's failure at its own index and null where that member succeeded.
This is the shape a form wants — one round trip, every bad field reported at once:
const form = Result.collect([
validateEmail(body.email), // Result<string, ValidationFailed>
validatePassword(body.password), // Result<string, ValidationFailed>
validateAge(body.age), // Result<number, ValidationFailed>
]);
// Result<
// [string, string, number],
// [ValidationFailed | null, ValidationFailed | null, ValidationFailed | null]
// >
const response = form.match({
ok: ([email, password, age]) => ({ status: 200, body: { email, password, age } }),
err: (errors) => ({
status: 422,
body: {
fields: errors.flatMap((e) => (e ? [{ field: e.field, reason: e.reason }] : [])),
},
}),
});
// → 422 { fields: [{ field: "password", reason: "too short" },
// { field: "age", reason: "must be 18 or older" }] }
all | collect | |
|---|---|---|
| Value on success | tuple of values | tuple of values (identical) |
| Error on failure | the first failure | a tuple, null where it succeeded |
| Error type | a union of tagged failures | a tuple of failure | null |
Works with handleError | yes | no — the error is a tuple |
| Reach for it when | any failure means stop | the user must see every failure at once |
Because the error is a tuple rather than a tagged failure, handleError() cannot match on it — instanceof against an array is never true, so a handler simply never fires. Read a collected failure with match() or error and turn it into whatever your API returns.
It follows the same dispatch rule as everything else, so a form whose checks hit the database — a uniqueness check, say — collects concurrently and still reports every field:
const form = await Result.collect([
validateEmailFormat(body.email), // sync
ensureEmailIsFree(body.email), // async: one query
ensureUsernameIsFree(body.handle), // async: one query, runs alongside
]).getResult();
Terminals
Getting the value out#
The terminals live on Result only. An asynchronous chain has none — you call getResult() first, and use them on the Result that comes back. That is deliberate: it is what makes a short-circuited step safe.
const settled = await loadUser(userId).getResult();
settled.match({ ok: … , err: … }); // and every other terminal
| Method | Returns | On the other branch |
|---|---|---|
match({ ok, err }) | A | B | runs the other branch |
unwrap() | T | throws ResultUnwrapError |
unwrapError() | E | throws ResultUnwrapError |
unwrapOr(fallback) | T | D | returns fallback |
unwrapOrElse(fn) | T | D | returns the computed fallback |
getResult() | Promise<Result<T, E>> | — (on both classes) |
isOk / isErr | boolean | — |
value / error | T | undefined / E | undefined | undefined |
match — the exhaustive one#
You cannot forget a branch, and the compiler infers the union of both return types. This is how a failure becomes a response:
const charge = await chargeSubscription(userId, 4900).getResult();
const response = charge.match({
ok: (charge) => ({ status: 200, body: { receiptUrl: charge.receiptUrl } }),
err: (e) => {
switch (e._tag) {
case "UserNotFound":
return { status: 404, body: { error: "no such user" } };
case "NoPaymentMethod":
return { status: 402, body: { error: "add a card first" } };
case "PaymentDeclined":
return { status: 402, body: { error: "declined", code: e.failureCode } };
}
},
});
unwrap — the deliberate escape hatch#
unwrap() is the only place this library throws on purpose. Once the union has been narrowed to never it is provably safe, which makes it the natural end of a fully-handled chain — and a reasonable thing to do at boot, where a failure should stop the process anyway.
// application startup: if the config is wrong, do not start.
// loadConfig reads process.env — no I/O, so this whole chain is synchronous.
const config = loadConfig(process.env)
.handleError(MissingEnvVar, (e) => Result.err(new FatalMisconfiguration(e.name)))
.unwrapOrElse((e) => {
logger.fatal({ tag: e._tag }, "invalid configuration");
process.exit(1);
});
Reaching for unwrap() in the middle of a chain throws away the guarantee you adopted the library for. Keep it at the top of your program, at a framework boundary, or in tests.
The thrown ResultUnwrapError carries the original failure on .taggedError, so nothing is lost when it crosses back into exception-land:
try {
(await loadUser("u_missing").getResult()).unwrap();
} catch (thrown) {
if (thrown instanceof ResultUnwrapError) {
thrown.taggedError; // the UserNotFound instance, with its userId
thrown.message; // 'Called unwrap() on an error Result (UserNotFound)'
}
}
Why value and error are optional#
value and error are typed as T | undefined and E | undefined. A getter cannot carry a type predicate in TypeScript, so isOk cannot narrow this the way a discriminated union would.
That is a deliberate trade: keeping Result a single class with a boolean flag is what makes the implementation small enough to read. When you want the compiler to prove which branch you are on, use match — it is the narrowing path, and it is exhaustive.
Interop
Bridging code that throws#
Result.try runs a function and converts anything it throws into a tagged failure. This is the boundary between the throwing world — every SDK you did not write — and the typed one.
// a third-party SDK that rejects on network and HTTP errors alike
const charge = await Result.try(
() => stripe.charges.create({ amount, customer }),
(thrown) => new PaymentGatewayError(String(thrown)),
).getResult();
// Result<Charge, PaymentGatewayError>
// parsing a webhook body
const event = Result.try(
() => JSON.parse(rawBody) as StripeEvent,
(thrown) => new MalformedWebhook(String(thrown)),
);
// Result<StripeEvent, MalformedWebhook>
It follows the dispatch rule: a promise-returning body gives an asynchronous chain, a plain one gives a Result. On the asynchronous path it catches a synchronous throw and a rejected promise, which are two different failure modes that are easy to get wrong by hand.
onThrow receives the thrown value as unknown, not as Error. JavaScript lets you throw anything, and plenty of libraries do — that is exactly how e.message becomes undefined in production.
const parsed = Result.try(
() => schema.parse(input), // zod throws a ZodError
(thrown) =>
thrown instanceof ZodError
? new ValidationFailed(thrown.issues[0].path.join("."), thrown.issues[0].message)
: new ValidationFailed("<unknown>", String(thrown)),
);
The mirror direction — going back to exceptions at the edge of your typed core — is unwrap(), or an explicit throw inside match when a framework insists on it.
Patterns
Recipes#
Mapping failures onto a response#
Because _tag is a literal, a switch over it narrows in every branch — e.retryAfterSeconds is available only where it actually exists. One place decides status codes, and adding a failure upstream breaks the build here rather than in production.
app.get("/api/orders/:id", async (req, res) => {
const loaded = await loadOrder(req.params.id, req.user.id).getResult();
const response = loaded.match({
ok: (order) => ({ status: 200, headers: {}, body: order }),
err: (e) => {
switch (e._tag) {
case "OrderNotFound":
return { status: 404, headers: {}, body: { error: "not found" } };
case "NotYourOrder":
return { status: 403, headers: {}, body: { error: "forbidden" } };
case "RateLimited":
return {
status: 429,
headers: { "Retry-After": String(e.retryAfterSeconds) },
body: { error: "slow down" },
};
case "DatabaseUnavailable":
logger.error({ err: e }, "order lookup failed"); // a real Error: has a stack
return { status: 503, headers: {}, body: { error: "try again shortly" } };
}
},
});
res.status(response.status).set(response.headers).json(response.body);
});
Making a new failure a build error#
Force the compiler to fail when a failure appears upstream that this boundary has not considered:
const toStatus = (e: OrderNotFound | NotYourOrder | RateLimited): number => {
switch (e._tag) {
case "OrderNotFound":
return 404;
case "NotYourOrder":
return 403;
case "RateLimited":
return 429;
default: {
const _exhaustive: never = e; // ← breaks when a fourth failure is added
return 500;
}
}
};
Cache, then database, then origin#
const avatar = (
await fromCache(userId)
.handleError(CacheMiss, () => fromDatabase(userId))
.handleError(NotStored, () => fromGravatar(userId))
.tapError((e) => metrics.increment("avatar.miss", { tag: e._tag }))
.getResult()
).unwrapOr(defaultAvatarUrl);
Retrying only what is worth retrying#
A rate limit is worth another attempt; a declined card is not. Because the two are separate classes, that policy is expressible:
// callExternalApi: AsyncResult<Response, RateLimited | PaymentDeclined | CardExpired>
// The chain type is inferred from the function that produces it — never imported.
type ApiCall = ReturnType<typeof callExternalApi>;
const withRetry = (attempt: () => ApiCall, left: number): ApiCall =>
attempt().handleError(RateLimited, async (e) => {
if (left <= 0) return Result.err(e); // out of attempts: surface the rate limit
await sleep(e.retryAfterSeconds * 1000);
return withRetry(attempt, left - 1).getResult();
});
const response = await withRetry(() => callExternalApi(request), 3).getResult();
// PaymentDeclined and CardExpired were never retried — they are not the handled class
Keeping layers honest#
Each layer handles what it can and re-tags what it cannot, so the union at the transport layer is exactly the set of responses you must write — no more, no fewer:
// repository — infrastructure vocabulary only
const findOrderRow = Result.registerExecution(async (id: string) => { ... });
// AsyncResult<OrderRow, RowNotFound | DatabaseUnavailable>
// service — retires infrastructure detail, adds domain meaning
const loadOrder = Result.registerExecution(async (id: string, viewerId: string) =>
findOrderRow(id)
.handleError(RowNotFound, () => Result.err(new OrderNotFound(id)))
.mapValue((row) =>
row.userId === viewerId ? Result.ok(row) : Result.err(new NotYourOrder()),
)
.mapValue((row) => Result.ok(toDomain(row)))
.getResult(),
);
// AsyncResult<Order, OrderNotFound | NotYourOrder | DatabaseUnavailable | InvalidRow>
// transport — the union above is the exact list of cases the handler must map
Types
TypeScript notes#
How the union moves#
| Operation | Effect on the error union |
|---|---|
mapValue(fn) | adds whatever fn can fail with |
mapError(fn) | replaces the union entirely |
handleError(A, B, fn) | removes A and B, adds fn's failures |
tap / tapError | unchanged |
Result.all([...]) | the union of every member |
Result.collect([...]) | a tuple of every member, each widened with null |
Result.registerExecution(fn) | collapses a union of results into one result |
Naming an asynchronous chain#
The asynchronous class is not exported, so you never write it by hand. When you do need to name one — a helper that takes a chain, or a declared return type — infer it from the function that produces one:
const loadOrder = Result.registerExecution(async (id: string) => { ... });
type OrderChain = ReturnType<typeof loadOrder>;
type OrderResult = Awaited<ReturnType<OrderChain["getResult"]>>; // Result<Order, …>
// middleware that works on any chain this function returns
const instrumented = (chain: OrderChain, route: string) =>
chain
.tap(() => metrics.increment("order.loaded", { route }))
.tapError((e) => metrics.increment("order.failed", { route, tag: e._tag }));
In practice this comes up rarely: a chain is usually built and consumed in one expression, and getResult() hands you back an ordinary Result, which is exported and nameable.
The inference rules are tested#
The library ships 35 compile-time assertions in tests/types.test-d.ts. Nothing in that file runs — tsc is the test. If a union stops collapsing, if handleError stops subtracting, or if the sync/async dispatch picks the wrong branch, the build fails on the exact assertion that regressed.
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
type Expect<T extends true> = T;
// handleError subtracts exactly the classes named, and nothing more
export type _handled = Expect<Equal<typeof handled, Result<string | number, BError>>>;
const handled = registered(1).handleError(AError, (e) => Result.ok(e.limit));
// a sync callback keeps the chain sync; an async one does not
export type _tapSync = Expect<Equal<typeof tapped, Result<string, AError | BError>>>;
const tapped = registered(1).tap(() => {});
Reference
API reference#
Result — statics#
Success carrying nothing.
Success carrying value. An explicit undefined is a value, not a failure.
Success carrying null.
Failure carrying a tagged failure. Rejects a widened _tag at compile time.
Runs fn, converting a throw — or a rejection — into a tagged failure. Asynchronous when fn returns a promise.
Collapses a union of results into a result of unions. Asynchronous when the body is.
Tuple of results → result of a tuple. First failure in argument order wins. Asynchronous if any member is; members run concurrently.
Same values, but the error is a tuple of every member's failure with null where it succeeded. See collect.
Result — instance#
The chaining methods and terminals below exist on an asynchronous chain too, under the same names with the same meanings — the async one just returns promises. The accessors and toAsync() are synchronous-only; getResult() is chain-only. There is no then: a chain is not thenable.
Which branch this is.
The payload. See why they are optional.
Transform the value; failures pass through. Failures accumulate.
Transform the failure; successes pass through. The union is replaced.
Handle specific classes; subtracts them from the union. Matching is instanceof.
Side effect; returns the chain unchanged. An async effect is awaited.
Collapse both branches into one value. Exhaustive.
Extract, or throw ResultUnwrapError.
Extract with a fallback.
Lift a synchronous result into an asynchronous chain. Result only.
Resolve an asynchronous chain to a plain Result. Chain only — see the caveat for why it still works on a stand-in.
Failure base classes#
Returns an abstract base class stamping a literal _tag. For domain outcomes — no stack trace, no allocation beyond the object. Each call returns a distinct class.
The same, but the base also extends Error: a message, a stack, and name === tag. For infrastructure failures you will log or report.
Thrown by unwrap() / unwrapError(). Carries the original failure on .taggedError.
Context
How it compares#
| this | neverthrow | Effect | |
|---|---|---|---|
| Error union in the type | yes | yes | yes |
| Handle by error class | yes, subtractive | manual | yes, via tags |
| Sync and async | one API | two types, two APIs | one API |
| Runtime to install | none | none | yes |
| Generator syntax | no | no | yes |
| Dependency injection | no | no | yes |
| Concurrency · retries · scheduling | no | no | yes |
| Bundle size | 1.2 KB min+gz | comparable | substantially larger |
| Names to import | 4 | a dozen or so | many |
Pick Effect when you want the whole platform — it is a genuinely better answer for large systems that need structured concurrency, resource safety and injection. Pick this one when you want typed errors and nothing else in the way.
The practical difference against neverthrow is two things: handleError, which removes a failure class from the union rather than making you rebuild it by hand, and the absence of a separate asynchronous type to convert into and out of.
Exports#
import {
Result,
Tagged,
TaggedError,
ResultUnwrapError,
} from "@dmytromykhailiuk/typed-error-execution";
That is the entire public surface. The asynchronous chain class, the type helpers and the runtime guards are reached through Result or inferred — see naming an asynchronous chain if you need to write one down.
Running it locally
npm run playground # a runnable tour of every feature
npm run playground:watch # the same, re-running on save
npm test # 156 runtime tests, 100% coverage
npm run typecheck # 35 compile-time inference assertions
npm run verify # lint + typecheck + test + build