Migrate from neverthrow
temporal-contract used neverthrow for its Result type through contract v2.x; neverthrow was removed at v3.0.0. It now uses unthrown throughout — workflows, activities, and the typed client. This guide maps the neverthrow idioms you may still be carrying to their unthrown equivalents.
This is a different step from the unthrown-4 → unthrown-5 change that landed in v8. If you are already on unthrown and only need the 7 → 8 upgrade, see Upgrade to v8 instead.
Why the change
unthrown adds a third channel. neverthrow's Result<T, E> has two outcomes: success and failure. unthrown has three:
| Channel | Meaning |
|---|---|
ok | Success |
err | A failure you modeled — part of the type signature |
defect | A failure you did not model — a bug, an unexpected throw |
The distinction matters at a Temporal boundary. "The card was declined" is a domain outcome your caller branches on. "The payment SDK threw a TypeError" is a bug that should surface loudly, not be quietly folded into your error union. With two channels those collapse together; with three they do not.
A defect re-throws when you unwrap it, carrying the original cause and stack.
Swap the dependency
pnpm remove neverthrow
pnpm add unthrown// before
import { ResultAsync, ok, err, okAsync, errAsync } from "neverthrow";
// after
import { AsyncResult, Ok, Err, OkAsync, ErrAsync } from "unthrown";Constructors are capitalized in unthrown.
Type names
| neverthrow | unthrown |
|---|---|
Result<T, E> | Result<T, E> |
ResultAsync<T, E> | AsyncResult<T, E> |
// before
const activity = (): ResultAsync<Payment, ApplicationFailure> => ...
// after
const activity = (): AsyncResult<Payment, ApplicationFailure> => ...Method mapping
| neverthrow | unthrown | Note |
|---|---|---|
ok(v) | Ok(v) | |
err(e) | Err(e) | |
okAsync(v) | OkAsync(v) or Ok(v).toAsync() | |
errAsync(e) | ErrAsync(e) or Err(e).toAsync() | |
ResultAsync.fromPromise(p, f) | fromPromise(p, f) | free function |
ResultAsync.fromSafePromise(p) | fromSafePromise(p) | free function |
.map(f) | .map(f) | unchanged |
.andThen(f) | .flatMap(f) | renamed |
.mapErr(f) | .mapErrCases(m => …) | takes a matcher |
.orElse(f) | .flatMapErrCases(m => …) | matcher; arms return a Result |
.match(ok, err) | .match({ ok, errCases, defect }) | object form, three channels |
Result.combine([…]) | all([…]) | free function |
.isOk() / .isErr() | .isOk() / .isErr() / .isDefect() | plus free functions |
.unwrapOr(v) | .getOr(v) | |
._unsafeUnwrap() | .getOrThrow() |
andThen becomes flatMap
// before
fetchOrder(id)
.andThen((order) => chargeCard(order))
.map((charge) => charge.id);
// after
fetchOrder(id)
.flatMap((order) => chargeCard(order))
.map((charge) => charge.id);Error handling takes a matcher
This is the biggest shift. Anything touching the error channel receives an exhaustive matcher instead of the bare error:
// before
result.mapErr((error) => new WrappedError(error));
// after — one arm per tag in the union (abbreviated here; see the note below)
import { P } from "unthrown";
result.mapErrCases((matcher) =>
matcher.with(P.tag("@temporal-contract/WorkflowFailedError"), (error) => new WrappedError(error)),
);The matcher must cover the whole union — a missing tag is a compile error. For a genuine catch-all:
import { P } from "unthrown";
result.mapErrCases((matcher) => matcher.with(P._, (error) => new WrappedError(error)));.with() accepts several patterns before the handler, so folding a wide union into one branch is compact:
matcher.with(
P.tag("@temporal-contract/WorkflowNotInContractError"),
P.tag("@temporal-contract/WorkflowValidationError"),
P.tag("@temporal-contract/WorkflowFailedError"),
(error) => report(error),
);match is an object with three channels
// before
const message = result.match(
(value) => `charged ${value.transactionId}`,
(error) => `failed: ${error.message}`,
);
// after
const message = result.match({
ok: (value) => `charged ${value.transactionId}`,
errCases: (matcher) =>
matcher.with(
P.tag("@temporal-contract/WorkflowFailedError"),
P.tag("@temporal-contract/WorkflowValidationError"),
(error) => `failed: ${error.message}`,
),
defect: (cause) => `unexpected: ${String(cause)}`,
});Forgetting defect is a compile error, which is deliberate — it is the channel you most want not to ignore.
Narrowing
Both the methods and the free functions are type guards:
import { isDefect, isErr, isOk } from "unthrown";
// methods — what this codebase uses
if (result.isOk()) result.value;
if (result.isErr()) result.error;
if (result.isDefect()) result.cause;
// free functions — identical behaviour
if (isOk(result)) result.value;Narrow before touching .value, .error, or .cause.
Error classes
unthrown's TaggedError stamps a _tag discriminant used by the matcher:
import { TaggedError } from "unthrown";
class PaymentDeclined extends TaggedError("PaymentDeclined")<{
readonly customerId: string;
}> {}temporal-contract's own classes namespace their tag with the package scope — "@temporal-contract/WorkflowFailedError" — so they never collide with yours. Their .name stays the bare class name for readable logs.
Note the exception: the worker's ValidationError subclasses still extend Temporal's ApplicationFailure rather than TaggedError, because Temporal's terminal-failure semantics depend on it.
A full activity, before and after
// before — neverthrow
import { ResultAsync, err, ok } from "neverthrow";
const chargeCard = ({ customerId, amount }) =>
ResultAsync.fromPromise(gateway.charge(customerId, amount), (error) =>
ApplicationFailure.create({ type: "CHARGE_FAILED", cause: error as Error }),
).andThen((charge) =>
charge.declined
? err(ApplicationFailure.create({ type: "DECLINED", nonRetryable: true }))
: ok({ transactionId: charge.id }),
);// after — unthrown
import { Err, Ok, fromPromise } from "unthrown";
import { qualifyFailure } from "@temporal-contract/worker/activity";
const chargeCard = ({ input: { customerId, amount } }) =>
fromPromise(
gateway.charge(customerId, amount),
// `expected` names the anticipated failure class; unmatched throws stay defects.
qualifyFailure("CHARGE_FAILED", { expected: GatewayError }),
).flatMap((charge) =>
charge.declined
? Err(ApplicationFailure.create({ type: "DECLINED", nonRetryable: true }))
: Ok({ transactionId: charge.id }),
);qualifyFailure is a temporal-contract helper that collapses the hand-written ApplicationFailure.create mapper into one call.
Combining results
// before
const combined = Result.combine([validateA(a), validateB(b)]);
// after
import { all } from "unthrown";
const combined = all([validateA(a), validateB(b)]);all fails on the first error, like Result.combine. allAsync is the AsyncResult variant, and allFromDict combines a record instead of an array.
Also see
- The result model — the three channels in temporal-contract specifically
- Upgrade to v8 — the more recent breaking change
- unthrown's own migration guide