typed-error-execution
typed-error-execution 1.0.0

Typed, tagged errors for TypeScript. Zero dependencies.

Contents

Contents

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.

zero dependencies subtractive error handling four exports one API, sync and async no runtime 100% test coverage

Getting started

Install#

sh
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.

Note

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:

ts
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:

ts
function registerUser(
  input: SignUpInput,
): AsyncResult<User, ValidationFailed | EmailAlreadyRegistered | DatabaseUnavailable>;

Three properties follow, and they are the whole point.

You cannot forget a failurethe union is in the signature

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.

Handling is subtractionhandleError removes what it handles

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.

Nothing is magicclasses, instanceof, two fields

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.

Note

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.

tsThe switch is exhaustive — add a third failure and it stops compiling
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:

ts
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.

ts
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.

TaggedTaggedError
Extends Errornoyes
message / stacknoyes
Construction costa plain objectcaptures a stack trace
Reach for it whenthe failure is an expected outcomethe failure means something is broken
Typical exampleUserNotFound, RateLimitedDatabaseUnavailable, PaymentGatewayError
ts
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.

Note

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.

Careful

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#

ts
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.

ts
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:

tsA deliberately loud compile error, not a silent downgrade
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:

a Result→ Result

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.

a Promise, or an async chain→ asynchronous chain

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.

tsThe check is instanceof — no heuristics on the function itself
// 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:

CallSynchronous whenAsynchronous when
mapValue · mapError · handleErrorthe callback returns a resultit returns a promise or an async chain
tap · tapErrorthe effect returns nothingthe effect returns a promise (which is awaited)
Result.trythe body returns a valuethe body returns a promise
Result.registerExecutionthe body returns a resultthe body is async
Result.all · Result.collectevery member is synchronousany 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.

ts
// 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.

tsDetection covers arrows, declarations, methods and bound functions
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:

tsTechnically correct, practically unchainable
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:

tsA result of unions, instead of a union of results
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:

ts
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>
Careful

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.

tsEach step can only add to the error 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:

ts
// 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.

ts
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
Note

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.

tsHandling is subtraction — watch the union shrink to never
//  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:

ts
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:

ts
loadRow(id).handleError(DatabaseUnavailable, (e) => Result.err(new ServiceDegraded(e)));
// AsyncResult<Row, RowNotFound | ServiceDegraded>

Three rules worth knowing#

Matching is instanceofnot tag comparison

Handling a class also handles every subclass of it. One handleError(PaymentDeclined, …) covers CardExpired, InsufficientFunds and any other member of that family.

A handler never sees its own outputno re-entry

Converting DatabaseUnavailable into ServiceDegraded and handling ServiceDegraded later in the chain works exactly as written. There is no loop.

The handler runs at most onceeven with overlapping classes

If you list both a class and its subclass and the instance matches both, the handler still runs a single time.

ts
// 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.

Memberon Resulton an async chain
mapValue · mapError · handleError · tap · tapErroryesyes — identical signature
getResult()yesyes — identical signature
match() · unwrap() · unwrapError() · unwrapOr() · unwrapOrElse()yesno — call getResult() first
value · error · isOk · isErryesno — nothing to read yet
toAsync()yesno — already one
Note

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.

tsgetResult() is the one way a chain ends
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
Note

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:

ts
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.

ts
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
Careful

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.

tsThe tuple keeps positional types — not an array of a union
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.

Note

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:

tsThe index is the point: you know which field failed
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" }] }
allcollect
Value on successtuple of valuestuple of values (identical)
Error on failurethe first failurea tuple, null where it succeeded
Error typea union of tagged failuresa tuple of failure | null
Works with handleErroryesno — the error is a tuple
Reach for it whenany failure means stopthe user must see every failure at once
Careful

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:

ts
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.

ts
const settled = await loadUser(userId).getResult();
settled.match({ ok: … , err: … });   // and every other terminal
MethodReturnsOn the other branch
match({ ok, err })A | Bruns the other branch
unwrap()Tthrows ResultUnwrapError
unwrapError()Ethrows ResultUnwrapError
unwrapOr(fallback)T | Dreturns fallback
unwrapOrElse(fn)T | Dreturns the computed fallback
getResult()Promise<Result<T, E>>— (on both classes)
isOk / isErrboolean
value / errorT | undefined / E | undefinedundefined

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:

ts
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.

ts
// 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);
  });
Don't

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:

ts
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.

ts
// 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.

Note

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.

ts
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.

ts
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:

tsThe one line that turns a new failure into a failed build
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#

ts
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:

tsThe chain type is inferred, never imported
// 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:

ts
// 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#

OperationEffect 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 / tapErrorunchanged
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:

tsInference names it for you — there is nothing to import
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.

tstests/types.test-d.ts — run with npm run typecheck
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#

Result.ok()(): Result<void, never>

Success carrying nothing.

Result.ok(value)<T>(value: T): Result<T, never>

Success carrying value. An explicit undefined is a value, not a failure.

Result.empty()(): Result<null, never>

Success carrying null.

Result.err(error)<E extends Tagged>(error: E): Result<never, E>

Failure carrying a tagged failure. Rejects a widened _tag at compile time.

Result.try(fn, onThrow)(() => T, (thrown: unknown) => E)

Runs fn, converting a throw — or a rejection — into a tagged failure. Asynchronous when fn returns a promise.

Result.registerExecution(fn)((...a: A) => R) => (...a: A) => chain

Collapses a union of results into a result of unions. Asynchronous when the body is.

Result.all(results)(readonly results[]) => chain of a tuple

Tuple of results → result of a tuple. First failure in argument order wins. Asynchronous if any member is; members run concurrently.

Result.collect(results)(readonly results[]) => chain of a tuple

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.

isOk · isErrboolean

Which branch this is.

value · errorT | undefined · E | undefined

The payload. See why they are optional.

mapValue(fn)(value: T) => result | promise

Transform the value; failures pass through. Failures accumulate.

mapError(fn)(error: E) => result | promise

Transform the failure; successes pass through. The union is replaced.

handleError(...classes, handler)ErrorClass[], (e) => result | promise

Handle specific classes; subtracts them from the union. Matching is instanceof.

tap(fn) · tapError(fn)(value|error) => void | Promise<void>

Side effect; returns the chain unchanged. An async effect is awaited.

match({ ok, err })=> A | B

Collapse both branches into one value. Exhaustive.

unwrap() · unwrapError()(): T · (): E

Extract, or throw ResultUnwrapError.

unwrapOr(d) · unwrapOrElse(fn)(): T | D

Extract with a fallback.

toAsync()(): async chain

Lift a synchronous result into an asynchronous chain. Result only.

getResult()(): Promise<Result<T, E>>

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#

Tagged(tag)<Tag extends string>(tag: Tag)

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.

TaggedError(tag)<Tag extends string>(tag: Tag)

The same, but the base also extends Error: a message, a stack, and name === tag. For infrastructure failures you will log or report.

ResultUnwrapErrorclass extends Error

Thrown by unwrap() / unwrapError(). Carries the original failure on .taggedError.

Context

How it compares#

thisneverthrowEffect
Error union in the typeyesyesyes
Handle by error classyes, subtractivemanualyes, via tags
Sync and asyncone APItwo types, two APIsone API
Runtime to installnonenoneyes
Generator syntaxnonoyes
Dependency injectionnonoyes
Concurrency · retries · schedulingnonoyes
Bundle size1.2 KB min+gzcomparablesubstantially larger
Names to import4a dozen or somany

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#

ts
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

sh
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