Migrating from neverthrow to unthrown
temporal-contract previously used neverthrow for its Result / ResultAsync pattern. Starting in this major version it uses unthrown instead. The shape of the API surface is the same — signals, queries, updates, activities, and the client all still return a Result-like value — but the type and function names differ, narrowing now uses free functions, and there is a new third outcome channel: defect.
This page is an end-to-end mapping for upgrading existing code.
Why the change
- A third channel for bugs: unthrown separates anticipated failures (
err) from unanticipated ones (defect). Modeled boundary errors stay in your type signature; unexpected throws surface as defects that re-throw onawait/unwrap instead of being silently swallowed. - Tagged errors: error classes built with
TaggedError(...)carry a_tagdiscriminant, enabling exhaustivematchTags(...)folds. - Sound narrowing: free functions
isOk/isErr/isDefectnarrow the type correctly, instead of relying on method-based type guards.
Drop the dep, add the new one
// package.json
"dependencies": {
- "neverthrow": "^8"
+ "unthrown": "^0.1.0"
}- import { ResultAsync, ok, err, okAsync, errAsync } from "neverthrow";
+ import { fromPromise, ok, err, isOk, isErr, isDefect } from "unthrown";Type signatures
ResultAsync<T, E> is renamed to AsyncResult<T, E>. Result<T, E> keeps the same name but is now imported from "unthrown".
- (args: TInput): ResultAsync<TOutput, MyError>
+ (args: TInput): AsyncResult<TOutput, MyError>API mapping
| neverthrow | unthrown |
|---|---|
import { ResultAsync } from "neverthrow" | import { fromPromise } from "unthrown" |
type ResultAsync<T, E> | type AsyncResult<T, E> |
type Result<T, E> | Result<T, E> (now from "unthrown") |
ok(v) / err(e) | ok(v) / err(e) (from "unthrown") |
okAsync(v) | ok(v).toAsync() (no okAsync) |
errAsync(e) | err(e).toAsync() (no errAsync) |
ResultAsync.fromPromise(promise, errFn) | fromPromise(promise, errFn) |
ResultAsync.fromSafePromise(promise) | fromSafePromise(promise) |
.andThen(fn) | .flatMap(fn) |
.map(fn) / .mapErr(fn) / .orElse(fn) | .map(fn) / .mapErr(fn) / .orElse(fn) |
Result.combine([...]) | all([...]) |
result.match(okFn, errFn) (positional) | result.match({ ok, err, defect }) (object, 3 channels) |
result.isOk() / result.isErr() to narrow | isOk(result) / isErr(result) / isDefect(result) (free functions) |
okAsync / errAsync are gone
unthrown has no okAsync / errAsync. Build a synchronous Result and lift it to an AsyncResult with .toAsync():
- import { okAsync, errAsync } from "neverthrow";
+ import { ok, err } from "unthrown";
- return okAsync({ sent: true });
+ return ok({ sent: true }).toAsync();
- return errAsync(new MyError());
+ return err(new MyError()).toAsync();Narrowing: methods or free functions
Both narrow. The result.isOk() / result.isErr() / result.isDefect()methods are type guards (as in neverthrow), and unthrown also exports the matching free functions isOk / isErr / isDefect. This codebase uses the methods, but either reaches .value / .error / .cause:
if (result.isErr()) {
console.error(result.error);
return;
}
// result is narrowed to `Ok | Defect` here — a `Defect` still needs handling
// (see below) before `.value` is reachableNOTE
Versions before unthrown 0.2.0 returned a plain boolean from the methods, so only the free functions narrowed. On 0.2.0+ either form works.
The new defect channel
unthrown models three outcomes, not two:
ok— success.err— a deliberate, anticipated failure that is part of your type signature (returned witherr(...)/err(...).toAsync(), or produced by mapping a rejection throughfromPromise(promise, errFn)).defect— an unanticipated failure (a bug): an unexpected throw that was never modeled. It carries the raw failure onresult.causeand re-throws when youawait/unwrap it, so genuine bugs surface loudly.
This is a behavior change. Under neverthrow, an unexpected throw inside a chain was generally coerced into the typed error channel. Under unthrown it becomes a defect instead, distinct from your modeled err values. Inspect it with isDefect(result) / result.cause, or handle all three at once:
import { isOk, isErr, isDefect } from "unthrown";
const result = await client.executeWorkflow("processOrder", { workflowId, args });
if (isOk(result)) {
console.log(result.value);
} else if (isErr(result)) {
console.error("Modeled failure:", result.error);
} else if (isDefect(result)) {
console.error("Unexpected failure (bug):", result.cause);
}NOTE
The worker's previous WorkflowScopeError has been removed. The unexpected conditions it used to model now surface on the defect channel via result.cause rather than as a typed err. Stop matching on WorkflowScopeError; handle the defect channel instead.
match is now object form with three channels
- result.match(
- (output) => console.log("Order:", output),
- (err) => console.error("Failed:", err),
- );
+ result.match({
+ ok: (output) => console.log("Order:", output),
+ err: (error) => console.error("Failed:", error),
+ defect: (cause) => console.error("Unexpected:", cause),
+ });Always add the defect handler — it is a required, distinct channel.
Error classes: TaggedError
Error classes are now built with TaggedError(...), which stamps each class with a _tag discriminant:
- export class PaymentDeclined extends Error {
- constructor(public readonly customerId: string) {
- super("Payment declined");
- }
- }
+ import { TaggedError } from "unthrown";
+
+ export class PaymentDeclined extends TaggedError("PaymentDeclined")<{
+ readonly customerId: string;
+ }> {}Because every tagged error carries a _tag, unthrown's matchTags folds a Result exhaustively by tag, with dedicated Ok and Defect channels:
import { matchTags } from "unthrown";
const message = matchTags(result, {
Ok: (value) => `charged ${value.transactionId}`,
PaymentDeclined: (e) => `declined for ${e.customerId}`,
GatewayTimeout: (e) => `timed out after ${e.elapsedMs}ms`,
Defect: (cause) => `unexpected: ${String(cause)}`,
});NOTE
The worker's ValidationError subclasses are the exception — they still extend Temporal's ApplicationFailure rather than TaggedError.
NOTE
temporal-contract's own error tags are package-namespaced — e.g. _tag === "@temporal-contract/WorkflowExecutionNotFoundError" — while each error's .name stays the bare class name. If you matchTags over library errors, the handler keys carry the prefix: matchTags(result, { "@temporal-contract/WorkflowExecutionNotFoundError": ... }).
End-to-end activity example
Before (neverthrow):
import { ResultAsync } from "neverthrow";
import { ApplicationFailure, declareActivitiesHandler } from "@temporal-contract/worker/activity";
export const activities = declareActivitiesHandler({
contract,
activities: {
sendEmail: ({ to, subject }) =>
ResultAsync.fromPromise(emailService.send(to, subject), (e) =>
ApplicationFailure.create({
type: "EMAIL_FAILED",
message: e instanceof Error ? e.message : "Failed",
cause: e instanceof Error ? e : undefined,
}),
),
},
});After (unthrown):
import { fromPromise } from "unthrown";
import { ApplicationFailure, declareActivitiesHandler } from "@temporal-contract/worker/activity";
export const activities = declareActivitiesHandler({
contract,
activities: {
sendEmail: ({ to, subject }) =>
fromPromise(emailService.send(to, subject), (e) =>
ApplicationFailure.create({
type: "EMAIL_FAILED",
message: e instanceof Error ? e.message : "Failed",
cause: e instanceof Error ? e : undefined,
}),
),
},
});End-to-end client example
Before (neverthrow):
const result = await client.executeWorkflow("processOrder", { workflowId, args });
result.match(
(output) => console.log("Order:", output),
(err) => console.error("Failed:", err),
);After (unthrown):
const result = await client.executeWorkflow("processOrder", { workflowId, args });
result.match({
ok: (output) => console.log("Order:", output),
err: (error) => console.error("Failed:", error),
defect: (cause) => console.error("Unexpected:", cause),
});Combining results
Result.combine([...]) becomes all([...]):
- import { Result } from "neverthrow";
- const combined = Result.combine([validateA(a), validateB(b)]);
+ import { all } from "unthrown";
+ const combined = all([validateA(a), validateB(b)]);Cancellation scopes
context.cancellableScope and context.nonCancellableScope previously returned ResultAsync<T, WorkflowCancelledError>. They now return AsyncResult<T, WorkflowCancelledError> — narrow the resolved Result with isErr(result) (free function) instead of result.isErr().
See Also
- Result Pattern
- Migrating from @swan-io/boxed (the earlier migration, kept for history)