From 59eeb4b1181b1484b4f56e526740c8fc58289678 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 26 Jun 2026 14:58:27 +0200 Subject: [PATCH 1/6] feat!: replace neverthrow with unthrown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the Result/error-handling spine from neverthrow to the btravstack-owned unthrown (unthrown@0.1.0) across all packages. This is a breaking change to the public API of the published packages. - ResultAsync -> AsyncResult in every public signature; the unthrown peer dependency replaces neverthrow. - No okAsync/errAsync: lift a sync Result with .toAsync(). Promise boundaries use fromPromise/fromSafePromise. - Narrowing uses the free functions isOk(r)/isErr(r)/isDefect(r) — the method forms return plain boolean and do not narrow. - New defect channel: unanticipated throws surface as defects (re-thrown at the edge), not typed errors. WorkflowScopeError is removed (scope throws are now defects); the client's "unexpected" RuntimeClientError wrap is gone. - Error classes use unthrown's TaggedError; ChildWorkflowCancelledError is now a sibling (distinct _tag) of ChildWorkflowError. The worker's ValidationError subclasses stay as ApplicationFailure for Temporal's terminal-failure semantics. - First-party unthrown is excluded from the pnpm minimumReleaseAge policy. - Docs, agent rules, READMEs, and the example apps updated; new migrating-to-unthrown guide added. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/rules/adding-a-package.md | 2 +- .agents/rules/code-style.md | 8 +- .agents/rules/commands.md | 2 +- .agents/rules/dependencies.md | 6 +- .agents/rules/handlers.md | 25 +- .agents/rules/project-overview.md | 8 +- .agents/rules/workflow-determinism.md | 4 +- .changeset/migrate-to-unthrown.md | 20 ++ AGENTS.md | 2 +- README.md | 16 +- docs/.vitepress/config.ts | 1 + docs/api/index.md | 8 +- docs/examples/basic-order-processing.md | 20 +- docs/examples/index.md | 12 +- docs/guide/activity-handlers.md | 42 +-- docs/guide/client-usage.md | 120 ++++---- docs/guide/core-concepts.md | 7 +- docs/guide/entry-points.md | 48 +-- docs/guide/getting-started.md | 25 +- docs/guide/installation.md | 20 +- docs/guide/migrating-to-unthrown.md | 280 ++++++++++++++++++ docs/guide/result-pattern.md | 236 ++++++++++----- docs/guide/troubleshooting.md | 37 ++- docs/guide/why-temporal-contract.md | 5 +- docs/guide/worker-implementation.md | 93 +++--- docs/guide/worker-usage.md | 53 ++-- docs/index.md | 17 +- examples/order-processing-client/package.json | 1 + .../order-processing-client/src/client.ts | 48 ++- examples/order-processing-worker/package.json | 2 +- .../src/application/activities.ts | 32 +- .../src/application/workflows.ts | 2 +- .../src/integration.spec.ts | 37 +-- packages/client/package.json | 10 +- packages/client/src/__tests__/client.spec.ts | 104 +++---- packages/client/src/client.spec.ts | 228 +++++++------- packages/client/src/client.ts | 137 +++++---- packages/client/src/errors.ts | 190 +++++++----- packages/client/src/internal.ts | 25 +- packages/client/src/schedule.spec.ts | 29 +- packages/client/src/schedule.ts | 39 ++- packages/client/src/types.ts | 14 +- packages/contract/package.json | 6 +- packages/contract/src/result-async.spec.ts | 121 ++++---- packages/contract/src/result-async.ts | 47 ++- packages/worker/README.md | 6 +- packages/worker/package.json | 8 +- .../worker/src/__tests__/test.workflows.ts | 7 +- packages/worker/src/__tests__/worker.spec.ts | 88 +++--- packages/worker/src/activity.spec.ts | 12 +- packages/worker/src/activity.ts | 71 +++-- packages/worker/src/cancellation.spec.ts | 98 +++--- packages/worker/src/cancellation.ts | 71 +++-- packages/worker/src/child-workflow.ts | 60 ++-- packages/worker/src/errors.ts | 156 ++++------ packages/worker/src/internal.ts | 14 +- packages/worker/src/workflow.ts | 77 +++-- pnpm-lock.yaml | 59 ++-- pnpm-workspace.yaml | 6 +- 59 files changed, 1697 insertions(+), 1225 deletions(-) create mode 100644 .changeset/migrate-to-unthrown.md create mode 100644 docs/guide/migrating-to-unthrown.md diff --git a/.agents/rules/adding-a-package.md b/.agents/rules/adding-a-package.md index 76e9f9e3..6f3d1940 100644 --- a/.agents/rules/adding-a-package.md +++ b/.agents/rules/adding-a-package.md @@ -122,6 +122,6 @@ If the new package introduces a public concept agents should know about (a new e ## What NOT to do - **Don't add a root `.` entry to `exports`** unless the package is single-entry. Subpath-only is intentional for multi-entry packages. -- **Don't put `neverthrow` (or any other type-bearing dep) in `dependencies`** — peer-dep, see [dependencies.md](./dependencies.md). +- **Don't put `unthrown` (or any other type-bearing dep) in `dependencies`** — peer-dep, see [dependencies.md](./dependencies.md). - **Don't import from `@temporal-contract/` via relative path.** Use the workspace-resolved package name even for sibling packages. - **Don't skip the changeset.** CI will pass without one, but the release will skip the package silently. Changesets are the only release mechanism. diff --git a/.agents/rules/code-style.md b/.agents/rules/code-style.md index 5450fe9c..018c721c 100644 --- a/.agents/rules/code-style.md +++ b/.agents/rules/code-style.md @@ -32,9 +32,11 @@ ApplicationFailure.create({ ## Error Handling -- Use neverthrow's `Result` / `ResultAsync` instead of throwing exceptions -- Activities return `ResultAsync` -- Client methods return `ResultAsync` with specific error types +- Use unthrown's `Result` / `AsyncResult` instead of throwing exceptions +- Activities return `AsyncResult` +- Client methods return `AsyncResult` with specific error types +- Narrow results with the **free functions** `isOk(r)` / `isErr(r)` / `isDefect(r)` — the `r.isOk()` methods return plain `boolean` and do not narrow `.value` / `.error` / `.cause` +- An unanticipated throw surfaces on unthrown's third **`defect`** channel, not as a typed `err`; build error classes with `TaggedError("Name")<{ ...payload }>` - Wrap technical exceptions in `ApplicationFailure` (re-exported from `@temporal-contract/worker/activity`) with a `type` field; set `nonRetryable: true` for permanent failures ## Module System diff --git a/.agents/rules/commands.md b/.agents/rules/commands.md index 7ba45602..8fab4621 100644 --- a/.agents/rules/commands.md +++ b/.agents/rules/commands.md @@ -43,7 +43,7 @@ pnpm test:integration # Run integration tests (requires Docker) | `chore` | Maintenance / housekeeping (release commits, lockfile bumps) | | `revert` | Reverts a prior commit | -Add `!` after the type for a breaking change (e.g. `feat!: replace boxed with neverthrow`). Header is capped at 100 chars; the type must be lowercase. +Add `!` after the type for a breaking change (e.g. `feat!: replace neverthrow with unthrown`). Header is capped at 100 chars; the type must be lowercase. ## Versioning & Release diff --git a/.agents/rules/dependencies.md b/.agents/rules/dependencies.md index fc7a8b8b..5d6327ce 100644 --- a/.agents/rules/dependencies.md +++ b/.agents/rules/dependencies.md @@ -9,7 +9,7 @@ | `@temporalio/workflow` | Temporal workflow API — peer dep of `worker` | | `@temporalio/common` | Shared Temporal types — peer dep of `client`/`worker` | | `@standard-schema/spec` | Standard Schema specification — direct dep | -| `neverthrow` | `Result` / `ResultAsync` — peer dep of `client`/`worker` | +| `unthrown` | `Result` / `AsyncResult` — peer dep of `client`/`worker` | | `zod` | Direct dep of `contract` (used internally for the `defineContract` runtime validation pass); user-side schema lib for the others | | `valibot` / `arktype` | User-side schema libraries (Standard Schema) | @@ -39,8 +39,8 @@ Anything that appears in a published package's **public type signatures** must b | Package | Peer dependencies | | -------- | -------------------------------------------------------------------------------------------- | -| client | `@temporalio/client ^1.16.0`, `@temporalio/common ^1`, `neverthrow ^8` | -| worker | `@temporalio/common ^1`, `@temporalio/worker ^1`, `@temporalio/workflow ^1`, `neverthrow ^8` | +| client | `@temporalio/client ^1.16.0`, `@temporalio/common ^1`, `unthrown ^0.1` | +| worker | `@temporalio/common ^1`, `@temporalio/worker ^1`, `@temporalio/workflow ^1`, `unthrown ^0.1` | | contract | none (pure type definitions) | | testing | `vitest ^4` (the `globalSetup` hook integrates with vitest's test runner) | diff --git a/.agents/rules/handlers.md b/.agents/rules/handlers.md index 0c8b8f15..7d3e8dc8 100644 --- a/.agents/rules/handlers.md +++ b/.agents/rules/handlers.md @@ -2,17 +2,17 @@ ## Activity Handler -Use `declareActivitiesHandler` with neverthrow's `ResultAsync`: +Use `declareActivitiesHandler` with unthrown's `AsyncResult`: ```typescript import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; export const activities = declareActivitiesHandler({ contract: myContract, activities: { validateInventory: (args) => - ResultAsync.fromPromise(inventoryService.check(args.orderId), (error) => + fromPromise(inventoryService.check(args.orderId), (error) => ApplicationFailure.create({ type: "INVENTORY_CHECK_FAILED", message: error instanceof Error ? error.message : "Failed to check inventory", @@ -23,6 +23,11 @@ export const activities = declareActivitiesHandler({ }); ``` +`fromPromise(promise, qualify)` forces every rejection through `qualify`, which +returns the modeled error `E` (here an `ApplicationFailure`). For a value you +already have, lift a sync result with `ok(value).toAsync()` / `err(failure).toAsync()` +— unthrown has no `okAsync`/`errAsync`. + Canonical example: `examples/order-processing-worker/src/application/activities.ts`. ## Workflow Declaration @@ -68,15 +73,17 @@ await worker.run(); ## Cancellation -Workflows opt into cancellation control via `context.cancellableScope` / `context.nonCancellableScope`. They fold cancellation into the project's `ResultAsync` shape — callers branch on `err(WorkflowCancelledError)` instead of catching `CancelledFailure`. +Workflows opt into cancellation control via `context.cancellableScope` / `context.nonCancellableScope`. They fold cancellation into the project's `AsyncResult` shape — callers branch on `err(WorkflowCancelledError)` instead of catching `CancelledFailure`. ```typescript +import { isErr } from "unthrown"; + implementation: async (context, args) => { const result = await context.cancellableScope(async () => { return context.activities.processStep(args); }); - if (result.isErr()) { + if (isErr(result)) { // Workflow was cancelled. Cleanup that must not be cancelled itself // goes inside `nonCancellableScope`. await context.nonCancellableScope(async () => { @@ -89,9 +96,9 @@ implementation: async (context, args) => { }; ``` -- `cancellableScope(fn)` — returns `ResultAsync`. Cancels propagate from outside. +- `cancellableScope(fn)` — returns `AsyncResult`. Cancels propagate from outside. - `nonCancellableScope(fn)` — same shape; _outside_ cancels are ignored. Cancels raised _inside_ still surface as `err(...)`. Use for graceful-shutdown cleanup. -- Non-cancellation errors thrown by `fn` propagate as `ResultAsync` rejections — they don't get wrapped, so domain errors keep their identity for upstream `try/catch`. +- Non-cancellation errors thrown by `fn` are _unmodeled_ failures: they ride unthrown's **`defect`** channel (inspectable via `isDefect(result)` / `result.cause`, re-thrown at the edge), not the modeled `err` channel. Canonical implementation: `packages/worker/src/cancellation.ts:38` (`cancellableScope`), `:75` (`nonCancellableScope`). Error class: `packages/worker/src/errors.ts:193`. @@ -122,7 +129,7 @@ ApplicationFailure.create({ ## Anti-patterns -- **Never throw** from activities — Temporal sees thrown errors as `ApplicationFailure(type: "Error", retryable: true)` by default, which masks the real failure type and triggers unwanted retries. Use `errAsync(ApplicationFailure.create({ type, message, nonRetryable }))` (or `.mapErr(...)` on a `ResultAsync.fromPromise(...)` chain) instead. +- **Never throw** from activities — Temporal sees thrown errors as `ApplicationFailure(type: "Error", retryable: true)` by default, which masks the real failure type and triggers unwanted retries. Use `err(ApplicationFailure.create({ type, message, nonRetryable })).toAsync()` (or a `fromPromise(promise, qualify)` chain whose `qualify` returns the `ApplicationFailure`) instead. - **Never use `any`** — use `unknown` and validate with schemas. Enforced by oxlint. - **Always use `.js` extensions** in imports (even for TypeScript files) — required by ESM module resolution. -- **Don't `try/catch` `CancelledFailure` in workflows** — use `cancellableScope` so cancellation flows through the same `ResultAsync` discipline as everything else. +- **Don't `try/catch` `CancelledFailure` in workflows** — use `cancellableScope` so cancellation flows through the same `AsyncResult` discipline as everything else. diff --git a/.agents/rules/project-overview.md b/.agents/rules/project-overview.md index 70cdc209..cdb2d32c 100644 --- a/.agents/rules/project-overview.md +++ b/.agents/rules/project-overview.md @@ -7,7 +7,7 @@ - Monorepo managed with **pnpm workspaces** and **Turborepo** - Packages publish to npm under the `@temporal-contract/` scope - Uses **Standard Schema** (Zod, Valibot, ArkType) for runtime validation -- Uses **Result/ResultAsync** pattern (via `neverthrow`) instead of throwing exceptions +- Uses **Result/AsyncResult** pattern (via `unthrown`) instead of throwing exceptions ## Repo Layout @@ -24,13 +24,13 @@ | ---------- | --------------------------------------------------- | ---------------------------------------------------------- | | `contract` | `packages/contract/src/builder.ts` | Contract builder (`defineContract`) and type definitions | | `worker` | `packages/worker/src/{activity,workflow,worker}.ts` | Type-safe worker, workflow declarations, activity handlers | -| `client` | `packages/client/src/client.ts` | Type-safe client for consuming workflows via `ResultAsync` | +| `client` | `packages/client/src/client.ts` | Type-safe client for consuming workflows via `AsyncResult` | | `testing` | `packages/testing/src/global-setup.ts` | Testing utilities (global setup, Temporal test server) | ## Key Concepts - **Contract** — defines task queue, workflows, activities, signals, queries, updates, search attributes with schemas. See [contract-patterns.md](./contract-patterns.md). - **Worker** — `declareWorkflow` + `declareActivitiesHandler` with automatic validation. See [handlers.md](./handlers.md). -- **Client** — `TypedClient.create()` returns `ResultAsync` for all operations. -- **Result** — `Result` and `ResultAsync` from neverthrow for explicit error handling. +- **Client** — `TypedClient.create()` returns `AsyncResult` for all operations. +- **Result** — `Result` and `AsyncResult` from unthrown for explicit error handling, plus a third `defect` channel for unanticipated failures. - **Determinism** — workflow code runs in Temporal's replay sandbox. See [workflow-determinism.md](./workflow-determinism.md). diff --git a/.agents/rules/workflow-determinism.md b/.agents/rules/workflow-determinism.md index 827ccc45..2fad25aa 100644 --- a/.agents/rules/workflow-determinism.md +++ b/.agents/rules/workflow-determinism.md @@ -26,7 +26,7 @@ That's also why activity inputs/outputs must be serializable (validated through ## Cancellation primitives are deterministic -Use `context.cancellableScope` / `context.nonCancellableScope` (`packages/worker/src/cancellation.ts:38`, `:75`) — they wrap Temporal's `CancellationScope` and surface cancellation as `err(WorkflowCancelledError)` in a `ResultAsync`. Don't `try/catch` `CancelledFailure` directly; that bypasses the project's `Result` discipline. +Use `context.cancellableScope` / `context.nonCancellableScope` (`packages/worker/src/cancellation.ts:38`, `:75`) — they wrap Temporal's `CancellationScope` and surface cancellation as `err(WorkflowCancelledError)` in an `AsyncResult`. Don't `try/catch` `CancelledFailure` directly; that bypasses the project's `Result` discipline. ## Side-effect escape hatch @@ -36,4 +36,4 @@ If you absolutely need non-determinism inside workflow code (e.g. logging at a c - `examples/order-processing-worker/src/application/workflows.ts` — uses `context.activities.*` for every effectful call, never reaches for native primitives. - `packages/worker/src/__tests__/test.workflows.ts` — minimal workflows used in integration tests. -- `packages/worker/src/cancellation.ts:38` — `cancellableScope` implementation showing the `ResultAsync` adapter pattern. +- `packages/worker/src/cancellation.ts:38` — `cancellableScope` implementation showing the `AsyncResult` adapter pattern. diff --git a/.changeset/migrate-to-unthrown.md b/.changeset/migrate-to-unthrown.md new file mode 100644 index 00000000..70b50ae6 --- /dev/null +++ b/.changeset/migrate-to-unthrown.md @@ -0,0 +1,20 @@ +--- +"@temporal-contract/contract": major +"@temporal-contract/worker": major +"@temporal-contract/client": major +"@temporal-contract/testing": major +--- + +Replace `neverthrow` with [`unthrown`](https://github.com/btravstack/unthrown) for the Result/error-handling spine across all packages. This is a breaking change to the public API. + +**What changed** + +- **`ResultAsync` → `AsyncResult`.** Every activity, workflow-context, child-workflow, schedule, and typed-client method that returned a `ResultAsync` now returns an `AsyncResult`. The `unthrown` peer dependency replaces `neverthrow`. +- **No `okAsync` / `errAsync`.** Lift a synchronous `Result` with `.toAsync()` instead: `ok(value).toAsync()`, `err(failure).toAsync()`. Promise boundaries use `fromPromise(promise, qualify)` / `fromSafePromise(promise)`. +- **Narrowing uses free functions.** `unthrown`'s `result.isOk()` / `isErr()` _methods_ return a plain `boolean` and do not narrow. Use `isOk(result)` / `isErr(result)` / `isDefect(result)` (imported from `unthrown`) before accessing `.value` / `.error` / `.cause`. +- **New `defect` channel.** Unanticipated throws (a thrown exception the code did not model) now surface on `unthrown`'s third `defect` channel — inspected via `isDefect(result)` / `result.cause` and re-thrown at the edge — rather than as a typed `err`. Deliberate boundary classification (e.g. mapping a Temporal SDK rejection to `WorkflowExecutionNotFoundError`) still produces a modeled `err`. `result.match({ ok, err, defect })` folds all three. +- **`WorkflowScopeError` removed.** Non-cancellation errors thrown inside `cancellableScope` / `nonCancellableScope` are unmodeled failures and now ride the `defect` channel. The scopes' error union narrows to `WorkflowCancelledError`. +- **The client's "unexpected" `RuntimeClientError` wrap is gone.** An unanticipated rejection in a client operation now surfaces as a defect, not a manufactured `RuntimeClientError`. `RuntimeClientError` is still produced by deliberate boundary classification. +- **Error classes use `TaggedError`.** The worker `WorkerError` hierarchy and the entire client `TypedClientError` hierarchy are now built with `unthrown`'s `TaggedError("Name")<{ ...payload }>`, each carrying a `_tag` discriminant (foldable with `matchTags`). `ChildWorkflowCancelledError` is now a sibling of `ChildWorkflowError` (distinct `_tag`) rather than a subclass — discriminate on `_tag` / `instanceof ChildWorkflowCancelledError` instead of relying on `instanceof ChildWorkflowError` matching cancellation. The worker's `ValidationError` subclasses are unchanged — they still extend Temporal's `ApplicationFailure` for terminal-failure semantics. + +See the [Migrating from neverthrow](https://btravstack.github.io/temporal-contract/guide/migrating-to-unthrown) guide. diff --git a/AGENTS.md b/AGENTS.md index 07446217..e0cf70ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ This file is the source of truth for agent guidance in this repo. `CLAUDE.md` an ## The 6 rules that prevent broken PRs 1. **Workflow code is deterministic.** No `Date.now()`, `Math.random()`, `setTimeout`, `crypto.randomUUID()`, native I/O, or `process.env` reads inside `declareWorkflow`'s `implementation`. Use `@temporalio/workflow` primitives (`sleep`, `uuid4`, the patched `Date`) or push the side effect into an activity. See [.agents/rules/workflow-determinism.md](.agents/rules/workflow-determinism.md). This is the #1 cause of broken Temporal workflows — read that file before touching workflow code. -2. **Activities and the typed client return `ResultAsync` from `neverthrow`.** Never throw — wrap technical errors in `ApplicationFailure` and surface them via `errAsync(...)` (or `.mapErr(...)` on a `ResultAsync.fromPromise(...)` chain). The client uses neverthrow's `Result` for sync returns. There is no `@swan-io/boxed` and no `@temporal-contract/boxed` package — those were removed. +2. **Activities and the typed client return `AsyncResult` from `unthrown`.** Never throw — wrap technical errors in `ApplicationFailure` and surface them via `err(...).toAsync()` (or `fromPromise(promise, qualify)`, where `qualify` returns the modeled error `E`). unthrown has no `okAsync`/`errAsync`: lift a sync `Result` with `.toAsync()`. The client uses unthrown's `Result` for sync returns. unthrown adds a third **`defect`** channel for _unanticipated_ failures — a thrown exception the code didn't model surfaces as a defect (inspectable via `isDefect(result)` / `result.cause`, re-thrown at the edge), not a typed `err`. Narrow with the **free functions** `isOk(r)`/`isErr(r)`/`isDefect(r)` (the `r.isOk()` _methods_ return plain `boolean` and do **not** narrow). Error classes are built with `TaggedError("Name")<{ ...payload }>` — except the worker's `ValidationError` subclasses, which must stay `ApplicationFailure` for Temporal's terminal-failure semantics. There is no `neverthrow`, no `@swan-io/boxed`, and no `@temporal-contract/boxed` package — those were removed. 3. **No `any`.** Use `unknown` and narrow. Enforced by oxlint. 4. **`.js` extensions in every import.** TypeScript files import each other as `./foo.js`, never `./foo` or `./foo.ts`. Required by ESM module resolution. 5. **ESM only.** All packages are `"type": "module"`. No CommonJS in source. diff --git a/README.md b/README.md index dd16b7b4..57db2f9b 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ End-to-end type safety and automatic validation for workflows and activities - ✅ **Automatic validation** — Zod schemas validate at all network boundaries - ✅ **Compile-time checks** — TypeScript catches missing or incorrect implementations - ✅ **Better DX** — Autocomplete, refactoring support, inline documentation -- ✅ **Child workflows** — Type-safe child workflow execution with neverthrow's `ResultAsync` -- ✅ **Result pattern** — Explicit error handling without exceptions, powered by [neverthrow](https://github.com/supermacro/neverthrow) +- ✅ **Child workflows** — Type-safe child workflow execution with unthrown's `AsyncResult` +- ✅ **Result pattern** — Explicit error handling without exceptions, powered by [unthrown](https://github.com/btravstack/unthrown) - 🚧 **Nexus support** — Cross-namespace operations (planned for v0.5.0) ## Quick Example @@ -46,15 +46,15 @@ const contract = defineContract({ }, }); -// Implement activities with neverthrow's ResultAsync +// Implement activities with unthrown's AsyncResult import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; const activities = declareActivitiesHandler({ contract, activities: { processPayment: ({ orderId }) => - ResultAsync.fromPromise(paymentService.process(orderId), (error) => + fromPromise(paymentService.process(orderId), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: error instanceof Error ? error.message : "Payment failed", @@ -77,8 +77,8 @@ const result = await client.executeWorkflow("processOrder", { # Core packages pnpm add @temporal-contract/contract @temporal-contract/worker @temporal-contract/client -# Result/ResultAsync — peer dep used by worker/client APIs -pnpm add neverthrow +# Result/AsyncResult — peer dep used by worker/client APIs +pnpm add unthrown ``` ## Documentation @@ -101,7 +101,7 @@ pnpm add neverthrow ## Usage Patterns -temporal-contract uses **[neverthrow](https://github.com/supermacro/neverthrow)** end-to-end (workflows, activities, and the typed client) for explicit error handling via `Result` and `ResultAsync`. Migrating from a previous release that used `@swan-io/boxed`? See [Migrating to neverthrow](https://btravstack.github.io/temporal-contract/guide/migrating-to-neverthrow). +temporal-contract uses **[unthrown](https://github.com/btravstack/unthrown)** end-to-end (workflows, activities, and the typed client) for explicit error handling via `Result` and `AsyncResult`, with a separate `defect` channel for unanticipated failures. Migrating from a previous release that used `neverthrow`? See [Migrating to unthrown](https://btravstack.github.io/temporal-contract/guide/migrating-to-unthrown). ## Contributing diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 6f8107d1..42068965 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -104,6 +104,7 @@ export default withMermaid( items: [ { text: "Result Pattern", link: "/guide/result-pattern" }, { text: "Migrating from @swan-io/boxed", link: "/guide/migrating-to-neverthrow" }, + { text: "Migrating from neverthrow", link: "/guide/migrating-to-unthrown" }, { text: "Worker Implementation", link: "/guide/worker-implementation" }, { text: "Entry Points Architecture", link: "/guide/entry-points" }, { text: "Activity Handler Types", link: "/guide/activity-handlers" }, diff --git a/docs/api/index.md b/docs/api/index.md index afa9d0c9..293400b6 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -8,10 +8,10 @@ Welcome to the temporal-contract API documentation. This documentation is auto-g - [@temporal-contract/client](./client/) - Type-safe Temporal client - [@temporal-contract/worker](./worker/) - Type-safe Temporal worker -The `Result` / `ResultAsync` types used throughout the API surface come from -[`neverthrow`](https://github.com/supermacro/neverthrow). See -[Migrating to neverthrow](/guide/migrating-to-neverthrow) if you are upgrading -from the previous `@swan-io/boxed`-based version. +The `Result` / `AsyncResult` types used throughout the API surface come from +[`unthrown`](https://github.com/btravstack/unthrown). See +[Migrating from neverthrow](/guide/migrating-to-unthrown) if you are upgrading +from the previous `neverthrow`-based version. ## Testing diff --git a/docs/examples/basic-order-processing.md b/docs/examples/basic-order-processing.md index 49d0cd86..838185be 100644 --- a/docs/examples/basic-order-processing.md +++ b/docs/examples/basic-order-processing.md @@ -1,13 +1,13 @@ # Order Processing Example -A complete e-commerce order processing workflow example using `Result` / `ResultAsync` from neverthrow. +A complete e-commerce order processing workflow example using `Result` / `AsyncResult` from unthrown. ## Overview This example demonstrates: - **Separated contract package**: Contract is in its own package that can be shared -- **Result / ResultAsync pattern**: Explicit error handling with type-safe errors +- **Result / AsyncResult pattern**: Explicit error handling with type-safe errors - Order validation - Payment processing - Inventory management @@ -50,7 +50,7 @@ examples/ └── order-processing-worker/ # Worker/Client implementation ├── src/ │ ├── application/ - │ │ ├── activities.ts # Activity implementations (ResultAsync) + │ │ ├── activities.ts # Activity implementations (AsyncResult) │ │ ├── workflows.ts # Workflow implementations │ │ ├── worker.ts # Worker setup │ │ └── client.ts # Client example @@ -70,22 +70,22 @@ The contract is separated into its own package (`order-processing-contract`) whi - Provides full TypeScript type safety across all boundaries - Can be versioned and published independently -### Result / ResultAsync Pattern +### Result / AsyncResult Pattern -This example demonstrates the neverthrow-based pattern: +This example demonstrates the unthrown-based pattern: -- **neverthrow** is used in activities, workflows, and clients — one library +- **unthrown** is used in activities, workflows, and clients — one library covers every context -- Activities return `ResultAsync` instead of throwing +- Activities return `AsyncResult` instead of throwing - Child workflow calls return `Result` for explicit error handling - Errors are part of the type signature -- Enables railway-oriented programming via `.andThen` / `.map` / `.mapErr` +- Enables railway-oriented programming via `.flatMap` / `.map` / `.mapErr` ### Worker Application The worker application imports the contract package and implements: -- Activities that match the contract signatures with `ResultAsync` +- Activities that match the contract signatures with `AsyncResult` - Workflows that use the contract's type definitions - Worker setup that registers the implementations @@ -124,7 +124,7 @@ pnpm dev # Terminal 2 - Run client ## Benefits of This Architecture 1. **Contract Reusability**: The contract can be imported by multiple applications -2. **Type Safety**: Full TypeScript support with `Result` / `ResultAsync` types +2. **Type Safety**: Full TypeScript support with `Result` / `AsyncResult` types 3. **Explicit Error Handling**: Errors are part of the type system 4. **Independent Deployment**: Client and worker can be in different repositories 5. **Clear Separation**: Contract definition is separate from implementation diff --git a/docs/examples/index.md b/docs/examples/index.md index 220a6af7..7e15f80b 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -32,7 +32,7 @@ graph TB ### [Order Processing Example](/examples/basic-order-processing) -A complete e-commerce order processing workflow using `Result` / `ResultAsync` from neverthrow for explicit error handling. +A complete e-commerce order processing workflow using `Result` / `AsyncResult` from unthrown for explicit error handling. **Features:** @@ -180,11 +180,11 @@ export const orderContract = defineContract({ ### Activity Implementation -Clean, typed activity implementations with `ResultAsync`: +Clean, typed activity implementations with `AsyncResult`: ```typescript import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { orderContract } from "../contracts/order.contract"; import { emailService } from "../infrastructure/email.service"; import { paymentService } from "../infrastructure/payment.service"; @@ -193,7 +193,7 @@ export const activities = declareActivitiesHandler({ contract: orderContract, activities: { sendEmail: ({ to, subject, body }) => - ResultAsync.fromPromise(emailService.send({ to, subject, body }), (error) => + fromPromise(emailService.send({ to, subject, body }), (error) => ApplicationFailure.create({ type: "EMAIL_FAILED", message: error instanceof Error ? error.message : "Failed to send email", @@ -203,7 +203,7 @@ export const activities = declareActivitiesHandler({ processOrder: { validateInventory: ({ items }) => - ResultAsync.fromPromise(inventoryService.checkAvailability(items), (error) => + fromPromise(inventoryService.checkAvailability(items), (error) => ApplicationFailure.create({ type: "INVENTORY_CHECK_FAILED", message: "Failed to check inventory", @@ -212,7 +212,7 @@ export const activities = declareActivitiesHandler({ ).map((available) => ({ available })), processPayment: ({ customerId, amount }) => - ResultAsync.fromPromise(paymentService.charge(customerId, amount), (error) => + fromPromise(paymentService.charge(customerId, amount), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: "Failed to process payment", diff --git a/docs/guide/activity-handlers.md b/docs/guide/activity-handlers.md index f5a499fc..ce8f4b31 100644 --- a/docs/guide/activity-handlers.md +++ b/docs/guide/activity-handlers.md @@ -13,15 +13,15 @@ Instead of defining activity implementations inline, you can extract types for r ```typescript import type { ActivitiesHandler } from "@temporal-contract/worker/activity"; import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { orderContract } from "./contract"; // Extract all activity handler types from contract type OrderActivitiesHandler = ActivitiesHandler; -// Implement activities with explicit types using ResultAsync +// Implement activities with explicit types using AsyncResult const sendEmail: OrderActivitiesHandler["sendEmail"] = ({ to, body }) => - ResultAsync.fromPromise(emailService.send({ to, body }), (error) => + fromPromise(emailService.send({ to, body }), (error) => ApplicationFailure.create({ type: "EMAIL_FAILED", message: error instanceof Error ? error.message : "Failed to send email", @@ -30,7 +30,7 @@ const sendEmail: OrderActivitiesHandler["sendEmail"] = ({ to, body }) => ).map(() => ({ sent: true })); const processPayment: OrderActivitiesHandler["processPayment"] = ({ amount }) => - ResultAsync.fromPromise(paymentGateway.charge(amount), (error) => + fromPromise(paymentGateway.charge(amount), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: error instanceof Error ? error.message : "Payment failed", @@ -59,8 +59,8 @@ import type { ActivitiesHandler } from "@temporal-contract/worker/activity"; type MyActivities = ActivitiesHandler; // { -// sendEmail: (input: { to: string, body: string }) => ResultAsync<{ sent: boolean }, ApplicationFailure>; -// processPayment: (input: { amount: number }) => ResultAsync<{ transactionId: string }, ApplicationFailure>; +// sendEmail: (input: { to: string, body: string }) => AsyncResult<{ sent: boolean }, ApplicationFailure>; +// processPayment: (input: { amount: number }) => AsyncResult<{ transactionId: string }, ApplicationFailure>; // } ``` @@ -73,8 +73,8 @@ type SendEmailHandler = ActivitiesHandler["sendEmail"]; type ProcessPaymentHandler = ActivitiesHandler["processPayment"]; const sendEmail: SendEmailHandler = ({ to, body }) => { - // Implementation — must return ResultAsync - return okAsync({ sent: true }); + // Implementation — must return AsyncResult + return ok({ sent: true }).toAsync(); }; ``` @@ -88,13 +88,13 @@ Implement activities in separate files: // activities/email.ts import type { ActivitiesHandler } from "@temporal-contract/worker/activity"; import { ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { orderContract } from "../contracts/order.contract"; type Handlers = ActivitiesHandler; export const sendEmail: Handlers["sendEmail"] = ({ to, body }) => - ResultAsync.fromPromise(emailService.send({ to, body }), (error) => + fromPromise(emailService.send({ to, body }), (error) => ApplicationFailure.create({ type: "EMAIL_FAILED", message: error instanceof Error ? error.message : "Failed to send email", @@ -107,13 +107,13 @@ export const sendEmail: Handlers["sendEmail"] = ({ to, body }) => // activities/payment.ts import type { ActivitiesHandler } from "@temporal-contract/worker/activity"; import { ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { orderContract } from "../contracts/order.contract"; type Handlers = ActivitiesHandler; export const processPayment: Handlers["processPayment"] = ({ amount }) => - ResultAsync.fromPromise(paymentGateway.charge(amount), (error) => + fromPromise(paymentGateway.charge(amount), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: error instanceof Error ? error.message : "Payment failed", @@ -145,14 +145,14 @@ Create factory functions with typed activities: ```typescript import type { ActivitiesHandler } from "@temporal-contract/worker/activity"; import { ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; type Handlers = ActivitiesHandler; export const createEmailActivity = (emailService: EmailService): Handlers["sendEmail"] => ({ to, body }) => - ResultAsync.fromPromise(emailService.send({ to, body }), (error) => + fromPromise(emailService.send({ to, body }), (error) => ApplicationFailure.create({ type: "EMAIL_FAILED", message: error instanceof Error ? error.message : "Failed", @@ -163,7 +163,7 @@ export const createEmailActivity = export const createPaymentActivity = (paymentGateway: PaymentGateway): Handlers["processPayment"] => ({ amount }) => - ResultAsync.fromPromise(paymentGateway.charge(amount), (error) => + fromPromise(paymentGateway.charge(amount), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: error instanceof Error ? error.message : "Failed", @@ -193,14 +193,14 @@ Mock activities with correct types: ```typescript import type { ActivitiesHandler } from "@temporal-contract/worker/activity"; -import { okAsync } from "neverthrow"; +import { ok } from "unthrown"; type Handlers = ActivitiesHandler; // Create mock activities for testing const mockActivities: Handlers = { - sendEmail: ({ to, body }) => okAsync({ sent: true }), - processPayment: ({ amount }) => okAsync({ transactionId: "TEST-TXN" }), + sendEmail: ({ to, body }) => ok({ sent: true }).toAsync(), + processPayment: ({ amount }) => ok({ transactionId: "TEST-TXN" }).toAsync(), }; // Use in tests @@ -316,10 +316,10 @@ Always extract types for better maintainability: ```typescript // ✅ Good type Handlers = ActivitiesHandler; -const sendEmail: Handlers["sendEmail"] = ({ to, body }) => okAsync({ sent: true }); +const sendEmail: Handlers["sendEmail"] = ({ to, body }) => ok({ sent: true }).toAsync(); // ❌ Avoid inline typing -const sendEmail = ({ to, body }: { to: string; body: string }) => okAsync({ sent: true }); +const sendEmail = ({ to, body }: { to: string; body: string }) => ok({ sent: true }).toAsync(); ``` ### 2. Organize by Domain @@ -343,7 +343,7 @@ Make activities testable and configurable: ```typescript export const createActivities = (services: Services) => { const sendEmail: Handlers["sendEmail"] = ({ to, body }) => - ResultAsync.fromPromise(services.email.send({ to, body }), (error) => + fromPromise(services.email.send({ to, body }), (error) => ApplicationFailure.create({ type: "EMAIL_FAILED", message: error instanceof Error ? error.message : "Failed", diff --git a/docs/guide/client-usage.md b/docs/guide/client-usage.md index df2e4143..b3430abf 100644 --- a/docs/guide/client-usage.md +++ b/docs/guide/client-usage.md @@ -9,7 +9,7 @@ The `@temporal-contract/client` package provides a type-safe wrapper around Temp ## Installation ```bash -pnpm add @temporal-contract/client neverthrow +pnpm add @temporal-contract/client unthrown ``` ## Basic Setup @@ -33,8 +33,8 @@ const client = TypedClient.create(myContract, temporalClient); ### Basic Execution -Execute a workflow and wait for completion. `executeWorkflow` returns a -`ResultAsync` — `await` it to get a `Result`: +Execute a workflow and wait for completion. `executeWorkflow` returns an +`AsyncResult` — `await` it to get a `Result`: ```typescript const resultAsync = client.executeWorkflow("processOrder", { @@ -45,18 +45,21 @@ const resultAsync = client.executeWorkflow("processOrder", { }, }); -// await the ResultAsync to get the Result +// await the AsyncResult to get the Result const result = await resultAsync; -// Handle the Result with pattern matching (positional callbacks) -result.match( - (output) => { +// Handle the Result with pattern matching (object form, three channels) +result.match({ + ok: (output) => { console.log("Order processed:", output.status); // TypeScript knows the shape! }, - (error) => { + err: (error) => { console.error("Workflow failed:", error); }, -); + defect: (cause) => { + console.error("Unexpected failure:", cause); + }, +}); ``` ### Start Without Waiting @@ -72,22 +75,26 @@ const handleResult = await client.startWorkflow("processOrder", { }, }); -handleResult.match( - async (handle) => { +handleResult.match({ + ok: async (handle) => { // Get workflow ID console.log("Started workflow:", handle.workflowId); // Wait for result later const result = await handle.result(); - result.match( - (output) => console.log("Completed:", output), - (error) => console.error("Failed:", error), - ); + result.match({ + ok: (output) => console.log("Completed:", output), + err: (error) => console.error("Failed:", error), + defect: (cause) => console.error("Unexpected failure:", cause), + }); }, - (error) => { + err: (error) => { console.error("Failed to start workflow:", error); }, -); + defect: (cause) => { + console.error("Unexpected failure:", cause); + }, +}); ``` ## Type Safety @@ -124,25 +131,28 @@ await client.executeWorkflow("invalidWorkflow", { ## Result Pattern -The client uses `neverthrow` for explicit error handling: +The client uses `unthrown` for explicit error handling: ```typescript -import { Result } from "neverthrow"; +import { Result } from "unthrown"; const result = await client.executeWorkflow("processOrder", { workflowId: "order-123", args: { orderId: "ORD-123", customerId: "CUST-456" }, }); -// Handle result with pattern matching (positional callbacks) -result.match( - (value) => { +// Handle result with pattern matching (object form, three channels) +result.match({ + ok: (value) => { console.log("Order processed:", value.transactionId); }, - (error) => { + err: (error) => { console.error("Order failed:", error); }, -); + defect: (cause) => { + console.error("Unexpected failure:", cause); + }, +}); ``` ## Workflow Options @@ -176,31 +186,35 @@ Get a handle to an existing workflow: ```typescript const handleResult = await client.getHandle("processOrder", "order-123"); -handleResult.match( - async (handle) => { +handleResult.match({ + ok: async (handle) => { // Query the workflow const statusResult = await handle.queries.getStatus({}); - statusResult.match( - (status) => console.log("Status:", status), - (error) => console.error("Query failed:", error), - ); + statusResult.match({ + ok: (status) => console.log("Status:", status), + err: (error) => console.error("Query failed:", error), + defect: (cause) => console.error("Unexpected failure:", cause), + }); // Signal the workflow const signalResult = await handle.signals.cancelOrder({ reason: "Customer request" }); - signalResult.match( - () => console.log("Signal sent"), - (error) => console.error("Signal failed:", error), - ); + signalResult.match({ + ok: () => console.log("Signal sent"), + err: (error) => console.error("Signal failed:", error), + defect: (cause) => console.error("Unexpected failure:", cause), + }); // Get the result const result = await handle.result(); - result.match( - (output) => console.log("Result:", output), - (error) => console.error("Workflow failed:", error), - ); + result.match({ + ok: (output) => console.log("Result:", output), + err: (error) => console.error("Workflow failed:", error), + defect: (cause) => console.error("Unexpected failure:", cause), + }); }, - (error) => console.error("Failed to get handle:", error), -); + err: (error) => console.error("Failed to get handle:", error), + defect: (cause) => console.error("Unexpected failure:", cause), +}); ``` ## Multiple Workflows @@ -229,10 +243,11 @@ const result = await client.executeWorkflow("processOrder", { args: { orderId: "ORD-123", customerId: "CUST-456" }, }); -result.match( - (value) => console.log("Success:", value), - (error) => console.error("Workflow returned error:", error), -); +result.match({ + ok: (value) => console.log("Success:", value), + err: (error) => console.error("Workflow returned error:", error), + defect: (cause) => console.error("Unexpected failure:", cause), +}); ``` ### Workflow Failures @@ -325,7 +340,7 @@ Mock the client for testing: ```typescript import { describe, it, expect, vi } from "vitest"; -import { ok } from "neverthrow"; +import { ok } from "unthrown"; describe("OrderService", () => { it("should process order", async () => { @@ -389,22 +404,27 @@ const result = await client.executeWorkflow("processOrder", { args: { orderId: "ORD-123", customerId: "CUST-456" }, }); -result.match( - (value) => { +result.match({ + ok: (value) => { // Handle success updateDatabase(value); }, - (error) => { + err: (error) => { // Handle error logError(error); notifySupport(error); }, -); + defect: (cause) => { + // Handle unexpected failure (bug) + logError(cause); + notifySupport(cause); + }, +}); ``` ## See Also - [Defining Contracts](/guide/defining-contracts) - Creating your contract definitions - [Worker Usage](/guide/worker-usage) - Implementing workflows and activities -- [Result Pattern](/guide/result-pattern) - Understanding Result/ResultAsync error handling +- [Result Pattern](/guide/result-pattern) - Understanding Result/AsyncResult error handling - [API Reference](/api/client) - Complete client API documentation diff --git a/docs/guide/core-concepts.md b/docs/guide/core-concepts.md index b20d0988..e5655e9e 100644 --- a/docs/guide/core-concepts.md +++ b/docs/guide/core-concepts.md @@ -142,10 +142,11 @@ const result = await client.executeWorkflow("processOrder", { args: { orderId: "ORD-123", amount: 100 }, }); -// result is a Result — use .match() or .isOk() to unwrap +// result is a Result — use .match({ ok, err, defect }) or isOk(result) to unwrap result.match({ - Ok: (output) => console.log(output.transactionId), - Error: (error) => console.error("Workflow failed:", error), + ok: (output) => console.log(output.transactionId), + err: (error) => console.error("Workflow failed:", error), + defect: (cause) => console.error("Unexpected failure:", cause), }); ``` diff --git a/docs/guide/entry-points.md b/docs/guide/entry-points.md index d2158d32..50d9ecf3 100644 --- a/docs/guide/entry-points.md +++ b/docs/guide/entry-points.md @@ -120,14 +120,14 @@ Create a single activities handler in your worker application: ```typescript // worker-application/src/activities/index.ts import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { orderContract } from "contract-package"; export const activities = declareActivitiesHandler({ contract: orderContract, activities: { sendEmail: ({ to, body }) => - ResultAsync.fromPromise(emailService.send({ to, body }), (error) => + fromPromise(emailService.send({ to, body }), (error) => ApplicationFailure.create({ type: "EMAIL_FAILED", message: error instanceof Error ? error.message : "Failed to send email", @@ -135,7 +135,7 @@ export const activities = declareActivitiesHandler({ }), ).map(() => ({ sent: true })), processPayment: ({ amount }) => - ResultAsync.fromPromise(paymentGateway.charge(amount), (error) => + fromPromise(paymentGateway.charge(amount), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: error instanceof Error ? error.message : "Payment failed", @@ -217,23 +217,25 @@ const connection = await Connection.connect({ const temporalClient = new Client({ connection, namespace: "default" }); const client = TypedClient.create(orderContract, temporalClient); -// Start workflow with full type safety (returns ResultAsync) +// Start workflow with full type safety (returns AsyncResult) const handleResult = await client.startWorkflow("processOrder", { workflowId: "order-123", args: { orderId: "ORD-123" }, // ✅ Type-checked! }); // Unwrap the Result and wait for the workflow result -handleResult.match( - async (handle) => { +handleResult.match({ + ok: async (handle) => { const result = await handle.result(); - result.match( - (output) => console.log(output.success), // ✅ TypeScript knows the shape - (error) => console.error("Workflow failed:", error), - ); + result.match({ + ok: (output) => console.log(output.success), // ✅ TypeScript knows the shape + err: (error) => console.error("Workflow failed:", error), + defect: (cause) => console.error("Unexpected failure:", cause), + }); }, - (error) => console.error("Failed to start workflow:", error), -); + err: (error) => console.error("Failed to start workflow:", error), + defect: (cause) => console.error("Unexpected failure:", cause), +}); ``` ## Multiple Workflows @@ -273,14 +275,14 @@ const contract = defineContract({ }, }); -// Activities handler must match — must return ResultAsync +// Activities handler must match — must return AsyncResult declareActivitiesHandler({ contract, activities: { processPayment: ({ amount }) => { // ✅ amount is number - return okAsync({ transactionId: "TXN-123" }); - // ✅ Must return ResultAsync<{ transactionId: string }, ApplicationFailure> + return ok({ transactionId: "TXN-123" }).toAsync(); + // ✅ Must return AsyncResult<{ transactionId: string }, ApplicationFailure> }, }, }); @@ -368,22 +370,22 @@ Clear separation of concerns: ```typescript // activities/shared.ts -import { okAsync } from "neverthrow"; +import { ok } from "unthrown"; export const sharedActivities = { - sendEmail: ({ to, body }) => okAsync({ sent: true }), - logEvent: ({ event }) => okAsync({ logged: true }), + sendEmail: ({ to, body }) => ok({ sent: true }).toAsync(), + logEvent: ({ event }) => ok({ logged: true }).toAsync(), }; // activities/order.ts -import { okAsync } from "neverthrow"; +import { ok } from "unthrown"; import { sharedActivities } from "./shared"; export const orderActivities = declareActivitiesHandler({ contract: orderContract, activities: { ...sharedActivities, - processPayment: ({ amount }) => okAsync({ transactionId: "TXN" }), + processPayment: ({ amount }) => ok({ transactionId: "TXN" }).toAsync(), }, }); ``` @@ -392,14 +394,14 @@ export const orderActivities = declareActivitiesHandler({ ```typescript // activities/index.ts -import { okAsync } from "neverthrow"; +import { ok } from "unthrown"; const baseActivities = { - validateInput: ({ data }) => okAsync({ valid: true }), + validateInput: ({ data }) => ok({ valid: true }).toAsync(), }; const paymentActivities = { - processPayment: ({ amount }) => okAsync({ transactionId: "TXN" }), + processPayment: ({ amount }) => ok({ transactionId: "TXN" }).toAsync(), }; export const activities = declareActivitiesHandler({ diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index ad793012..fb8bbd70 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -33,17 +33,17 @@ Install the required packages: ::: code-group ```bash [pnpm] -pnpm add @temporal-contract/contract @temporal-contract/worker @temporal-contract/client neverthrow +pnpm add @temporal-contract/contract @temporal-contract/worker @temporal-contract/client unthrown pnpm add zod @temporalio/client @temporalio/worker @temporalio/workflow ``` ```bash [npm] -npm install @temporal-contract/contract @temporal-contract/worker @temporal-contract/client neverthrow +npm install @temporal-contract/contract @temporal-contract/worker @temporal-contract/client unthrown npm install zod @temporalio/client @temporalio/worker @temporalio/workflow ``` ```bash [yarn] -yarn add @temporal-contract/contract @temporal-contract/worker @temporal-contract/client neverthrow +yarn add @temporal-contract/contract @temporal-contract/worker @temporal-contract/client unthrown yarn add zod @temporalio/client @temporalio/worker @temporalio/workflow ``` @@ -123,7 +123,7 @@ Implement your activities and workflows with full type safety: ```typescript // activities.ts import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { orderContract } from "./contract"; export const activities = declareActivitiesHandler({ @@ -131,7 +131,7 @@ export const activities = declareActivitiesHandler({ activities: { sendEmail: ({ to, subject, body }) => // Full type safety - parameters are automatically typed! - ResultAsync.fromPromise(emailService.send({ to, subject, body }), (error) => + fromPromise(emailService.send({ to, subject, body }), (error) => ApplicationFailure.create({ type: "EMAIL_FAILED", message: error instanceof Error ? error.message : "Failed to send email", @@ -140,7 +140,7 @@ export const activities = declareActivitiesHandler({ ).map(() => ({ sent: true })), processPayment: ({ customerId, amount }) => // TypeScript knows the exact types - ResultAsync.fromPromise(paymentGateway.charge(customerId, amount), (error) => + fromPromise(paymentGateway.charge(customerId, amount), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: error instanceof Error ? error.message : "Payment failed", @@ -214,7 +214,7 @@ const connection = await Connection.connect({ const temporalClient = new Client({ connection }); const client = TypedClient.create(orderContract, temporalClient); -// Fully typed workflow execution with Result/ResultAsync pattern +// Fully typed workflow execution with Result/AsyncResult pattern const resultAsync = client.executeWorkflow("processOrder", { workflowId: "order-123", args: { orderId: "ORD-123", customerId: "CUST-456" }, @@ -222,14 +222,17 @@ const resultAsync = client.executeWorkflow("processOrder", { const result = await resultAsync; -result.match( - (output) => { +result.match({ + ok: (output) => { console.log(output.status); // 'success' | 'failed' — fully typed! }, - (error) => { + err: (error) => { console.error("Workflow failed:", error); }, -); + defect: (cause) => { + console.error("Unexpected failure:", cause); + }, +}); ``` ## What's Next? diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 969e0e53..d64578cc 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -21,10 +21,10 @@ temporal-contract consists of multiple packages. Install the ones you need: pnpm add @temporal-contract/contract # Worker implementation -pnpm add @temporal-contract/worker neverthrow +pnpm add @temporal-contract/worker unthrown # Client for executing workflows -pnpm add @temporal-contract/client neverthrow +pnpm add @temporal-contract/client unthrown # Required peer dependencies pnpm add zod @temporalio/client @temporalio/worker @temporalio/workflow @@ -32,15 +32,15 @@ pnpm add zod @temporalio/client @temporalio/worker @temporalio/workflow ```bash [npm] npm install @temporal-contract/contract -npm install @temporal-contract/worker neverthrow -npm install @temporal-contract/client neverthrow +npm install @temporal-contract/worker unthrown +npm install @temporal-contract/client unthrown npm install zod @temporalio/client @temporalio/worker @temporalio/workflow ``` ```bash [yarn] yarn add @temporal-contract/contract -yarn add @temporal-contract/worker neverthrow -yarn add @temporal-contract/client neverthrow +yarn add @temporal-contract/worker unthrown +yarn add @temporal-contract/client unthrown yarn add zod @temporalio/client @temporalio/worker @temporalio/workflow ``` @@ -48,12 +48,14 @@ yarn add zod @temporalio/client @temporalio/worker @temporalio/workflow ::: tip Package Usage -`neverthrow` provides the `Result` / `ResultAsync` types used by activities, +`unthrown` provides the `Result` / `AsyncResult` types used by activities, workflows, and clients. The same package works in every context — workflows use it directly without a Temporal-specific wrapper. -If you are migrating from a previous version that used `@swan-io/boxed` and -`@temporal-contract/boxed`, see [Migrating to neverthrow](/guide/migrating-to-neverthrow). +If you are upgrading from a previous version that used `neverthrow`, see +[Migrating from neverthrow](/guide/migrating-to-unthrown). If you are coming +from the older `@swan-io/boxed` / `@temporal-contract/boxed` version, see +[Migrating from @swan-io/boxed](/guide/migrating-to-neverthrow). ::: ### Optional Packages diff --git a/docs/guide/migrating-to-unthrown.md b/docs/guide/migrating-to-unthrown.md new file mode 100644 index 00000000..a9194548 --- /dev/null +++ b/docs/guide/migrating-to-unthrown.md @@ -0,0 +1,280 @@ +# 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. + +[`neverthrow`]: https://github.com/supermacro/neverthrow +[`unthrown`]: https://github.com/btravstack/unthrown + +## 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 + on `await`/unwrap instead of being silently swallowed. +- **Tagged errors**: error classes built with `TaggedError(...)` carry a + `_tag` discriminant, enabling exhaustive `matchTags(...)` folds. +- **Sound narrowing**: free functions `isOk` / `isErr` / `isDefect` narrow + the type correctly, instead of relying on method-based type guards. + +## Drop the dep, add the new one + +```diff + // package.json + "dependencies": { +- "neverthrow": "^8" ++ "unthrown": "^0.1.0" + } +``` + +```diff +- import { ResultAsync, ok, err, okAsync, errAsync } from "neverthrow"; ++ import { fromPromise, ok, err, isOk, isErr, isDefect } from "unthrown"; +``` + +## Type signatures + +`ResultAsync` is renamed to `AsyncResult`. `Result` keeps +the same name but is now imported from `"unthrown"`. + +```diff +- (args: TInput): ResultAsync ++ (args: TInput): AsyncResult +``` + +## API mapping + +| neverthrow | unthrown | +| -------------------------------------------- | ---------------------------------------------------------------------- | +| `import { ResultAsync } from "neverthrow"` | `import { fromPromise } from "unthrown"` | +| type `ResultAsync` | type `AsyncResult` | +| type `Result` | `Result` (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()`: + +```diff +- 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: free functions, not methods + +In neverthrow you narrowed with the `.isOk()` / `.isErr()` **methods**. In +unthrown those methods return a plain boolean and do **not** narrow the type. +Use the free functions `isOk` / `isErr` / `isDefect` instead: + +```diff +- if (result.isErr()) { ++ import { isErr } from "unthrown"; ++ ++ if (isErr(result)) { + console.error(result.error); + return; + } + console.log(result.value); +``` + +## 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 with `err(...)` / `err(...).toAsync()`, or produced by + mapping a rejection through `fromPromise(promise, errFn)`). +- **`defect`** — an _unanticipated_ failure (a bug): an unexpected throw that + was never modeled. It carries the raw failure on `result.cause` and + **re-throws** when you `await`/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: + +```ts +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 + +```diff +- 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: + +```diff +- 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: + +```ts +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`. + +## End-to-end activity example + +**Before (neverthrow):** + +```ts +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):** + +```ts +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):** + +```ts +const result = await client.executeWorkflow("processOrder", { workflowId, args }); +result.match( + (output) => console.log("Order:", output), + (err) => console.error("Failed:", err), +); +``` + +**After (unthrown):** + +```ts +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([...])`: + +```diff +- 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`. They now return +`AsyncResult` — narrow the resolved `Result` with +`isErr(result)` (free function) instead of `result.isErr()`. + +## See Also + +- [Result Pattern](/guide/result-pattern) +- [Migrating from @swan-io/boxed](/guide/migrating-to-neverthrow) (the + earlier migration, kept for history) diff --git a/docs/guide/result-pattern.md b/docs/guide/result-pattern.md index 705ff39e..081cdd7b 100644 --- a/docs/guide/result-pattern.md +++ b/docs/guide/result-pattern.md @@ -1,31 +1,31 @@ # Result Pattern -Learn how to use explicit error handling with the `Result` / `ResultAsync` -pattern from [neverthrow]. +Learn how to use explicit error handling with the `Result` / `AsyncResult` +pattern from [unthrown]. -[neverthrow]: https://github.com/supermacro/neverthrow +[unthrown]: https://github.com/btravstack/unthrown ## Overview -temporal-contract uses neverthrow's `Result` and `ResultAsync` +temporal-contract uses unthrown's `Result` and `AsyncResult` types throughout its public surface: -- **Activities** return `ResultAsync`. +- **Activities** return `AsyncResult`. - **Workflows** await activities and child workflows; the framework unwraps the `Result` for activities (so a workflow sees a plain value or a thrown error) and surfaces `Result` directly for child workflows. -- **Clients** await `ResultAsync`; the resolved value is a - `Result` that you destructure with `.match(okFn, errFn)` or - `.isOk()` / `.isErr()`. +- **Clients** await `AsyncResult`; the resolved value is a + `Result` that you destructure with `result.match({ ok, err, defect })` + or the free functions `isOk(result)` / `isErr(result)` / `isDefect(result)`. A single library covers every context — the same import works inside activities, workflows, and clients. ```mermaid graph LR - A[Activities] -->|neverthrow| B[Result / ResultAsync] - C[Workflows] -->|neverthrow| B - D[Clients] -->|neverthrow| B + A[Activities] -->|unthrown| B[Result / AsyncResult] + C[Workflows] -->|unthrown| B + D[Clients] -->|unthrown| B style A fill:#10b981,stroke:#059669,color:#fff style C fill:#3b82f6,stroke:#1e40af,color:#fff @@ -35,31 +35,38 @@ graph LR ## Installation ```bash -pnpm add neverthrow +pnpm add unthrown ``` -`ResultAsync` is awaitable: `await resultAsync` resolves to +`AsyncResult` is awaitable: `await asyncResult` resolves to `Result`. The underlying Promise is constructed when the chain runs, so the type behaves like a lazy task — call sites that already `await` the -value before checking `.isOk()` / `.isErr()` need no changes. +value before checking `isOk(result)` / `isErr(result)` need no changes. + +> [!IMPORTANT] +> unthrown narrows with **free functions** — `isOk(result)`, `isErr(result)`, +> `isDefect(result)` imported from `"unthrown"`. The `result.isOk()` / +> `result.isErr()` **methods** return a plain boolean and do **not** narrow +> the type, so reach for the free functions before touching `.value` / +> `.error` / `.cause`. ## Basic Usage ### Activities -Activities return `ResultAsync`. The cleanest shape -is `ResultAsync.fromPromise(promise, mapError)`: +Activities return `AsyncResult`. The cleanest shape +is `fromPromise(promise, mapError)`: ```typescript import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { orderContract } from "./contract"; export const activities = declareActivitiesHandler({ contract: orderContract, activities: { processPayment: ({ amount }) => - ResultAsync.fromPromise(paymentGateway.charge(amount), (error) => + fromPromise(paymentGateway.charge(amount), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: error instanceof Error ? error.message : "Payment failed", @@ -68,7 +75,7 @@ export const activities = declareActivitiesHandler({ ).map((txId) => ({ transactionId: txId, success: true })), sendEmail: ({ to, body }) => - ResultAsync.fromPromise(emailService.send({ to, body }), (error) => + fromPromise(emailService.send({ to, body }), (error) => ApplicationFailure.create({ type: "EMAIL_FAILED", message: error instanceof Error ? error.message : "Email failed", @@ -114,7 +121,7 @@ export const processOrder = declareWorkflow({ ### Clients -Clients receive a `ResultAsync` from `executeWorkflow` / +Clients receive an `AsyncResult` from `executeWorkflow` / `startWorkflow`. Awaiting it yields a `Result`: ```typescript @@ -130,30 +137,35 @@ const result = await client.executeWorkflow("processOrder", { args: { orderId: "ORD-123", amount: 100 }, }); -// Handle result with pattern matching (positional callbacks) -result.match( - (value) => { +// Handle result with pattern matching (object form, three channels) +result.match({ + ok: (value) => { console.log("Order processed:", value.transactionId); }, - (error) => { + err: (error) => { console.error("Order failed:", error); }, -); + defect: (cause) => { + console.error("Unexpected failure:", cause); + }, +}); ``` ## Awaiting and inspecting -`ResultAsync` is a thin wrapper around a `Promise>`. You +`AsyncResult` is a thin wrapper around a `Promise>`. You can `await` it once and then inspect synchronously, or chain with -`.map`, `.mapErr`, `.andThen`, `.orElse` before awaiting: +`.map`, `.mapErr`, `.flatMap`, `.orElse` before awaiting: ```typescript +import { isErr } from "unthrown"; + const result = await client.executeWorkflow("processOrder", { workflowId: "order-123", args: { orderId: "ORD-123", amount: 100 }, }); -if (result.isErr()) { +if (isErr(result)) { console.error(result.error); return; } @@ -230,7 +242,7 @@ export const processOrder = declareWorkflow({ Define typed errors in your activities: ```typescript -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { ApplicationFailure } from "@temporal-contract/worker/activity"; type PaymentError = @@ -240,9 +252,9 @@ type PaymentError = type EmailError = { type: "InvalidEmail" } | { type: "ServiceUnavailable" }; -// Activities return ResultAsync with typed errors +// Activities return AsyncResult with typed errors processPayment: ({ amount }) => - ResultAsync.fromPromise(paymentGateway.charge(amount), (error) => { + fromPromise(paymentGateway.charge(amount), (error) => { // Wrap domain errors in ApplicationFailure so Temporal applies the // configured retry policy; set `nonRetryable: true` for permanent // failures. @@ -258,15 +270,15 @@ processPayment: ({ amount }) => ### 1. Explicit Error Handling -Activities use the `ResultAsync` pattern internally, while workflows use +Activities use the `AsyncResult` pattern internally, while workflows use try/catch: ```typescript -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; -// Activity implementation (uses ResultAsync) +// Activity implementation (uses AsyncResult) const processPayment = ({ amount }) => - ResultAsync.fromPromise(paymentGateway.charge(amount), (error) => + fromPromise(paymentGateway.charge(amount), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: "Payment failed", @@ -294,12 +306,12 @@ export const processOrder = declareWorkflow({ ### 2. No Hidden Exceptions in Activities -Activities explicitly return `ResultAsync` instead of throwing: +Activities explicitly return `AsyncResult` instead of throwing: ```typescript -// ✅ Clear - activity returns ResultAsync +// ✅ Clear - activity returns AsyncResult const processPayment = ({ amount }) => - ResultAsync.fromPromise(paymentGateway.charge(amount), (error) => + fromPromise(paymentGateway.charge(amount), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: "Payment failed", @@ -317,7 +329,7 @@ async function processPayment({ amount }) { ### 3. Railway-Oriented Programming (Activities) Activity implementations can chain operations that short-circuit on error -using `.andThen` (the neverthrow equivalent of boxed's `.flatMap`): +using `.flatMap` (unthrown's bind/chain operator): ```mermaid graph LR @@ -337,9 +349,9 @@ graph LR // Activity implementation with chaining const processOrder = ({ orderId }) => validateOrderId(orderId) - .andThen((validId) => fetchOrder(validId)) - .andThen((order) => processPayment(order)) - .andThen((payment) => updateDatabase(payment)) + .flatMap((validId) => fetchOrder(validId)) + .flatMap((order) => processPayment(order)) + .flatMap((payment) => updateDatabase(payment)) .mapErr((error) => ApplicationFailure.create({ type: "ORDER_FAILED", @@ -392,25 +404,25 @@ export const processOrder = declareWorkflow({ ## Combining results -neverthrow exposes `Result.combine([...])` to fan in a list of `Result`s -into a single `Result` that fails on the first error. There is no -direct equivalent of boxed's `Result.allFromDict({...})` — destructure the -combined array, or call `.match` per entry: +unthrown exposes `all([...])` to fan in a list of `Result`s into a single +`Result` that fails on the first error. Destructure the combined +array, or call `.match` on the result: ```typescript -import { Result } from "neverthrow"; +import { all } from "unthrown"; -const combined = Result.combine([validateA(a), validateB(b), validateC(c)]); +const combined = all([validateA(a), validateB(b), validateC(c)]); -return combined.match( - ([resA, resB, resC]) => proceed({ resA, resB, resC }), - (error) => fail(error), -); +return combined.match({ + ok: ([resA, resB, resC]) => proceed({ resA, resB, resC }), + err: (error) => fail(error), + defect: (cause) => fail(cause), +}); ``` ## Child Workflows -Child workflows return `ResultAsync` for consistent error handling: +Child workflows return `AsyncResult` for consistent error handling: ### Execute and Wait @@ -429,16 +441,20 @@ export const parentWorkflow = declareWorkflow({ }); // Workflows return plain objects, not Result - return result.match( - (output) => ({ + return result.match({ + ok: (output) => ({ success: true, transactionId: output.transactionId, }), - (error) => ({ + err: (error) => ({ success: false, error: error.message, }), - ); + defect: (cause) => ({ + success: false, + error: cause instanceof Error ? cause.message : "Unexpected failure", + }), + }); }, }); ``` @@ -457,16 +473,19 @@ export const parentWorkflow = declareWorkflow({ args: { message: "Order received" }, }); - handleResult.match( - async (handle) => { + handleResult.match({ + ok: async (handle) => { // Child started successfully // Can wait for result later if needed const result = await handle.result(); }, - (error) => { + err: (error) => { console.error("Failed to start child:", error); }, - ); + defect: (cause) => { + console.error("Unexpected failure starting child:", cause); + }, + }); // Workflows return plain objects, not Result return { success: true }; @@ -497,22 +516,103 @@ export const orderWorkflow = declareWorkflow({ ); // Workflows return plain objects, not Result - return notifyResult.match( - () => ({ status: "completed" }), - (error) => ({ + return notifyResult.match({ + ok: () => ({ status: "completed" }), + err: (error) => ({ status: "failed", error: error.message, }), - ); + defect: (cause) => ({ + status: "failed", + error: cause instanceof Error ? cause.message : "Unexpected failure", + }), + }); }, }); ``` +## The `defect` channel + +unthrown models **three** outcomes, not two. Besides `ok` (success) and +`err` (a deliberate, anticipated failure), there is a third channel — +`defect` — for **unanticipated** failures: bugs, programmer errors, or any +exception you never modeled. + +- An `err` is a value you returned on purpose (`err(...)` / + `errAsync` → `err(...).toAsync()`, or a rejection mapped through + `fromPromise(promise, errFn)`). It is part of your type signature. +- A `defect` is captured when an unexpected throw escapes — for example a + `fromSafePromise(...)` thunk that throws, or an unhandled exception inside + a `.map`. It is **not** part of the modeled error type and carries the raw + failure on `result.cause`. + +A defect **re-throws** when you `await`/unwrap it rather than being handled +as a value, so genuine bugs surface loudly instead of being silently +swallowed. Inspect it with the free function `isDefect(result)` and +`result.cause`, or handle all three channels at once with +`result.match({ ok, err, defect })`: + +```typescript +import { isOk, isErr, isDefect } from "unthrown"; + +const result = await client.executeWorkflow("processOrder", { + workflowId: "order-123", + args: { orderId: "ORD-123", amount: 100 }, +}); + +if (isOk(result)) { + console.log(result.value); +} else if (isErr(result)) { + console.error("Modeled failure:", result.error); // anticipated boundary error +} else if (isDefect(result)) { + console.error("Unexpected failure (bug):", result.cause); // unmodeled +} +``` + +Keep deliberate boundary errors in the `err` channel (wrap them in +`ApplicationFailure` for activities) and let only truly unexpected throws +become defects. + +## `TaggedError` and `matchTags` + +Error classes are built with `TaggedError`, which stamps each class with a +`_tag` discriminant: + +```typescript +import { TaggedError } from "unthrown"; + +class PaymentDeclined extends TaggedError("PaymentDeclined")<{ + readonly customerId: string; +}> {} + +class GatewayTimeout extends TaggedError("GatewayTimeout")<{ + readonly elapsedMs: number; +}> {} +``` + +> [!NOTE] +> The worker's `ValidationError` subclasses are the exception — they still +> extend Temporal's `ApplicationFailure` rather than `TaggedError`. + +Because every tagged error carries a `_tag`, unthrown's `matchTags` folds a +`Result` exhaustively by tag, with dedicated `Ok` and `Defect` channels: + +```typescript +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)}`, +}); +``` + ## When to Use -### Use `Result` / `ResultAsync` When: +### Use `Result` / `AsyncResult` When: -- **In Activity Implementations**: Always use `ResultAsync` for explicit error handling +- **In Activity Implementations**: Always use `AsyncResult` for explicit error handling - **For Child Workflows**: Child workflows return `Result` for explicit error handling - **For Type-Safe Errors**: When you need `ApplicationFailure` with `type` / `nonRetryable` for proper retry policies @@ -524,6 +624,6 @@ export const orderWorkflow = declareWorkflow({ ## See Also -- [Migrating to neverthrow](/guide/migrating-to-neverthrow) +- [Migrating from neverthrow](/guide/migrating-to-unthrown) - [Order Processing Example](/examples/basic-order-processing) - [Worker Implementation](/guide/worker-implementation) diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 2399e7e2..00eaef05 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -174,7 +174,7 @@ Property 'transactionId' does not exist on type 'never'. processOrder: { processPayment: ({ customerId, amount }) => { console.log(customerId); // Type-safe! - return okAsync({ transactionId: "tx-123" }); + return ok({ transactionId: "tx-123" }).toAsync(); }, }, } @@ -366,7 +366,7 @@ Error: Activity task failed: ApplicationFailure ```typescript // ✅ Return proper error result processPayment: ({ customerId, amount }) => - ResultAsync.fromPromise(paymentService.charge(customerId, amount), (e) => + fromPromise(paymentService.charge(customerId, amount), (e) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: e instanceof Error ? e.message : "Payment failed", @@ -429,7 +429,7 @@ Error: Cannot find module './workflows' - Ensure TypeScript compiles workflows - Check that output directory contains workflow files -## Result / ResultAsync Pattern Issues +## Result / AsyncResult Pattern Issues ### "Cannot read property 'match' of undefined" @@ -439,9 +439,9 @@ Error: Cannot find module './workflows' TypeError: Cannot read property 'match' of undefined ``` -**Cause:** Activity returned `undefined` instead of a `ResultAsync`. +**Cause:** Activity returned `undefined` instead of a `AsyncResult`. -**Solution:** Always return a `ResultAsync` from activities: +**Solution:** Always return a `AsyncResult` from activities: ```typescript // ❌ Returns undefined @@ -450,9 +450,9 @@ processPayment: () => { // No return! }; -// ✅ Returns ResultAsync +// ✅ Returns AsyncResult processPayment: ({ customerId, amount }) => - ResultAsync.fromPromise(paymentService.charge(customerId, amount), (e) => + fromPromise(paymentService.charge(customerId, amount), (e) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: e instanceof Error ? e.message : "Payment failed", @@ -469,18 +469,24 @@ TypeError: Result.Ok is not a function ``` **Cause:** Code is still using the old `@swan-io/boxed` API -(`Result.Ok` / `Result.Error`) or importing from a package that no longer -exists. The previous `@temporal-contract/boxed` package was removed in the -neverthrow migration. +(`Result.Ok` / `Result.Error`), the `neverthrow` API (`okAsync` / +`errAsync` / `ResultAsync`), or importing from a package that no longer +exists. The previous `@temporal-contract/boxed` package was removed when the +library moved to `neverthrow`, and `neverthrow` was later replaced by +`unthrown`. -**Solution:** Use `neverthrow`: +**Solution:** Use `unthrown` (note: there is no `okAsync` / `errAsync` — +lift a sync `Result` with `.toAsync()`): ```typescript // ✅ For activities, workflows, and clients -import { ResultAsync, ok, err, okAsync, errAsync } from "neverthrow"; +import { fromPromise, ok, err, isOk, isErr, isDefect, type AsyncResult } from "unthrown"; + +// okAsync(value) -> ok(value).toAsync() +// errAsync(error) -> err(error).toAsync() ``` -See [Migrating to neverthrow](/guide/migrating-to-neverthrow) for the full +See [Migrating from neverthrow](/guide/migrating-to-unthrown) for the full mapping. ## Performance Issues @@ -499,12 +505,11 @@ mapping. ```typescript // ❌ Blocking operation - processOrder: ({ payload }) => - ResultAsync.fromPromise(fetch("http://slow-api.com/process"), (e) => e); + processOrder: ({ payload }) => fromPromise(fetch("http://slow-api.com/process"), (e) => e); // ✅ Add timeouts and handle slow operations processOrder: ({ payload }) => - ResultAsync.fromPromise( + fromPromise( Promise.race([ fetch("http://api.com/process"), new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 5000)), diff --git a/docs/guide/why-temporal-contract.md b/docs/guide/why-temporal-contract.md index 8cfcc82d..329dab24 100644 --- a/docs/guide/why-temporal-contract.md +++ b/docs/guide/why-temporal-contract.md @@ -165,8 +165,9 @@ const result = await client.executeWorkflow("processOrder", { }); result.match({ - Ok: (output) => console.log("Success:", output), - Error: (error) => console.error("Validation failed:", error), + ok: (output) => console.log("Success:", output), + err: (error) => console.error("Validation failed:", error), + defect: (cause) => console.error("Unexpected failure:", cause), }); ``` diff --git a/docs/guide/worker-implementation.md b/docs/guide/worker-implementation.md index 6c5a1ce3..16b52c67 100644 --- a/docs/guide/worker-implementation.md +++ b/docs/guide/worker-implementation.md @@ -31,19 +31,19 @@ sequenceDiagram ## Activities Handler -Create a handler for all activities using `ResultAsync`: +Create a handler for all activities using `AsyncResult`: ```typescript import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { myContract } from "./contract"; export const activities = declareActivitiesHandler({ contract: myContract, activities: { - // Global activities - use ResultAsync for explicit error handling + // Global activities - use AsyncResult for explicit error handling sendEmail: ({ to, subject, body }) => - ResultAsync.fromPromise(emailService.send({ to, subject, body }), (error) => + fromPromise(emailService.send({ to, subject, body }), (error) => ApplicationFailure.create({ type: "EMAIL_FAILED", message: error instanceof Error ? error.message : "Failed to send email", @@ -53,7 +53,7 @@ export const activities = declareActivitiesHandler({ // Workflow-specific activities processPayment: ({ customerId, amount }) => - ResultAsync.fromPromise(paymentGateway.charge(customerId, amount), (error) => + fromPromise(paymentGateway.charge(customerId, amount), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: error instanceof Error ? error.message : "Payment failed", @@ -67,7 +67,7 @@ export const activities = declareActivitiesHandler({ ## Working with the Activity Context `declareActivitiesHandler` accepts implementations in the -`ResultAsync` shape and wraps each one into an +`AsyncResult` shape and wraps each one into an ordinary Promise-returning Temporal activity (Temporal sees a normal `(args) => Promise` handler at the runtime boundary). The wrapper does **not** hide Temporal's `@temporalio/activity` runtime — @@ -88,7 +88,7 @@ often you heartbeat, so size both timeouts with the activity's worst- case runtime in mind. Call `Context.current().heartbeat(details)` from inside your -`ResultAsync`-returning body — heartbeats are independent of the +`AsyncResult`-returning body — heartbeats are independent of the `Result` wrapping. The example below uses the inline-implementation pattern: TypeScript infers each activity's input/output shape from the contract via `declareActivitiesHandler`'s `activities` parameter, so no @@ -96,7 +96,7 @@ extra annotation is needed. ```typescript import { Context } from "@temporalio/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; import { reportContract } from "./contract"; @@ -104,7 +104,7 @@ export const activities = declareActivitiesHandler({ contract: reportContract, activities: { exportLargeReport: ({ reportId }) => - ResultAsync.fromPromise( + fromPromise( runExport(reportId, ({ chunkIndex }) => { // Heartbeat the most recent progress checkpoint. Temporal records // this as the activity's `heartbeatDetails` for the next attempt. @@ -134,7 +134,7 @@ attempt already completed: ```typescript import { Context } from "@temporalio/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; import { reportContract } from "./contract"; @@ -149,7 +149,7 @@ export const activities = declareActivitiesHandler({ const last = Context.current().heartbeatDetails as { chunkIndex: number } | undefined; const startFrom = last?.chunkIndex ?? 0; - return ResultAsync.fromPromise(runExport(reportId, { startFrom }), (error) => + return fromPromise(runExport(reportId, { startFrom }), (error) => ApplicationFailure.create({ type: "EXPORT_FAILED", message: error instanceof Error ? error.message : "Export failed", @@ -170,7 +170,7 @@ behavior: ```typescript import { Context } from "@temporalio/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; import { paymentContract } from "./contract"; @@ -184,7 +184,7 @@ export const activities = declareActivitiesHandler({ workflowId: workflowExecution.workflowId, orderId, }); - return ResultAsync.fromPromise(paymentGateway.charge(orderId, amount), (error) => + return fromPromise(paymentGateway.charge(orderId, amount), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: error instanceof Error ? error.message : "Payment failed", @@ -214,7 +214,7 @@ Two outcomes need to coexist inside the activity body: `ApplicationFailure` so the regular retry/error semantics still apply. The cleanest shape is an inner `async` function that throws either -class. `ResultAsync.fromPromise` converts the rejection into an `Err`, +class. `fromPromise` converts the rejection into an `Err`, the activity wrapper rethrows whatever it finds there, and Temporal's runtime recognizes the `CompleteAsyncError` class unchanged. The `` type parameters acknowledge that the @@ -224,7 +224,7 @@ caller, so the assertion is safe. ```typescript import { Context, CompleteAsyncError } from "@temporalio/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; import { approvalContract } from "./contract"; @@ -234,7 +234,7 @@ export const activities = declareActivitiesHandler({ awaitApproval: ({ requestId }) => { const taskToken = Context.current().info.taskToken; - return ResultAsync.fromPromise( + return fromPromise( (async () => { try { await enqueueApprovalRequest({ requestId, taskToken }); @@ -419,7 +419,7 @@ implementation: async (context, args) => { ## Child Workflows -Execute child workflows with the type-safe `Result` / `ResultAsync` pattern. Child workflows can be from the same contract or from a different contract (cross-worker communication). +Execute child workflows with the type-safe `Result` / `AsyncResult` pattern. Child workflows can be from the same contract or from a different contract (cross-worker communication). ```mermaid graph TB @@ -462,10 +462,11 @@ export const parentWorkflow = declareWorkflow({ args: { amount: args.totalAmount }, }); - result.match( - (output) => console.log("Payment processed:", output), - (error) => console.error("Payment failed:", error), - ); + result.match({ + ok: (output) => console.log("Payment processed:", output), + err: (error) => console.error("Payment failed:", error), + defect: (cause) => console.error("Unexpected failure:", cause), + }); return { success: true }; }, @@ -477,6 +478,8 @@ export const parentWorkflow = declareWorkflow({ Invoke child workflows from different contracts and workers: ```typescript +import { isErr } from "unthrown"; + export const orderWorkflow = declareWorkflow({ workflowName: "processOrder", contract: orderContract, @@ -488,7 +491,8 @@ export const orderWorkflow = declareWorkflow({ args: { amount: args.total }, }); - if (paymentResult.isErr()) { + // Free-function narrowing — the `.isErr()` method would not narrow the type. + if (isErr(paymentResult)) { return { status: "failed", reason: "payment" }; } @@ -526,16 +530,19 @@ export const orderWorkflow = declareWorkflow({ args: { to: args.customerEmail, subject: "Order received" }, }); - handleResult.match( - async (handle) => { + handleResult.match({ + ok: async (handle) => { // Child workflow started successfully // Can wait for result later if needed const result = await handle.result(); }, - (error) => { + err: (error) => { console.error("Failed to start notification:", error); }, - ); + defect: (cause) => { + console.error("Unexpected failure starting notification:", cause); + }, + }); return { success: true }; }, @@ -552,12 +559,12 @@ const result = await context.executeChildWorkflow(myContract, "processPayment", args: { amount: 100 }, }); -result.match( - (output) => { +result.match({ + ok: (output) => { // Child workflow completed successfully console.log("Transaction ID:", output.transactionId); }, - (error) => { + err: (error) => { // Handle child workflow errors if (error instanceof ChildWorkflowNotFoundError) { console.error("Workflow not found in contract"); @@ -565,7 +572,11 @@ result.match( console.error("Child workflow failed:", error.message); } }, -); + defect: (cause) => { + // Unexpected failure (bug), not a modeled child-workflow error + console.error("Unexpected failure:", cause); + }, +}); ``` ## Best Practices @@ -576,12 +587,12 @@ Organize activities by domain: ```typescript // activities/payment.ts -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { ApplicationFailure } from "@temporal-contract/worker/activity"; export const paymentActivities = { processPayment: ({ customerId, amount }) => - ResultAsync.fromPromise(paymentGateway.charge(customerId, amount), (err) => + fromPromise(paymentGateway.charge(customerId, amount), (err) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: err instanceof Error ? err.message : "Payment failed", @@ -589,7 +600,7 @@ export const paymentActivities = { }), ).map((tx) => ({ transactionId: tx.id })), refundPayment: ({ transactionId }) => - ResultAsync.fromPromise(paymentGateway.refund(transactionId), (err) => + fromPromise(paymentGateway.refund(transactionId), (err) => ApplicationFailure.create({ type: "REFUND_FAILED", message: err instanceof Error ? err.message : "Refund failed", @@ -599,12 +610,12 @@ export const paymentActivities = { }; // activities/email.ts -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { ApplicationFailure } from "@temporal-contract/worker/activity"; export const emailActivities = { sendEmail: ({ to, subject, body }) => - ResultAsync.fromPromise(emailService.send({ to, subject, body }), (err) => + fromPromise(emailService.send({ to, subject, body }), (err) => ApplicationFailure.create({ type: "EMAIL_FAILED", message: err instanceof Error ? err.message : "Email failed", @@ -632,7 +643,7 @@ export const activities = declareActivitiesHandler({ Make activities testable: ```typescript -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { ApplicationFailure } from "@temporal-contract/worker/activity"; export const createActivities = (services: { @@ -643,7 +654,7 @@ export const createActivities = (services: { contract: myContract, activities: { sendEmail: ({ to, subject, body }) => - ResultAsync.fromPromise(services.emailService.send({ to, subject, body }), (err) => + fromPromise(services.emailService.send({ to, subject, body }), (err) => ApplicationFailure.create({ type: "EMAIL_FAILED", message: err instanceof Error ? err.message : "Email failed", @@ -651,7 +662,7 @@ export const createActivities = (services: { }), ).map(() => ({ sent: true })), processPayment: ({ customerId, amount }) => - ResultAsync.fromPromise(services.paymentGateway.charge(customerId, amount), (err) => + fromPromise(services.paymentGateway.charge(customerId, amount), (err) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: err instanceof Error ? err.message : "Payment failed", @@ -664,17 +675,17 @@ export const createActivities = (services: { ### 3. Error Handling -Activities use `ResultAsync` for explicit error handling: +Activities use `AsyncResult` for explicit error handling: ```typescript import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; export const activities = declareActivitiesHandler({ contract: myContract, activities: { processPayment: ({ customerId, amount }) => - ResultAsync.fromPromise(paymentGateway.charge(customerId, amount), (error) => { + fromPromise(paymentGateway.charge(customerId, amount), (error) => { // Wrap technical errors in ApplicationFailure so Temporal's // retry policy applies; set `nonRetryable: true` for permanent // failures (e.g. card declined) so retries don't fire. diff --git a/docs/guide/worker-usage.md b/docs/guide/worker-usage.md index 24bd41ac..2ac90e12 100644 --- a/docs/guide/worker-usage.md +++ b/docs/guide/worker-usage.md @@ -9,16 +9,16 @@ The `@temporal-contract/worker` package provides type-safe implementations for w ## Installation ```bash -pnpm add @temporal-contract/worker neverthrow +pnpm add @temporal-contract/worker unthrown ``` ## Implementing Activities -Activities use `neverthrow` for explicit error handling: +Activities use `unthrown` for explicit error handling: ```typescript import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync, okAsync } from "neverthrow"; +import { fromPromise, ok } from "unthrown"; import { myContract } from "./contract"; export const activities = declareActivitiesHandler({ @@ -27,13 +27,13 @@ export const activities = declareActivitiesHandler({ // Global activities log: ({ level, message }) => { console.log(`[${level}] ${message}`); - return okAsync(undefined); + return ok(undefined).toAsync(); }, // Workflow-specific activities processOrder: { processPayment: ({ customerId, amount }) => - ResultAsync.fromPromise(paymentService.charge(customerId, amount), (error) => + fromPromise(paymentService.charge(customerId, amount), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: error instanceof Error ? error.message : "Payment processing failed", @@ -110,10 +110,10 @@ main().catch((error) => { ```typescript import { ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; processPayment: ({ customerId, amount }) => - ResultAsync.fromPromise(paymentService.charge(customerId, amount), (error) => + fromPromise(paymentService.charge(customerId, amount), (error) => ApplicationFailure.create({ type: "PAYMENT_FAILED", // categorizes the failure for retry policies / search message: error instanceof Error ? error.message : "Payment failed", @@ -160,7 +160,7 @@ implementation: async (context, args) => { ## Child Workflows -Execute child workflows with type safety using the `Result` / `ResultAsync` pattern: +Execute child workflows with type safety using the `Result` / `AsyncResult` pattern: ```typescript import { declareWorkflow } from "@temporal-contract/worker/workflow"; @@ -170,23 +170,27 @@ export const parentWorkflow = declareWorkflow({ contract: myContract, activityOptions: { startToCloseTimeout: "1 minute" }, implementation: async (context, args) => { - // Execute child workflow - returns ResultAsync + // Execute child workflow - returns AsyncResult const childResult = await context.executeChildWorkflow(myContract, "processPayment", { workflowId: `payment-${args.orderId}`, args: { amount: args.amount, customerId: args.customerId }, }); - // Handle the Result with pattern matching (positional callbacks) - return childResult.match( - (output) => ({ + // Handle the Result with pattern matching (object form, three channels) + return childResult.match({ + ok: (output) => ({ success: true, transactionId: output.transactionId, }), - (error) => ({ + err: (error) => ({ success: false, error: error.message, }), - ); + defect: (cause) => ({ + success: false, + error: cause instanceof Error ? cause.message : "Unexpected failure", + }), + }); }, }); ``` @@ -245,6 +249,7 @@ Test activities and workflows in isolation: ```typescript import { describe, it, expect } from "vitest"; +import { isOk } from "unthrown"; import { activities } from "./activities"; describe("Activities", () => { @@ -254,8 +259,8 @@ describe("Activities", () => { amount: 100, }); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ transactionId: expect.any(String), }); @@ -266,15 +271,15 @@ describe("Activities", () => { ## Best Practices -### 1. Use `ResultAsync.fromPromise` with `.map` / `.mapErr` for Activities +### 1. Use `fromPromise` with `.map` / `.mapErr` for Activities -Activities should pass the error mapper directly to `ResultAsync.fromPromise` +Activities should pass the error mapper directly to `fromPromise` and chain `.map` for the success path: ```typescript -// ✅ Good - explicit error handling with ResultAsync.fromPromise +// ✅ Good - explicit error handling with fromPromise processPayment: ({ amount }) => - ResultAsync.fromPromise(paymentService.charge(amount), (err) => + fromPromise(paymentService.charge(amount), (err) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: err instanceof Error ? err.message : "Payment failed", @@ -284,7 +289,7 @@ processPayment: ({ amount }) => // ❌ Avoid - hand-rolling a Promise with try/catch processPayment: ({ amount }) => - ResultAsync.fromPromise( + fromPromise( (async () => { try { const tx = await paymentService.charge(amount); @@ -303,10 +308,10 @@ Activities internally return a `Result`, but the framework unwraps it for network serialization: ```typescript -// ✅ Good - activity returns ResultAsync +// ✅ Good - activity returns AsyncResult // Framework unwraps to plain DTO over network processPayment: ({ amount }) => - ResultAsync.fromPromise(paymentService.charge(amount), (err) => + fromPromise(paymentService.charge(amount), (err) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: err instanceof Error ? err.message : "Payment failed", @@ -355,5 +360,5 @@ ApplicationFailure.create({ type: "ERROR", message: "Something went wrong" }); - [Defining Contracts](/guide/defining-contracts) - Creating contract definitions - [Client Usage](/guide/client-usage) - Executing workflows from clients -- [Result Pattern](/guide/result-pattern) - Understanding Result/ResultAsync patterns +- [Result Pattern](/guide/result-pattern) - Understanding Result/AsyncResult patterns - [API Reference](/api/worker) - Complete worker API documentation diff --git a/docs/index.md b/docs/index.md index 262cd77b..cd6f03ff 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,7 +28,7 @@ features: - icon: 🎯 title: Explicit Error Handling - details: Result/ResultAsync pattern from neverthrow for workflows that need explicit error handling without exceptions. + details: Result/AsyncResult pattern from unthrown for workflows that need explicit error handling without exceptions. - icon: 📝 title: Contract-First Design @@ -74,7 +74,7 @@ export const orderContract = defineContract({ ``` ```typescript [2. Implement Activities] -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; import { orderContract } from "./contract"; @@ -83,7 +83,7 @@ export const activities = declareActivitiesHandler({ activities: { processOrder: { processPayment: ({ customerId, amount }) => - ResultAsync.fromPromise(paymentService.charge(customerId, amount), (e) => + fromPromise(paymentService.charge(customerId, amount), (e) => ApplicationFailure.create({ type: "PAYMENT_FAILED", message: e instanceof Error ? e.message : "Payment failed", @@ -91,7 +91,7 @@ export const activities = declareActivitiesHandler({ }), ).map((tx) => ({ transactionId: tx.id })), sendNotification: ({ customerId, message }) => - ResultAsync.fromPromise(notificationService.send(customerId, message), (e) => + fromPromise(notificationService.send(customerId, message), (e) => ApplicationFailure.create({ type: "NOTIFICATION_FAILED", message: e instanceof Error ? e.message : "Notification failed", @@ -135,10 +135,11 @@ const future = client.executeWorkflow("processOrder", { const result = await future; -result.match( - (output) => console.log(output.status), // ✅ 'success' | 'failed' - (error) => console.error("Failed:", error), -); +result.match({ + ok: (output) => console.log(output.status), // ✅ 'success' | 'failed' + err: (error) => console.error("Failed:", error), + defect: (cause) => console.error("Unexpected:", cause), +}); ``` ::: diff --git a/examples/order-processing-client/package.json b/examples/order-processing-client/package.json index 7191c1b1..df7aa566 100644 --- a/examples/order-processing-client/package.json +++ b/examples/order-processing-client/package.json @@ -14,6 +14,7 @@ "pino": "catalog:", "pino-pretty": "catalog:", "ts-pattern": "catalog:", + "unthrown": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/examples/order-processing-client/src/client.ts b/examples/order-processing-client/src/client.ts index 458e6bba..ffe7ae31 100644 --- a/examples/order-processing-client/src/client.ts +++ b/examples/order-processing-client/src/client.ts @@ -14,15 +14,16 @@ import { } from "@temporal-contract/sample-order-processing-contract"; import type { z } from "zod"; import { match, P } from "ts-pattern"; +import { isOk, isErr, isDefect } from "unthrown"; import { logger } from "./logger.js"; type Order = z.infer; /** - * Order Processing Client with neverthrow ResultAsync Pattern + * Order Processing Client with unthrown AsyncResult Pattern * * This client demonstrates how to interact with the unified order processing contract - * using neverthrow's `ResultAsync` for explicit error handling. + * using unthrown's `AsyncResult` for explicit error handling. * * Usage: * 1. Start Temporal server: temporal server start-dev @@ -30,7 +31,7 @@ type Order = z.infer; * 3. Run this client: cd examples/order-processing-client && pnpm dev */ async function run() { - logger.info("🚀 Starting Order Processing Client (neverthrow ResultAsync)..."); + logger.info("🚀 Starting Order Processing Client (unthrown AsyncResult)..."); // Connect to Temporal server const connection = await Connection.connect({ @@ -42,7 +43,7 @@ async function run() { namespace: "default", }); - // Create type-safe client with neverthrow ResultAsync pattern + // Create type-safe client with unthrown AsyncResult pattern const contractClient = TypedClient.create(orderProcessingContract, rawClient); // Example orders to process @@ -78,7 +79,7 @@ async function run() { }, ]; - logger.info("📦 Processing orders with neverthrow ResultAsync..."); + logger.info("📦 Processing orders with unthrown AsyncResult..."); for (const order of orders) { logger.info({ order }, `📦 Creating order: ${order.orderId}`); @@ -90,7 +91,7 @@ async function run() { }); // Handle workflow start errors - if (handleResult.isErr()) { + if (isErr(handleResult)) { const error = handleResult.error; match(error) .with(P.instanceOf(WorkflowNotFoundError), (err) => { @@ -114,6 +115,15 @@ async function run() { .exhaustive(); continue; } + // A defect is an unmodeled failure (a bug) — surfaced on its own channel + // rather than as a typed domain error. + if (isDefect(handleResult)) { + logger.error( + { cause: handleResult.cause, orderId: order.orderId }, + "❌ Unexpected failure starting workflow", + ); + continue; + } const handle = handleResult.value; logger.info({ workflowId: handle.workflowId }, `✅ Workflow started: ${handle.workflowId}`); @@ -123,7 +133,7 @@ async function run() { const result = await handle.result(); // Handle workflow execution result - if (result.isErr()) { + if (isErr(result)) { const error = result.error; match(error) .with(P.instanceOf(WorkflowValidationError), (err) => { @@ -150,6 +160,13 @@ async function run() { .exhaustive(); continue; } + if (isDefect(result)) { + logger.error( + { cause: result.cause, orderId: order.orderId }, + "❌ Unexpected failure waiting for workflow result", + ); + continue; + } const output = result.value; // Handle successful result @@ -174,8 +191,8 @@ async function run() { } } - // Example using executeWorkflow with ResultAsync pattern - logger.info("\n📦 Example: Using executeWorkflow with ResultAsync..."); + // Example using executeWorkflow with AsyncResult pattern + logger.info("\n📦 Example: Using executeWorkflow with AsyncResult..."); const exampleOrder: Order = { orderId: `ORD-${Date.now()}-EXAMPLE`, @@ -197,7 +214,7 @@ async function run() { }); // Handle result with pattern matching - if (result.isOk()) { + if (isOk(result)) { const output = result.value; const summary = { id: output.orderId, @@ -208,8 +225,8 @@ async function run() { : `Order failed: ${output.failureReason}`, }; logger.info({ data: summary }, `📊 Order summary: ${summary.message}`); - } else { - // Handle errors + } else if (isErr(result)) { + // Handle modeled errors match(result.error) .with(P.instanceOf(WorkflowNotFoundError), (err) => { logger.error({ error: err }, "❌ Workflow not found"); @@ -230,14 +247,17 @@ async function run() { logger.error({ error: err }, "❌ Workflow execution failed"); }) .exhaustive(); + } else { + // A defect is an unmodeled failure (a bug), not an anticipated outcome. + logger.error({ cause: result.cause }, "❌ Unexpected failure executing workflow"); } logger.info("\n✨ Done!"); logger.info(""); - logger.info("💡 Benefits of neverthrow ResultAsync:"); + logger.info("💡 Benefits of unthrown AsyncResult:"); logger.info(" - Explicit error handling - no hidden exceptions"); logger.info(" - Type-safe error values"); - logger.info(" - Functional composition with andThen, map, mapErr, orElse"); + logger.info(" - Functional composition with flatMap, map, mapErr, orElse"); logger.info(" - Railway-oriented programming"); logger.info(" - Exhaustive error matching with ts-pattern"); diff --git a/examples/order-processing-worker/package.json b/examples/order-processing-worker/package.json index 7a3fd01d..bff4ea11 100644 --- a/examples/order-processing-worker/package.json +++ b/examples/order-processing-worker/package.json @@ -13,9 +13,9 @@ "@temporal-contract/worker": "workspace:*", "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", - "neverthrow": "catalog:", "pino": "catalog:", "pino-pretty": "catalog:", + "unthrown": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/examples/order-processing-worker/src/application/activities.ts b/examples/order-processing-worker/src/application/activities.ts index 7b81f195..e1d671d3 100644 --- a/examples/order-processing-worker/src/application/activities.ts +++ b/examples/order-processing-worker/src/application/activities.ts @@ -1,4 +1,4 @@ -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; import { orderProcessingContract } from "@temporal-contract/sample-order-processing-contract"; import { @@ -23,12 +23,12 @@ const toApplicationFailure = (type: string, fallback: string, error: unknown): A }); /** - * Activity implementations using neverthrow's `ResultAsync` pattern. + * Activity implementations using unthrown's `AsyncResult` pattern. * * Instead of throwing exceptions, activities return: - * - okAsync(value) for success - * - errAsync(ApplicationFailure) for failures (or a `ResultAsync.fromPromise` - * chain that mapErr's a rejection into an `ApplicationFailure`). + * - ok(value).toAsync() for success + * - err(ApplicationFailure).toAsync() for failures (or a `fromPromise` + * chain that qualifies a rejection into an `ApplicationFailure`). * * All technical exceptions MUST be caught and wrapped in `ApplicationFailure` * (Temporal's first-class failure shape, re-exported from @@ -39,7 +39,7 @@ const toApplicationFailure = (type: string, fallback: string, error: unknown): A * Benefits: * - Explicit error types in function signatures * - Per-instance `nonRetryable` flag for permanent failures - * - Functional composition with map/andThen/match + * - Functional composition with map/flatMap/match * - Native Temporal serialization across the activity → workflow boundary */ @@ -48,9 +48,9 @@ const toApplicationFailure = (type: string, fallback: string, error: unknown): A // ============================================================================ /** - * Create the activities handler with neverthrow's ResultAsync pattern. + * Create the activities handler with unthrown's AsyncResult pattern. * Activities are thin wrappers that delegate to use cases. - * All activities return `ResultAsync`. + * All activities return `AsyncResult`. * * Domain errors are wrapped in `ApplicationFailure` so Temporal applies the * configured retry policy. Set `nonRetryable: true` for permanent failures @@ -60,20 +60,18 @@ export const activities = declareActivitiesHandler({ contract: orderProcessingContract, activities: { sendNotification: ({ customerId, subject, message }) => - ResultAsync.fromPromise( - sendNotificationUseCase.execute(customerId, subject, message), - (error) => - toApplicationFailure("NOTIFICATION_FAILED", "Failed to send notification", error), + fromPromise(sendNotificationUseCase.execute(customerId, subject, message), (error) => + toApplicationFailure("NOTIFICATION_FAILED", "Failed to send notification", error), ), processOrder: { processPayment: ({ customerId, amount }) => - ResultAsync.fromPromise(processPaymentUseCase.execute(customerId, amount), (error) => + fromPromise(processPaymentUseCase.execute(customerId, amount), (error) => toApplicationFailure("PAYMENT_FAILED", "Payment processing failed", error), ), reserveInventory: (items) => - ResultAsync.fromPromise(reserveInventoryUseCase.execute(items), (error) => + fromPromise(reserveInventoryUseCase.execute(items), (error) => toApplicationFailure( "INVENTORY_RESERVATION_FAILED", "Inventory reservation failed", @@ -82,17 +80,17 @@ export const activities = declareActivitiesHandler({ ), releaseInventory: (reservationId) => - ResultAsync.fromPromise(releaseInventoryUseCase.execute(reservationId), (error) => + fromPromise(releaseInventoryUseCase.execute(reservationId), (error) => toApplicationFailure("INVENTORY_RELEASE_FAILED", "Inventory release failed", error), ), createShipment: ({ orderId, customerId }) => - ResultAsync.fromPromise(createShipmentUseCase.execute(orderId, customerId), (error) => + fromPromise(createShipmentUseCase.execute(orderId, customerId), (error) => toApplicationFailure("SHIPMENT_CREATION_FAILED", "Shipment creation failed", error), ), refundPayment: (transactionId) => - ResultAsync.fromPromise(refundPaymentUseCase.execute(transactionId), (error) => + fromPromise(refundPaymentUseCase.execute(transactionId), (error) => toApplicationFailure("REFUND_FAILED", "Refund failed", error), ), }, diff --git a/examples/order-processing-worker/src/application/workflows.ts b/examples/order-processing-worker/src/application/workflows.ts index d8c82279..8aa0cdf8 100644 --- a/examples/order-processing-worker/src/application/workflows.ts +++ b/examples/order-processing-worker/src/application/workflows.ts @@ -5,7 +5,7 @@ import { orderProcessingContract } from "@temporal-contract/sample-order-process /** * Process Order Workflow Implementation * - * - Activities use neverthrow's `ResultAsync` in their implementation + * - Activities use unthrown's `AsyncResult` in their implementation * (domain + infrastructure). * - Workflow checks activity results and returns appropriate status. * - No exceptions thrown — pure functional style with explicit return values. diff --git a/examples/order-processing-worker/src/integration.spec.ts b/examples/order-processing-worker/src/integration.spec.ts index 9a27ecab..4318350e 100644 --- a/examples/order-processing-worker/src/integration.spec.ts +++ b/examples/order-processing-worker/src/integration.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, vi, beforeEach } from "vitest"; +import { isOk, isErr } from "unthrown"; import { Worker } from "@temporalio/worker"; import { TypedClient, WorkflowValidationError } from "@temporal-contract/client"; import { it as baseIt } from "@temporal-contract/testing/extension"; @@ -94,8 +95,8 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ orderId: order.orderId, status: "completed", @@ -127,14 +128,14 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; expect(handle.workflowId).toBe(order.orderId); const result = await handle.result(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ orderId: order.orderId, status: "completed", @@ -168,14 +169,14 @@ describe("Order Processing Workflow - Integration Tests", () => { // THEN const handleResult = await client.getHandle("processOrder", order.orderId); - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; expect(handle.workflowId).toBe(order.orderId); const result = await handle.result(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ orderId: order.orderId, status: "completed", @@ -207,13 +208,13 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; const describeResult = await handle.describe(); - expect(describeResult.isOk()).toBe(true); - if (describeResult.isOk()) { + expect(isOk(describeResult)).toBe(true); + if (isOk(describeResult)) { expect(describeResult.value).toEqual( expect.objectContaining({ workflowId: order.orderId, type: "processOrder" }), ); @@ -244,8 +245,8 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - expect(execution.isErr()).toBe(true); - if (execution.isErr()) { + expect(isErr(execution)).toBe(true); + if (isErr(execution)) { expect(execution.error).toBeInstanceOf(WorkflowValidationError); const validationError = execution.error as WorkflowValidationError; expect(validationError.workflowName).toBe("processOrder"); @@ -289,8 +290,8 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - Should return failed status - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ status: "failed", errorCode: "PAYMENT_FAILED", diff --git a/packages/client/package.json b/packages/client/package.json index 4d242eef..7c6aa3d3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,14 +1,14 @@ { "name": "@temporal-contract/client", "version": "2.4.0", - "description": "Client utilities with neverthrow Result/ResultAsync for consuming temporal-contract workflows", + "description": "Client utilities with unthrown Result/AsyncResult for consuming temporal-contract workflows", "keywords": [ "client", "contract", - "neverthrow", "result", "temporal", - "typescript" + "typescript", + "unthrown" ], "homepage": "https://github.com/btravstack/temporal-contract#readme", "bugs": { @@ -64,18 +64,18 @@ "@temporalio/workflow": "catalog:", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", - "neverthrow": "catalog:", "tsdown": "catalog:", "typedoc": "catalog:", "typedoc-plugin-markdown": "catalog:", "typescript": "catalog:", + "unthrown": "catalog:", "vitest": "catalog:", "zod": "catalog:" }, "peerDependencies": { "@temporalio/client": "^1", "@temporalio/common": "^1", - "neverthrow": "^8" + "unthrown": "^0.1" }, "engines": { "node": ">=22.19.0" diff --git a/packages/client/src/__tests__/client.spec.ts b/packages/client/src/__tests__/client.spec.ts index 695e3085..4bb35604 100644 --- a/packages/client/src/__tests__/client.spec.ts +++ b/packages/client/src/__tests__/client.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, vi, beforeEach } from "vitest"; +import { isOk, isErr } from "unthrown"; import { Worker } from "@temporalio/worker"; import { TypedClient } from "../client.js"; import { WorkflowValidationError } from "../errors.js"; @@ -93,8 +94,8 @@ describe("Client Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: test-data" }); } expect(logMessages).toContain("Processing: test-data"); @@ -112,15 +113,15 @@ describe("Client Package - Integration Tests", () => { }); // THEN - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; expect(handle.workflowId).toBe(workflowId); const result = await handle.result(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: async-test" }); } }); @@ -139,13 +140,13 @@ describe("Client Package - Integration Tests", () => { const handleResult = await client.getHandle("simpleWorkflow", workflowId); // THEN - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; const result = await handle.result(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: get-handle-test" }); } }); @@ -163,8 +164,8 @@ describe("Client Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ result: "HELLO WORLD" }); } expect(logMessages).toEqual(expect.arrayContaining(["Activity result: HELLO WORLD"])); @@ -184,8 +185,8 @@ describe("Client Package - Integration Tests", () => { args: invalidInput as { value: string }, }); - expect(execution.isErr()).toBe(true); - if (execution.isErr()) { + expect(isErr(execution)).toBe(true); + if (isErr(execution)) { expect(execution.error).toBeInstanceOf(WorkflowValidationError); } }); @@ -201,7 +202,7 @@ describe("Client Package - Integration Tests", () => { }); // THEN - Should succeed with proper validation - expect(result.isOk()).toBe(true); + expect(isOk(result)).toBe(true); }); }); @@ -214,8 +215,8 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 10 }, }); - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN - Send signals to increment value @@ -224,8 +225,8 @@ describe("Client Package - Integration Tests", () => { // THEN - Workflow should complete with updated value const result = await handle.result(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ finalValue: 18 }); // 10 + 5 + 3 } }); @@ -238,16 +239,16 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 42 }, }); - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN - Query the current value const queryResult = await handle.queries.getCurrentValue({}); // THEN - Should return current value - expect(queryResult.isOk()).toBe(true); - if (queryResult.isOk()) { + expect(isOk(queryResult)).toBe(true); + if (isOk(queryResult)) { expect(queryResult.value).toEqual({ value: 42 }); } @@ -263,23 +264,23 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 5 }, }); - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN - Send update to multiply value const updateResult = await handle.updates.multiply({ factor: 3 }); // THEN - Update should return the new value - expect(updateResult.isOk()).toBe(true); - if (updateResult.isOk()) { + expect(isOk(updateResult)).toBe(true); + if (isOk(updateResult)) { expect(updateResult.value).toEqual({ newValue: 15 }); // 5 * 3 } // Workflow should complete with the multiplied value const result = await handle.result(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ finalValue: 15 }); } }); @@ -294,16 +295,16 @@ describe("Client Package - Integration Tests", () => { args: { value: "describe-me" }, }); - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN const describeResult = await handle.describe(); // THEN - expect(describeResult.isOk()).toBe(true); - if (describeResult.isOk()) { + expect(isOk(describeResult)).toBe(true); + if (isOk(describeResult)) { expect(describeResult.value).toEqual( expect.objectContaining({ workflowId, type: "simpleWorkflow" }), ); @@ -321,19 +322,19 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 10 }, }); - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN const cancelResult = await handle.cancel(); // THEN - expect(cancelResult.isOk()).toBe(true); + expect(isOk(cancelResult)).toBe(true); // Result should throw or return error const result = await handle.result(); - expect(result.isErr()).toBe(true); + expect(isErr(result)).toBe(true); }); it("should terminate a running workflow", async ({ client }) => { @@ -344,24 +345,24 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 10 }, }); - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN const terminateResult = await handle.terminate("Test termination"); // THEN - expect(terminateResult.isOk()).toBe(true); + expect(isOk(terminateResult)).toBe(true); // Result should throw or return error const result = await handle.result(); - expect(result.isErr()).toBe(true); + expect(isErr(result)).toBe(true); }); }); describe("Result Pattern", () => { - it("should support Result.isOk() check", async ({ client }) => { + it("should support isOk(Result) check", async ({ client }) => { // GIVEN const input = { value: "test" }; @@ -372,8 +373,8 @@ describe("Client Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: test" }); } }); @@ -390,15 +391,18 @@ describe("Client Package - Integration Tests", () => { // THEN let matched = false; - result.match( - (value) => { + result.match({ + ok: (value) => { matched = true; expect(value).toEqual({ result: "Processed: test" }); }, - () => { + err: () => { throw new Error("Should not be called"); }, - ); + defect: () => { + throw new Error("Should not be called"); + }, + }); expect(matched).toBe(true); }); @@ -414,8 +418,8 @@ describe("Client Package - Integration Tests", () => { // THEN const mapped = result.map((value) => value.result.toUpperCase()); - expect(mapped.isOk()).toBe(true); - if (mapped.isOk()) { + expect(isOk(mapped)).toBe(true); + if (isOk(mapped)) { expect(mapped.value).toBe("PROCESSED: TEST"); } }); diff --git a/packages/client/src/client.spec.ts b/packages/client/src/client.spec.ts index 33f8605f..c87576c8 100644 --- a/packages/client/src/client.spec.ts +++ b/packages/client/src/client.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { isOk, isErr } from "unthrown"; import { z } from "zod"; import { defineContract, defineSearchAttribute, defineWorkflow } from "@temporal-contract/contract"; import { readTypedSearchAttributes, TypedClient } from "./client.js"; @@ -166,8 +167,8 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual( expect.objectContaining({ workflowId: "test-123", @@ -188,8 +189,8 @@ describe("TypedClient", () => { args: { name: "hello", value: "not-a-number" as unknown as number }, }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } }); @@ -203,8 +204,8 @@ describe("TypedClient", () => { }, ); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } }); @@ -219,8 +220,8 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ result: "success" }); } @@ -239,8 +240,8 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } }); @@ -253,7 +254,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); + expect(isErr(result)).toBe(true); }); }); @@ -293,8 +294,8 @@ describe("TypedClient", () => { signalArgs: [50], }); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value.workflowId).toBe("test-123"); expect(result.value.signaledRunId).toBe("run-abc"); } @@ -320,8 +321,8 @@ describe("TypedClient", () => { }, ); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } expect(mockWorkflow.signalWithStart).not.toHaveBeenCalled(); @@ -336,8 +337,8 @@ describe("TypedClient", () => { signalArgs: [50], }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } expect(mockWorkflow.signalWithStart).not.toHaveBeenCalled(); @@ -352,8 +353,8 @@ describe("TypedClient", () => { signalArgs: ["not a number"], }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(SignalValidationError); } expect(mockWorkflow.signalWithStart).not.toHaveBeenCalled(); @@ -369,8 +370,8 @@ describe("TypedClient", () => { signalArgs: [50], }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("signalWithStart"); } @@ -395,8 +396,8 @@ describe("TypedClient", () => { const result = await typedClient.getHandle("testWorkflow", "test-123"); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual(expect.objectContaining({ workflowId: "test-123" })); } }); @@ -407,8 +408,8 @@ describe("TypedClient", () => { "test-123", ); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } }); @@ -455,13 +456,13 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(isOk(handleResult)).toBe(true); - if (handleResult.isOk()) { + if (isOk(handleResult)) { const result = await handleResult.value.result(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ result: "success" }); } } @@ -475,13 +476,13 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(isOk(handleResult)).toBe(true); - if (handleResult.isOk()) { + if (isOk(handleResult)) { const result = await handleResult.value.queries.getStatus([]); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual("running"); } } @@ -493,12 +494,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(isOk(handleResult)).toBe(true); - if (handleResult.isOk()) { + if (isOk(handleResult)) { const result = await handleResult.value.signals.updateProgress([50]); - expect(result.isOk()).toBe(true); + expect(isOk(result)).toBe(true); expect(mockHandle.signal).toHaveBeenCalledWith("updateProgress", [50]); } }); @@ -511,13 +512,13 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(isOk(handleResult)).toBe(true); - if (handleResult.isOk()) { + if (isOk(handleResult)) { const result = await handleResult.value.updates.setConfig([{ value: "new-config" }]); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual(true); } } @@ -529,12 +530,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(isOk(handleResult)).toBe(true); - if (handleResult.isOk()) { + if (isOk(handleResult)) { const result = await handleResult.value.terminate("test reason"); - expect(result.isOk()).toBe(true); + expect(isOk(result)).toBe(true); expect(mockHandle.terminate).toHaveBeenCalledWith("test reason"); } }); @@ -545,12 +546,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(isOk(handleResult)).toBe(true); - if (handleResult.isOk()) { + if (isOk(handleResult)) { const result = await handleResult.value.cancel(); - expect(result.isOk()).toBe(true); + expect(isOk(result)).toBe(true); expect(mockHandle.cancel).toHaveBeenCalled(); } }); @@ -561,13 +562,13 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(isOk(handleResult)).toBe(true); - if (handleResult.isOk()) { + if (isOk(handleResult)) { const result = await handleResult.value.describe(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual(expect.objectContaining({ workflowId: "test-123" })); } } @@ -582,7 +583,7 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!handleResult.isOk()) throw new Error("expected Ok"); + if (!isOk(handleResult)) throw new Error("expected Ok"); // getStatus expects z.tuple([]); pass a non-tuple to bypass at runtime const result = await handleResult.value.queries.getStatus( @@ -590,8 +591,8 @@ describe("TypedClient", () => { [123], ); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(QueryValidationError); expect((result.error as QueryValidationError).direction).toBe("input"); } @@ -606,12 +607,12 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!handleResult.isOk()) throw new Error("expected Ok"); + if (!isOk(handleResult)) throw new Error("expected Ok"); const result = await handleResult.value.queries.getStatus([]); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(QueryValidationError); expect((result.error as QueryValidationError).direction).toBe("output"); } @@ -624,12 +625,12 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!handleResult.isOk()) throw new Error("expected Ok"); + if (!isOk(handleResult)) throw new Error("expected Ok"); const result = await handleResult.value.queries.getStatus([]); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("query"); } @@ -640,7 +641,7 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!handleResult.isOk()) throw new Error("expected Ok"); + if (!isOk(handleResult)) throw new Error("expected Ok"); // updateProgress expects z.tuple([z.number()]); pass a string const result = await handleResult.value.signals.updateProgress( @@ -648,8 +649,8 @@ describe("TypedClient", () => { ["not a number"], ); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(SignalValidationError); } expect(mockHandle.signal).not.toHaveBeenCalled(); @@ -662,12 +663,12 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!handleResult.isOk()) throw new Error("expected Ok"); + if (!isOk(handleResult)) throw new Error("expected Ok"); const result = await handleResult.value.signals.updateProgress([50]); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("signal"); } @@ -678,7 +679,7 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!handleResult.isOk()) throw new Error("expected Ok"); + if (!isOk(handleResult)) throw new Error("expected Ok"); // setConfig expects z.tuple([z.object({ value: z.string() })]); // pass an object with the wrong shape @@ -687,8 +688,8 @@ describe("TypedClient", () => { [{ value: 99 }], ); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(UpdateValidationError); expect((result.error as UpdateValidationError).direction).toBe("input"); } @@ -703,12 +704,12 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!handleResult.isOk()) throw new Error("expected Ok"); + if (!isOk(handleResult)) throw new Error("expected Ok"); const result = await handleResult.value.updates.setConfig([{ value: "ok" }]); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(UpdateValidationError); expect((result.error as UpdateValidationError).direction).toBe("output"); } @@ -721,12 +722,12 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!handleResult.isOk()) throw new Error("expected Ok"); + if (!isOk(handleResult)) throw new Error("expected Ok"); const result = await handleResult.value.updates.setConfig([{ value: "ok" }]); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("update"); } @@ -744,15 +745,18 @@ describe("TypedClient", () => { }); let matched = false; - result.match( - (value) => { + result.match({ + ok: (value) => { matched = true; expect(value).toEqual({ result: "success" }); }, - () => { + err: () => { throw new Error("Should not be called"); }, - ); + defect: () => { + throw new Error("Should not be called"); + }, + }); expect(matched).toBe(true); }); @@ -767,8 +771,8 @@ describe("TypedClient", () => { const mapped = result.map((value) => value.result.toUpperCase()); - expect(mapped.isOk()).toBe(true); - if (mapped.isOk()) { + expect(isOk(mapped)).toBe(true); + if (isOk(mapped)) { expect(mapped.value).toEqual("SUCCESS"); } }); @@ -836,7 +840,7 @@ describe("TypedClient", () => { }, }); - expect(result.isOk()).toBe(true); + expect(isOk(result)).toBe(true); const call = mockWorkflow.start.mock.calls[0]; expect(call?.[0]).toBe("processOrder"); const passed = call?.[1] as { typedSearchAttributes?: TypedSearchAttributes }; @@ -926,8 +930,8 @@ describe("TypedClient", () => { }, }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); const op = (result.error as RuntimeClientError).operation; expect(op).toBe("searchAttributes"); @@ -955,8 +959,8 @@ describe("TypedClient", () => { }, ); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("searchAttributes"); expect((result.error as RuntimeClientError).message).toContain("customerId"); @@ -1008,8 +1012,8 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowAlreadyStartedError); const err = result.error as WorkflowAlreadyStartedError; expect(err.workflowId).toBe("test-123"); @@ -1026,8 +1030,8 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect(result.error).not.toBeInstanceOf(WorkflowAlreadyStartedError); expect((result.error as RuntimeClientError).operation).toBe("startWorkflow"); @@ -1046,8 +1050,8 @@ describe("TypedClient", () => { signalArgs: [50], }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowAlreadyStartedError); } }); @@ -1062,8 +1066,8 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowAlreadyStartedError); } }); @@ -1084,8 +1088,8 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowFailedError); const err = result.error as WorkflowFailedError; expect(err.workflowId).toBe("test-123"); @@ -1105,8 +1109,8 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); const err = result.error as WorkflowExecutionNotFoundError; expect(err.workflowId).toBe("test-123"); @@ -1134,11 +1138,11 @@ describe("TypedClient", () => { mockWorkflow.getHandle.mockReturnValue(handle); const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); - if (!handleResult.isOk()) throw new Error("getHandle should succeed"); + if (!isOk(handleResult)) throw new Error("getHandle should succeed"); const result = await handleResult.value.result(); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowFailedError); const err = result.error as WorkflowFailedError; expect(err.workflowId).toBe("test-123"); @@ -1171,11 +1175,11 @@ describe("TypedClient", () => { mockWorkflow.getHandle.mockReturnValue(handle); const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); - if (!handleResult.isOk()) throw new Error("getHandle should succeed"); + if (!isOk(handleResult)) throw new Error("getHandle should succeed"); const result = await handleResult.value.cancel(); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); const err = result.error as WorkflowExecutionNotFoundError; // Fallback applied: the handle's workflowId rather than the @@ -1201,11 +1205,11 @@ describe("TypedClient", () => { mockWorkflow.getHandle.mockReturnValue(handle); const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); - if (!handleResult.isOk()) throw new Error("getHandle should succeed"); + if (!isOk(handleResult)) throw new Error("getHandle should succeed"); const result = await handleResult.value.terminate("done"); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); } }); @@ -1227,11 +1231,11 @@ describe("TypedClient", () => { mockWorkflow.getHandle.mockReturnValue(handle); const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); - if (!handleResult.isOk()) throw new Error("getHandle should succeed"); + if (!isOk(handleResult)) throw new Error("getHandle should succeed"); const result = await handleResult.value.signals.updateProgress([50]); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); } }); @@ -1253,11 +1257,11 @@ describe("TypedClient", () => { mockWorkflow.getHandle.mockReturnValue(handle); const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); - if (!handleResult.isOk()) throw new Error("getHandle should succeed"); + if (!isOk(handleResult)) throw new Error("getHandle should succeed"); const result = await handleResult.value.describe(); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { const err = result.error as WorkflowExecutionNotFoundError; expect(err).toBeInstanceOf(WorkflowExecutionNotFoundError); expect(err.runId).toBe("run-xyz"); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index cfd82359..feefdcb2 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -17,7 +17,7 @@ import type { ClientInferWorkflowSignals, ClientInferWorkflowUpdates, } from "./types.js"; -import { ResultAsync, type Result, ok, err } from "neverthrow"; +import { type AsyncResult, type Result, ok, err, isOk, isErr, fromPromise } from "unthrown"; import { type TemporalFailure, WorkflowAlreadyStartedError, @@ -81,7 +81,7 @@ export type TypedSearchAttributeMap = * @example * ```ts * const description = await handle.describe(); - * if (description.isOk()) { + * if (isOk(description)) { * const attrs = readTypedSearchAttributes( * myContract.workflows.processOrder, * description.value.typedSearchAttributes, @@ -170,22 +170,22 @@ export type TypedWorkflowHandleWithSignaledRunId = { workflowId: string; /** * Type-safe queries based on workflow definition with Result pattern - * Each query returns ResultAsync instead of Promise + * Each query returns AsyncResult instead of Promise */ queries: { [K in keyof ClientInferWorkflowQueries]: ClientInferWorkflowQueries[K] extends ( ...args: infer Args - ) => ResultAsync + ) => AsyncResult ? ( ...args: Args - ) => ResultAsync< + ) => AsyncResult< R, QueryValidationError | WorkflowExecutionNotFoundError | RuntimeClientError > @@ -194,15 +194,15 @@ export type TypedWorkflowHandle = { /** * Type-safe signals based on workflow definition with Result pattern - * Each signal returns ResultAsync instead of Promise + * Each signal returns AsyncResult instead of Promise */ signals: { [K in keyof ClientInferWorkflowSignals]: ClientInferWorkflowSignals[K] extends ( ...args: infer Args - ) => ResultAsync + ) => AsyncResult ? ( ...args: Args - ) => ResultAsync< + ) => AsyncResult< void, SignalValidationError | WorkflowExecutionNotFoundError | RuntimeClientError > @@ -211,15 +211,15 @@ export type TypedWorkflowHandle = { /** * Type-safe updates based on workflow definition with Result pattern - * Each update returns ResultAsync instead of Promise + * Each update returns AsyncResult instead of Promise */ updates: { [K in keyof ClientInferWorkflowUpdates]: ClientInferWorkflowUpdates[K] extends ( ...args: infer Args - ) => ResultAsync + ) => AsyncResult ? ( ...args: Args - ) => ResultAsync< + ) => AsyncResult< R, UpdateValidationError | WorkflowExecutionNotFoundError | RuntimeClientError > @@ -229,7 +229,7 @@ export type TypedWorkflowHandle = { /** * Get workflow result with Result pattern */ - result: () => ResultAsync< + result: () => AsyncResult< ClientInferOutput, | WorkflowValidationError | WorkflowFailedError @@ -242,17 +242,17 @@ export type TypedWorkflowHandle = { */ terminate: ( reason?: string, - ) => ResultAsync; + ) => AsyncResult; /** * Cancel workflow with Result pattern */ - cancel: () => ResultAsync; + cancel: () => AsyncResult; /** * Get workflow execution description including status and metadata */ - describe: () => ResultAsync< + describe: () => AsyncResult< Awaited>, WorkflowExecutionNotFoundError | RuntimeClientError >; @@ -260,7 +260,7 @@ export type TypedWorkflowHandle = { /** * Fetch the workflow execution history */ - fetchHistory: () => ResultAsync< + fetchHistory: () => AsyncResult< Awaited>, WorkflowExecutionNotFoundError | RuntimeClientError >; @@ -326,7 +326,9 @@ async function resolveDefinitionAndValidateInput< workflowName, searchAttributes, ); - if (searchAttributesResult.isErr()) return err(searchAttributesResult.error); + if (isErr(searchAttributesResult)) return err(searchAttributesResult.error); + // `toTypedSearchAttributes` only ever builds ok/err; a defect would be a bug. + if (!isOk(searchAttributesResult)) throw searchAttributesResult.cause; const typedSearchAttributes = searchAttributesResult.value; return ok({ @@ -337,7 +339,7 @@ async function resolveDefinitionAndValidateInput< } /** - * Typed Temporal client with neverthrow Result/ResultAsync pattern based on a contract + * Typed Temporal client with unthrown Result/AsyncResult pattern based on a contract * * Provides type-safe methods to start and execute workflows * defined in the contract, with explicit error handling using Result pattern. @@ -363,10 +365,11 @@ export class TypedClient { * args: { orderId: "sweep" }, * }); * - * result.match( - * async (handle) => { await handle.pause("maintenance"); }, - * (error) => console.error("schedule create failed", error), - * ); + * await result.match({ + * ok: async (handle) => { await handle.pause("maintenance"); }, + * err: (error) => console.error("schedule create failed", error), + * defect: (cause) => console.error("unexpected failure", cause), + * }); * ``` */ readonly schedule: TypedScheduleClient; @@ -390,7 +393,7 @@ export class TypedClient { } /** - * Create a typed Temporal client with neverthrow pattern from a contract + * Create a typed Temporal client with unthrown pattern from a contract * * @example * ```ts @@ -403,10 +406,11 @@ export class TypedClient { * args: { ... }, * }); * - * result.match( - * (output) => console.log('Success:', output), - * (error) => console.error('Failed:', error), - * ); + * await result.match({ + * ok: (output) => console.log('Success:', output), + * err: (error) => console.error('Failed:', error), + * defect: (cause) => console.error('Unexpected failure:', cause), + * }); * ``` */ static create( @@ -417,7 +421,7 @@ export class TypedClient { } /** - * Start a workflow and return a typed handle with ResultAsync pattern + * Start a workflow and return a typed handle with AsyncResult pattern * * @example * ```ts @@ -428,13 +432,14 @@ export class TypedClient { * retry: { maximumAttempts: 3 }, * }); * - * handleResult.match( - * async (handle) => { + * await handleResult.match({ + * ok: async (handle) => { * const result = await handle.result(); * // ... handle result * }, - * (error) => console.error('Failed to start:', error), - * ); + * err: (error) => console.error('Failed to start:', error), + * defect: (cause) => console.error('Unexpected failure:', cause), + * }); * ``` */ startWorkflow( @@ -444,7 +449,7 @@ export class TypedClient { searchAttributes, ...temporalOptions }: TypedWorkflowStartOptions, - ): ResultAsync< + ): AsyncResult< TypedWorkflowHandle, | WorkflowNotFoundError | WorkflowValidationError @@ -464,7 +469,9 @@ export class TypedClient { args, searchAttributes as Record | undefined, ); - if (resolved.isErr()) return err(resolved.error); + if (isErr(resolved)) return err(resolved.error); + // The resolver only ever builds ok/err; a defect would be a genuine bug. + if (!isOk(resolved)) throw resolved.cause; const { definition, validatedInput, typedSearchAttributes } = resolved.value; try { @@ -502,10 +509,11 @@ export class TypedClient { * signalArgs: { reason: 'duplicate' }, * }); * - * result.match( - * (handle) => console.log('signaled run', handle.signaledRunId), - * (error) => console.error('signalWithStart failed', error), - * ); + * await result.match({ + * ok: (handle) => console.log('signaled run', handle.signaledRunId), + * err: (error) => console.error('signalWithStart failed', error), + * defect: (cause) => console.error('unexpected failure', cause), + * }); * ``` */ signalWithStart< @@ -520,7 +528,7 @@ export class TypedClient { searchAttributes, ...temporalOptions }: TypedSignalWithStartOptions, - ): ResultAsync< + ): AsyncResult< TypedWorkflowHandleWithSignaledRunId, | WorkflowNotFoundError | WorkflowValidationError @@ -543,7 +551,9 @@ export class TypedClient { args, searchAttributes as Record | undefined, ); - if (resolved.isErr()) return err(resolved.error); + if (isErr(resolved)) return err(resolved.error); + // The resolver only ever builds ok/err; a defect would be a genuine bug. + if (!isOk(resolved)) throw resolved.cause; const { definition, validatedInput, typedSearchAttributes } = resolved.value; // Validate signal input — call-site-specific, kept inline. @@ -587,7 +597,7 @@ export class TypedClient { } /** - * Execute a workflow (start and wait for result) with ResultAsync pattern + * Execute a workflow (start and wait for result) with AsyncResult pattern * * @example * ```ts @@ -598,10 +608,11 @@ export class TypedClient { * retry: { maximumAttempts: 3 }, * }); * - * result.match( - * (output) => console.log('Order processed:', output.status), - * (error) => console.error('Processing failed:', error), - * ); + * await result.match({ + * ok: (output) => console.log('Order processed:', output.status), + * err: (error) => console.error('Processing failed:', error), + * defect: (cause) => console.error('Unexpected failure:', cause), + * }); * ``` */ executeWorkflow( @@ -611,7 +622,7 @@ export class TypedClient { searchAttributes, ...temporalOptions }: TypedWorkflowStartOptions, - ): ResultAsync< + ): AsyncResult< ClientInferOutput, | WorkflowNotFoundError | WorkflowValidationError @@ -635,7 +646,9 @@ export class TypedClient { args, searchAttributes as Record | undefined, ); - if (resolved.isErr()) return err(resolved.error); + if (isErr(resolved)) return err(resolved.error); + // The resolver only ever builds ok/err; a defect would be a genuine bug. + if (!isOk(resolved)) throw resolved.cause; const { definition, validatedInput, typedSearchAttributes } = resolved.value; try { @@ -695,7 +708,7 @@ export class TypedClient { } /** - * Get a handle to an existing workflow with ResultAsync pattern + * Get a handle to an existing workflow with AsyncResult pattern * * @example * ```ts @@ -712,7 +725,7 @@ export class TypedClient { getHandle( workflowName: TWorkflowName, workflowId: string, - ): ResultAsync< + ): AsyncResult< TypedWorkflowHandle, WorkflowNotFoundError | RuntimeClientError > { @@ -775,7 +788,7 @@ export class TypedClient { queries, signals, updates, - result: (): ResultAsync< + result: (): AsyncResult< ClientInferOutput, | WorkflowValidationError | WorkflowFailedError @@ -810,26 +823,26 @@ export class TypedClient { }, terminate: ( reason?: string, - ): ResultAsync => - ResultAsync.fromPromise(workflowHandle.terminate(reason), (error) => + ): AsyncResult => + fromPromise(workflowHandle.terminate(reason), (error) => classifyHandleError("terminate", error, workflowHandle.workflowId), ).map(() => undefined), - cancel: (): ResultAsync => - ResultAsync.fromPromise(workflowHandle.cancel(), (error) => + cancel: (): AsyncResult => + fromPromise(workflowHandle.cancel(), (error) => classifyHandleError("cancel", error, workflowHandle.workflowId), ).map(() => undefined), - describe: (): ResultAsync< + describe: (): AsyncResult< Awaited>, WorkflowExecutionNotFoundError | RuntimeClientError > => - ResultAsync.fromPromise(workflowHandle.describe(), (error) => + fromPromise(workflowHandle.describe(), (error) => classifyHandleError("describe", error, workflowHandle.workflowId), ), - fetchHistory: (): ResultAsync< + fetchHistory: (): AsyncResult< Awaited>, WorkflowExecutionNotFoundError | RuntimeClientError > => - ResultAsync.fromPromise(workflowHandle.fetchHistory(), (error) => + fromPromise(workflowHandle.fetchHistory(), (error) => classifyHandleError("fetchHistory", error, workflowHandle.workflowId), ), }; @@ -881,7 +894,7 @@ type ProxyOptions = { }; /** - * Build a `{ name: (args) => ResultAsync<...> }` proxy for a contract's + * Build a `{ name: (args) => AsyncResult<...> }` proxy for a contract's * queries/signals/updates. The three call sites differ only in how they * invoke Temporal and whether they validate output, so the shared * input-validate → invoke → output-validate → wrap-Result pipeline lives @@ -898,13 +911,13 @@ function buildValidatedProxy ResultAsync + ) => AsyncResult > { const proxy: Record< string, ( args: unknown, - ) => ResultAsync< + ) => AsyncResult< unknown, TValidationError | WorkflowExecutionNotFoundError | RuntimeClientError > diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 451b019b..2bd78c0a 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -1,5 +1,6 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { summarizeIssues } from "@temporal-contract/contract"; +import { TaggedError } from "unthrown"; import type { ActivityFailure, ApplicationFailure, @@ -29,46 +30,39 @@ export type TemporalFailure = | ServerFailure | ActivityFailure; -/** - * Base class for all typed client errors. - */ -abstract class TypedClientError extends Error { - protected constructor(message: string) { - super(message); - this.name = this.constructor.name; - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor); - } - } -} - /** * Generic runtime failure wrapper when no specific error type applies */ -export class RuntimeClientError extends TypedClientError { - constructor( - public readonly operation: string, - public override readonly cause?: unknown, - ) { - super( - `Operation "${operation}" failed: ${ +export class RuntimeClientError extends TaggedError("RuntimeClientError")<{ + operation: string; + cause?: unknown; + message: string; +}> { + constructor(operation: string, cause?: unknown) { + super({ + operation, + cause, + message: `Operation "${operation}" failed: ${ cause instanceof Error ? cause.message : String(cause ?? "unknown error") }`, - ); + }); } } /** * Thrown when a workflow is not found in the contract */ -export class WorkflowNotFoundError extends TypedClientError { - constructor( - public readonly workflowName: string, - public readonly availableWorkflows: string[], - ) { - super( - `Workflow "${workflowName}" not found in contract. Available workflows: ${availableWorkflows.join(", ")}`, - ); +export class WorkflowNotFoundError extends TaggedError("WorkflowNotFoundError")<{ + workflowName: string; + availableWorkflows: string[]; + message: string; +}> { + constructor(workflowName: string, availableWorkflows: string[]) { + super({ + workflowName, + availableWorkflows, + message: `Workflow "${workflowName}" not found in contract. Available workflows: ${availableWorkflows.join(", ")}`, + }); } } @@ -83,13 +77,19 @@ export class WorkflowNotFoundError extends TypedClientError { * branch on it explicitly (e.g. fetch the existing handle and continue) * without inspecting `error.cause` against a Temporal SDK class. */ -export class WorkflowAlreadyStartedError extends TypedClientError { - constructor( - public readonly workflowType: string, - public readonly workflowId: string, - public override readonly cause?: unknown, - ) { - super(`Workflow "${workflowType}" with ID "${workflowId}" is already started or in retention.`); +export class WorkflowAlreadyStartedError extends TaggedError("WorkflowAlreadyStartedError")<{ + workflowType: string; + workflowId: string; + cause?: unknown; + message: string; +}> { + constructor(workflowType: string, workflowId: string, cause?: unknown) { + super({ + workflowType, + workflowId, + cause, + message: `Workflow "${workflowType}" with ID "${workflowId}" is already started or in retention.`, + }); } } @@ -105,15 +105,19 @@ export class WorkflowAlreadyStartedError extends TypedClientError { * - `executeWorkflow` (when the underlying execute call hits a missing * execution mid-flight) */ -export class WorkflowExecutionNotFoundError extends TypedClientError { - constructor( - public readonly workflowId: string, - public readonly runId?: string, - public override readonly cause?: unknown, - ) { - super( - `Workflow execution "${workflowId}"${runId ? ` (run "${runId}")` : ""} not found in namespace.`, - ); +export class WorkflowExecutionNotFoundError extends TaggedError("WorkflowExecutionNotFoundError")<{ + workflowId: string; + runId?: string | undefined; + cause?: unknown; + message: string; +}> { + constructor(workflowId: string, runId?: string, cause?: unknown) { + super({ + workflowId, + runId, + cause, + message: `Workflow execution "${workflowId}"${runId ? ` (run "${runId}")` : ""} not found in namespace.`, + }); } } @@ -136,14 +140,19 @@ export class WorkflowExecutionNotFoundError extends TypedClientError { * * Returned from `executeWorkflow` and `handle.result()`. */ -export class WorkflowFailedError extends TypedClientError { - constructor( - public readonly workflowId: string, - public override readonly cause?: TemporalFailure, - ) { +export class WorkflowFailedError extends TaggedError("WorkflowFailedError")<{ + workflowId: string; + cause?: TemporalFailure | undefined; + message: string; +}> { + constructor(workflowId: string, cause?: TemporalFailure) { const causeMessage = cause instanceof Error ? cause.message : String(cause ?? "unknown failure"); - super(`Workflow "${workflowId}" completed with failure: ${causeMessage}`); + super({ + workflowId, + cause, + message: `Workflow "${workflowId}" completed with failure: ${causeMessage}`, + }); } } @@ -155,52 +164,85 @@ export class WorkflowFailedError extends TypedClientError { /** * Thrown when workflow input or output validation fails */ -export class WorkflowValidationError extends TypedClientError { +export class WorkflowValidationError extends TaggedError("WorkflowValidationError")<{ + workflowName: string; + direction: "input" | "output"; + issues: ReadonlyArray; + message: string; +}> { constructor( - public readonly workflowName: string, - public readonly direction: "input" | "output", - public readonly issues: ReadonlyArray, + workflowName: string, + direction: "input" | "output", + issues: ReadonlyArray, ) { - super( - `Validation failed for workflow "${workflowName}" ${direction}: ${summarizeIssues(issues)}`, - ); + super({ + workflowName, + direction, + issues, + message: `Validation failed for workflow "${workflowName}" ${direction}: ${summarizeIssues(issues)}`, + }); } } /** * Thrown when query input or output validation fails */ -export class QueryValidationError extends TypedClientError { +export class QueryValidationError extends TaggedError("QueryValidationError")<{ + queryName: string; + direction: "input" | "output"; + issues: ReadonlyArray; + message: string; +}> { constructor( - public readonly queryName: string, - public readonly direction: "input" | "output", - public readonly issues: ReadonlyArray, + queryName: string, + direction: "input" | "output", + issues: ReadonlyArray, ) { - super(`Validation failed for query "${queryName}" ${direction}: ${summarizeIssues(issues)}`); + super({ + queryName, + direction, + issues, + message: `Validation failed for query "${queryName}" ${direction}: ${summarizeIssues(issues)}`, + }); } } /** * Thrown when signal input validation fails */ -export class SignalValidationError extends TypedClientError { - constructor( - public readonly signalName: string, - public readonly issues: ReadonlyArray, - ) { - super(`Validation failed for signal "${signalName}": ${summarizeIssues(issues)}`); +export class SignalValidationError extends TaggedError("SignalValidationError")<{ + signalName: string; + issues: ReadonlyArray; + message: string; +}> { + constructor(signalName: string, issues: ReadonlyArray) { + super({ + signalName, + issues, + message: `Validation failed for signal "${signalName}": ${summarizeIssues(issues)}`, + }); } } /** * Thrown when update input or output validation fails */ -export class UpdateValidationError extends TypedClientError { +export class UpdateValidationError extends TaggedError("UpdateValidationError")<{ + updateName: string; + direction: "input" | "output"; + issues: ReadonlyArray; + message: string; +}> { constructor( - public readonly updateName: string, - public readonly direction: "input" | "output", - public readonly issues: ReadonlyArray, + updateName: string, + direction: "input" | "output", + issues: ReadonlyArray, ) { - super(`Validation failed for update "${updateName}" ${direction}: ${summarizeIssues(issues)}`); + super({ + updateName, + direction, + issues, + message: `Validation failed for update "${updateName}" ${direction}: ${summarizeIssues(issues)}`, + }); } } diff --git a/packages/client/src/internal.ts b/packages/client/src/internal.ts index 90d5a35e..f9926d63 100644 --- a/packages/client/src/internal.ts +++ b/packages/client/src/internal.ts @@ -14,8 +14,8 @@ import { WorkflowNotFoundError as TemporalWorkflowNotFoundError, } from "@temporalio/common"; import type { AnyWorkflowDefinition, SearchAttributeDefinition } from "@temporal-contract/contract"; -import { _internal_makeResultAsync } from "@temporal-contract/contract/result-async"; -import { ok, err, type ResultAsync, type Result } from "neverthrow"; +import { _internal_makeAsyncResult } from "@temporal-contract/contract/result-async"; +import { ok, err, type AsyncResult, type Result } from "unthrown"; import { RuntimeClientError, type TemporalFailure, @@ -75,26 +75,23 @@ export function toTypedSearchAttributes( } /** - * Wrap an async result-producing function in a `ResultAsync`, catching any - * unhandled rejection as a `RuntimeClientError("unexpected", error)`. + * Wrap an async result-producing function in an `AsyncResult`, routing any + * unanticipated rejection through unthrown's `defect` channel. * * The work function is expected to handle its own domain errors and return - * an `err(...)` for them; the catch here is a safety net for thrown - * exceptions the work didn't anticipate. + * an `err(...)` for them; a thrown exception the work didn't anticipate is an + * *unmodeled* failure and surfaces as a defect (inspectable via + * `result.isDefect()` / `result.cause`, re-thrown at the edge) rather than a + * manufactured `RuntimeClientError`. * * Used by `client.ts` (workflow operations) and `schedule.ts` (schedule * operations) so the unexpected-rejection shape is identical across the - * typed client surface. Delegates to `_internal_makeResultAsync` from + * typed client surface. Delegates to `_internal_makeAsyncResult` from * `@temporal-contract/contract` so the same wrapper is shared between the * client and worker packages. */ -export function makeResultAsync( - work: () => Promise>, -): ResultAsync { - return _internal_makeResultAsync( - work, - (e) => new RuntimeClientError("unexpected", e), - ); +export function makeResultAsync(work: () => Promise>): AsyncResult { + return _internal_makeAsyncResult(work); } /** diff --git a/packages/client/src/schedule.spec.ts b/packages/client/src/schedule.spec.ts index 8696ecb8..946b811f 100644 --- a/packages/client/src/schedule.spec.ts +++ b/packages/client/src/schedule.spec.ts @@ -5,6 +5,7 @@ * Closes #181. */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { isOk, isErr } from "unthrown"; import { z } from "zod"; import type { Client } from "@temporalio/client"; import { TypedSearchAttributes } from "@temporalio/common"; @@ -83,8 +84,8 @@ describe("TypedClient.schedule", () => { args: { orderId: "sweep" }, }); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value.scheduleId).toBe("daily-sweep"); } @@ -113,8 +114,8 @@ describe("TypedClient.schedule", () => { }, ); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } expect(mockSchedule.create).not.toHaveBeenCalled(); @@ -128,8 +129,8 @@ describe("TypedClient.schedule", () => { args: { orderId: 123 }, }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } expect(mockSchedule.create).not.toHaveBeenCalled(); @@ -144,8 +145,8 @@ describe("TypedClient.schedule", () => { args: { orderId: "sweep" }, }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("schedule.create"); } @@ -291,8 +292,8 @@ describe("TypedClient.schedule", () => { }, }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("searchAttributes"); expect((result.error as RuntimeClientError).message).toContain("unknownAttr"); @@ -329,8 +330,8 @@ describe("TypedClient.schedule", () => { const handle = client.schedule.getHandle("missing"); const result = await handle.pause(); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("schedule.pause"); } @@ -344,8 +345,8 @@ describe("TypedClient.schedule", () => { const handle = client.schedule.getHandle("daily-sweep"); const result = await handle.describe(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect((result.value as { scheduleId: string }).scheduleId).toBe("daily-sweep"); } }); diff --git a/packages/client/src/schedule.ts b/packages/client/src/schedule.ts index 8b2e6151..2815c0a6 100644 --- a/packages/client/src/schedule.ts +++ b/packages/client/src/schedule.ts @@ -8,7 +8,7 @@ import type { ScheduleSpec, } from "@temporalio/client"; import type { ContractDefinition } from "@temporal-contract/contract"; -import { ResultAsync, type Result, ok, err } from "neverthrow"; +import { type AsyncResult, type Result, ok, err, isOk, isErr, fromPromise } from "unthrown"; import type { TypedSearchAttributeMap } from "./client.js"; import type { ClientInferInput } from "./types.js"; import { RuntimeClientError, WorkflowNotFoundError, WorkflowValidationError } from "./errors.js"; @@ -83,22 +83,22 @@ export type TypedScheduleCreateOptions< /** * Typed handle to a schedule. Mirrors Temporal's `ScheduleHandle` lifecycle * methods (`pause`, `unpause`, `trigger`, `describe`, `delete`) wrapped in - * the neverthrow ResultAsync pattern so call sites match the rest of the + * the unthrown AsyncResult pattern so call sites match the rest of the * typed client. */ export type TypedScheduleHandle = { /** This schedule's identifier. */ readonly scheduleId: string; /** Pause the schedule. Optional note becomes part of the audit trail. */ - pause: (note?: string) => ResultAsync; + pause: (note?: string) => AsyncResult; /** Resume a paused schedule. */ - unpause: (note?: string) => ResultAsync; + unpause: (note?: string) => AsyncResult; /** Fire the schedule's action immediately. */ - trigger: (overlap?: ScheduleOverlapPolicy) => ResultAsync; + trigger: (overlap?: ScheduleOverlapPolicy) => AsyncResult; /** Delete the schedule. */ - delete: () => ResultAsync; + delete: () => AsyncResult; /** Fetch the schedule's current description from the server. */ - describe: () => ResultAsync; + describe: () => AsyncResult; }; /** @@ -124,7 +124,7 @@ export class TypedScheduleClient { create( workflowName: TWorkflowName, options: TypedScheduleCreateOptions, - ): ResultAsync< + ): AsyncResult< TypedScheduleHandle, WorkflowNotFoundError | WorkflowValidationError | RuntimeClientError > { @@ -151,7 +151,10 @@ export class TypedScheduleClient { workflowName, options.searchAttributes as Record | undefined, ); - if (searchAttributesResult.isErr()) return err(searchAttributesResult.error); + if (isErr(searchAttributesResult)) return err(searchAttributesResult.error); + // `toTypedSearchAttributes` only ever builds ok/err; a defect would be a + // genuine bug — re-throw so it rides the defect channel. + if (!isOk(searchAttributesResult)) throw searchAttributesResult.cause; const typedSearchAttributes = searchAttributesResult.value; try { @@ -212,29 +215,25 @@ function wrapScheduleHandle(handle: ScheduleHandle): TypedScheduleHandle { return { scheduleId: handle.scheduleId, pause: (note) => - ResultAsync.fromPromise( + fromPromise( handle.pause(note), (error) => new RuntimeClientError("schedule.pause", error), ).map(() => undefined), unpause: (note) => - ResultAsync.fromPromise( + fromPromise( handle.unpause(note), (error) => new RuntimeClientError("schedule.unpause", error), ).map(() => undefined), trigger: (overlap) => - ResultAsync.fromPromise( + fromPromise( handle.trigger(overlap), (error) => new RuntimeClientError("schedule.trigger", error), ).map(() => undefined), delete: () => - ResultAsync.fromPromise( - handle.delete(), - (error) => new RuntimeClientError("schedule.delete", error), - ).map(() => undefined), - describe: () => - ResultAsync.fromPromise( - handle.describe(), - (error) => new RuntimeClientError("schedule.describe", error), + fromPromise(handle.delete(), (error) => new RuntimeClientError("schedule.delete", error)).map( + () => undefined, ), + describe: () => + fromPromise(handle.describe(), (error) => new RuntimeClientError("schedule.describe", error)), }; } diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 7ada2ac5..c5654a21 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -1,5 +1,5 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; -import type { ResultAsync } from "neverthrow"; +import type { AsyncResult } from "unthrown"; import type { AnySchema, ActivityDefinition, @@ -49,27 +49,27 @@ export type ClientInferActivity = ( /** * Infer signal handler signature from client perspective - * Client sends z.output and returns ResultAsync + * Client sends z.output and returns AsyncResult */ export type ClientInferSignal = ( args: ClientInferInput, -) => ResultAsync; +) => AsyncResult; /** * Infer query handler signature from client perspective - * Client sends z.output and receives z.input wrapped in ResultAsync + * Client sends z.output and receives z.input wrapped in AsyncResult */ export type ClientInferQuery = ( args: ClientInferInput, -) => ResultAsync, Error>; +) => AsyncResult, Error>; /** * Infer update handler signature from client perspective - * Client sends z.output and receives z.input wrapped in ResultAsync + * Client sends z.output and receives z.input wrapped in AsyncResult */ export type ClientInferUpdate = ( args: ClientInferInput, -) => ResultAsync, Error>; +) => AsyncResult, Error>; /** * CLIENT PERSPECTIVE - Contract-level types diff --git a/packages/contract/package.json b/packages/contract/package.json index 05507d56..5f57fbb1 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -65,19 +65,19 @@ "@temporal-contract/typedoc": "workspace:*", "@vitest/coverage-v8": "catalog:", "arktype": "catalog:", - "neverthrow": "catalog:", "tsdown": "catalog:", "typedoc": "catalog:", "typedoc-plugin-markdown": "catalog:", "typescript": "catalog:", + "unthrown": "catalog:", "valibot": "catalog:", "vitest": "catalog:" }, "peerDependencies": { - "neverthrow": "^8" + "unthrown": "^0.1" }, "peerDependenciesMeta": { - "neverthrow": { + "unthrown": { "optional": true } }, diff --git a/packages/contract/src/result-async.spec.ts b/packages/contract/src/result-async.spec.ts index 5b873234..425c637e 100644 --- a/packages/contract/src/result-async.spec.ts +++ b/packages/contract/src/result-async.spec.ts @@ -1,16 +1,17 @@ /** - * Coverage for the shared `_internal_makeResultAsync` helper. + * Coverage for the shared `_internal_makeAsyncResult` helper. * - * The helper closes the gap that bare `new ResultAsync(promise)` does not - * catch: a synchronous throw or a rejected promise from `work()` would - * otherwise surface as an unhandled rejection rather than `err(...)` on - * neverthrow's typed error channel. Both consuming packages - * (`@temporal-contract/client` and `@temporal-contract/worker`) rely on - * this — see `client/src/internal.ts` and `worker/src/internal.ts`. + * The helper routes a synchronous throw or a rejected promise from `work()` + * through unthrown's `defect` channel — an *unanticipated* failure becomes a + * defect (a bug, re-thrown at the edge) rather than an unhandled rejection, + * while the work function's own domain `err(...)` flows through untouched. + * Both consuming packages (`@temporal-contract/client` and + * `@temporal-contract/worker`) rely on this — see `client/src/internal.ts` + * and `worker/src/internal.ts`. */ import { describe, expect, it } from "vitest"; -import { ok, err } from "neverthrow"; -import { _internal_makeResultAsync } from "./result-async.js"; +import { ok, err, isOk, isErr, isDefect } from "unthrown"; +import { _internal_makeAsyncResult } from "./result-async.js"; class TestError extends Error { constructor(public readonly tag: string) { @@ -19,88 +20,66 @@ class TestError extends Error { } } -describe("_internal_makeResultAsync", () => { +describe("_internal_makeAsyncResult", () => { it("returns ok(...) when the work function resolves with ok(...)", async () => { - const result = await _internal_makeResultAsync( - async () => ok(42), - (e) => new TestError(`unexpected:${String(e)}`), - ); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + const result = await _internal_makeAsyncResult(async () => ok(42)); + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toBe(42); } }); it("returns err(...) unchanged when the work function resolves with err(...)", async () => { const domainError = new TestError("domain"); - const result = await _internal_makeResultAsync( - async () => err(domainError), - (e) => new TestError(`unexpected:${String(e)}`), - ); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + const result = await _internal_makeAsyncResult(async () => err(domainError)); + expect(isErr(result)).toBe(true); + if (isErr(result)) { // Identity preserved — the domain `err(...)` flows through untouched. expect(result.error).toBe(domainError); } }); - it("routes a rejected promise through mapRejection as err(...)", async () => { - // Without the helper, `new ResultAsync(work())` rejects rather than - // resolving to err(...) — this is the unhandled-rejection gap the - // helper closes. + it("routes a rejected promise through the defect channel", async () => { + // Without the helper, a rejected promise would surface as an unhandled + // rejection — an *unanticipated* failure becomes a defect instead. const thrown = new Error("kaboom"); - const result = await _internal_makeResultAsync( - async () => { - // Force at least one microtask before throwing so this exercises - // the rejection branch of the catch (vs. the synchronous-throw - // branch covered by the next test). - await Promise.resolve(); - throw thrown; - }, - (e) => new TestError(`mapped:${(e as Error).message}`), - ); - expect(result.isErr()).toBe(true); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(TestError); - expect(result.error.tag).toBe("mapped:kaboom"); + const result = await _internal_makeAsyncResult(async () => { + // Force at least one microtask before throwing so this exercises the + // rejection branch (vs. the synchronous-throw branch covered below). + await Promise.resolve(); + throw thrown; + }); + expect(isDefect(result)).toBe(true); + if (isDefect(result)) { + expect(result.cause).toBe(thrown); } }); - it("routes a synchronous throw before the first await through mapRejection", async () => { - // A non-async wrapper that throws *before* returning a promise. This - // exercises the outer try/catch in the helper — without it, calling - // `work()` would throw synchronously and the consumer would see the - // exception bubble out of the helper rather than land on err(...). + it("routes a synchronous throw before the first await through the defect channel", async () => { + // A non-async wrapper that throws *before* returning a promise — + // `fromSafePromise` invokes the thunk and captures the synchronous throw as + // a defect rather than letting it bubble out of the helper. const thrown = new Error("sync-blow-up"); - const result = await _internal_makeResultAsync( - () => { - throw thrown; - }, - (e) => new TestError(`mapped:${(e as Error).message}`), - ); - expect(result.isErr()).toBe(true); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(TestError); - expect(result.error.tag).toBe("mapped:sync-blow-up"); + const result = await _internal_makeAsyncResult(() => { + throw thrown; + }); + expect(isDefect(result)).toBe(true); + if (isDefect(result)) { + expect(result.cause).toBe(thrown); } }); - it("forwards the thrown value to mapRejection so callers can introspect it", async () => { + it("preserves a non-Error thrown value on the defect's cause", async () => { + // Throwing a non-Error is legal in JS — the defect must carry the raw + // thrown value untouched. const thrown = { kind: "non-error-throwable" }; - let captured: unknown; - const result = await _internal_makeResultAsync( - async () => { - // Throwing a non-Error is legal in JS — the helper must not assume - // the value has `.message` and the mapper sees it untouched. - await Promise.resolve(); - throw thrown; - }, - (e) => { - captured = e; - return new TestError("mapped"); - }, - ); - expect(captured).toBe(thrown); - expect(result.isErr()).toBe(true); + const result = await _internal_makeAsyncResult(async () => { + await Promise.resolve(); + throw thrown; + }); + expect(isDefect(result)).toBe(true); + if (isDefect(result)) { + expect(result.cause).toBe(thrown); + } }); }); diff --git a/packages/contract/src/result-async.ts b/packages/contract/src/result-async.ts index 9585b1a1..0710963e 100644 --- a/packages/contract/src/result-async.ts +++ b/packages/contract/src/result-async.ts @@ -1,44 +1,37 @@ /** * Internal helper shared across `@temporal-contract/client` and * `@temporal-contract/worker` for wrapping a result-producing async function - * in a `ResultAsync` while routing any unhandled rejection through a typed - * mapper. + * in an `AsyncResult`, routing any unanticipated rejection through unthrown's + * `defect` channel. * * Lives in `@temporal-contract/contract` so the two consuming packages don't * each carry their own copy. Exported from the package's public surface - * under a deliberately-internal-looking name (`_internal_makeResultAsync`) + * under a deliberately-internal-looking name (`_internal_makeAsyncResult`) * so users don't import it by accident — there is no semver guarantee on * this entry point. */ -import { ResultAsync, type Result, err } from "neverthrow"; +import { fromSafePromise, type AsyncResult, type Result } from "unthrown"; /** - * Wrap an async function returning `Promise>` in a - * `ResultAsync`, catching synchronous throws and rejected promises - * and routing them through `mapRejection` so they surface as typed - * `err(...)` instead of unhandled rejections. + * Wrap an async function returning `Promise>` in an + * `AsyncResult`, catching synchronous throws and rejected promises and + * routing them through unthrown's `defect` channel — so an *unanticipated* + * failure surfaces as a defect (a bug, re-thrown at the edge) rather than an + * unhandled rejection, while the work function's own domain `err(...)` flows + * through untouched. * - * `new ResultAsync(promise)` does **not** catch rejections — the resulting - * `ResultAsync` rejects, escaping neverthrow's railway. This helper closes - * that gap. The work function is expected to handle its own domain errors - * and return `err(...)` for them; `mapRejection` is a safety net for - * thrown exceptions the work didn't anticipate. + * `fromSafePromise(thunk)` invokes the thunk, capturing both a synchronous + * throw before the promise is produced and an eventual rejection as a `defect` + * (its error channel is `never`) — the work function is expected to model its + * own domain errors as `err(...)`, so any *thrown* failure is by definition + * unmodeled. The `.flatMap((inner) => inner)` flattens the nested + * `Result` the thunk resolves with, surfacing its modeled error channel. * - * @internal — exported under `_internal_makeResultAsync` for use by the + * @internal — exported under `_internal_makeAsyncResult` for use by the * sibling client and worker packages. Not part of the public API. */ -export function _internal_makeResultAsync( +export function _internal_makeAsyncResult( work: () => Promise>, - mapRejection: (error: unknown) => E, -): ResultAsync { - let promise: Promise>; - try { - promise = work(); - } catch (error) { - // Synchronous throw before the function returned its promise. Without - // this branch, `work()` blowing up synchronously would surface as a - // thrown error from the constructor call rather than an `err(...)`. - promise = Promise.resolve(err(mapRejection(error))); - } - return new ResultAsync(promise.catch((e: unknown) => err(mapRejection(e)))); +): AsyncResult { + return fromSafePromise(work).flatMap((inner) => inner); } diff --git a/packages/worker/README.md b/packages/worker/README.md index d24b9cc7..3c2833ce 100644 --- a/packages/worker/README.md +++ b/packages/worker/README.md @@ -15,13 +15,13 @@ pnpm add @temporal-contract/worker @temporal-contract/contract @temporalio/workf ```typescript // activities.ts import { declareActivitiesHandler, ApplicationFailure } from "@temporal-contract/worker/activity"; -import { ResultAsync } from "neverthrow"; +import { fromPromise } from "unthrown"; export const activities = declareActivitiesHandler({ contract: myContract, activities: { sendEmail: ({ to, body }) => - ResultAsync.fromPromise(emailService.send({ to, body }), (error) => + fromPromise(emailService.send({ to, body }), (error) => ApplicationFailure.create({ type: "EMAIL_FAILED", message: error instanceof Error ? error.message : "Failed to send email", @@ -65,7 +65,7 @@ run().catch(console.error); ### Child Workflows -Execute child workflows with type-safe `ResultAsync`. Supports both same-contract and cross-contract child workflows: +Execute child workflows with type-safe `AsyncResult`. Supports both same-contract and cross-contract child workflows: ```typescript // workflows.ts diff --git a/packages/worker/package.json b/packages/worker/package.json index 3e105561..d05cc097 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -1,13 +1,13 @@ { "name": "@temporal-contract/worker", "version": "2.4.0", - "description": "Worker utilities with neverthrow Result/ResultAsync for implementing temporal-contract workflows and activities", + "description": "Worker utilities with unthrown Result/AsyncResult for implementing temporal-contract workflows and activities", "keywords": [ "contract", - "neverthrow", "result", "temporal", "typescript", + "unthrown", "worker" ], "homepage": "https://github.com/btravstack/temporal-contract#readme", @@ -82,11 +82,11 @@ "@temporalio/workflow": "catalog:", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", - "neverthrow": "catalog:", "tsdown": "catalog:", "typedoc": "catalog:", "typedoc-plugin-markdown": "catalog:", "typescript": "catalog:", + "unthrown": "catalog:", "vitest": "catalog:", "zod": "catalog:" }, @@ -94,7 +94,7 @@ "@temporalio/common": "^1", "@temporalio/worker": "^1", "@temporalio/workflow": "^1", - "neverthrow": "^8" + "unthrown": "^0.1" }, "engines": { "node": ">=22.19.0" diff --git a/packages/worker/src/__tests__/test.workflows.ts b/packages/worker/src/__tests__/test.workflows.ts index c870ab9c..98cfba5e 100644 --- a/packages/worker/src/__tests__/test.workflows.ts +++ b/packages/worker/src/__tests__/test.workflows.ts @@ -1,3 +1,4 @@ +import { isOk, isErr } from "unthrown"; import { testContract } from "./test.contract.js"; import { declareWorkflow } from "../workflow.js"; import { sleep } from "@temporalio/workflow"; @@ -114,10 +115,12 @@ export const parentWorkflow = declareWorkflow({ args: { id: i }, }); - if (childResult.isOk()) { + if (isOk(childResult)) { results.push(childResult.value.message); - } else { + } else if (isErr(childResult)) { results.push(`Error: ${childResult.error.message}`); + } else { + results.push(`Defect: ${String(childResult.cause)}`); } } diff --git a/packages/worker/src/__tests__/worker.spec.ts b/packages/worker/src/__tests__/worker.spec.ts index 31a17f34..7fec438f 100644 --- a/packages/worker/src/__tests__/worker.spec.ts +++ b/packages/worker/src/__tests__/worker.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, vi, beforeEach } from "vitest"; import { Worker } from "@temporalio/worker"; import { TypedClient, WorkflowValidationError } from "@temporal-contract/client"; import { it as baseIt } from "@temporal-contract/testing/extension"; -import { okAsync, errAsync } from "neverthrow"; +import { ok, err, isOk, isErr, type AsyncResult } from "unthrown"; import { extname } from "node:path"; import { fileURLToPath } from "node:url"; import { testContract } from "./test.contract.js"; @@ -10,6 +10,10 @@ import { Client, WorkflowFailedError } from "@temporalio/client"; import { ApplicationFailure, declareActivitiesHandler } from "../activity.js"; import { createWorker } from "../worker.js"; +// unthrown has no `okAsync`/`errAsync`; lift a sync `Result` with `.toAsync()`. +const okAsync = (value: T): AsyncResult => ok(value).toAsync(); +const errAsync = (error: E): AsyncResult => err(error).toAsync(); + // ============================================================================ // Test Setup // ============================================================================ @@ -131,8 +135,8 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: test-data", }); @@ -152,15 +156,15 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; expect(handle.workflowId).toBe(workflowId); const result = await handle.result(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: async-test", }); @@ -181,13 +185,13 @@ describe("Worker Package - Integration Tests", () => { const handleResult = await client.getHandle("simpleWorkflow", workflowId); // THEN - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; const result = await handle.result(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: get-handle-test", }); @@ -210,8 +214,8 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ orderId: "ORD-123", status: "success", @@ -239,8 +243,8 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ orderId: "INVALID-123", status: "failed", @@ -263,8 +267,8 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ orderId: "ORD-456", status: "failed", @@ -288,8 +292,8 @@ describe("Worker Package - Integration Tests", () => { args: invalidInput, }); - expect(execution.isErr()).toBe(true); - if (execution.isErr()) { + expect(isErr(execution)).toBe(true); + if (isErr(execution)) { expect(execution.error).toBeInstanceOf(WorkflowValidationError); } }); @@ -311,7 +315,7 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - Should succeed with proper validation - expect(result.isOk()).toBe(true); + expect(isOk(result)).toBe(true); }); }); @@ -367,8 +371,8 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ results: ["Child 0 completed", "Child 1 completed", "Child 2 completed"], }); @@ -394,8 +398,8 @@ describe("Worker Package - Integration Tests", () => { args: { initialValue: 10 }, }); - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN - Send signals to increment value @@ -404,8 +408,8 @@ describe("Worker Package - Integration Tests", () => { // THEN - Workflow should complete with updated value const result = await handle.result(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ finalValue: 18, // 10 + 5 + 3 }); @@ -420,16 +424,16 @@ describe("Worker Package - Integration Tests", () => { args: { initialValue: 42 }, }); - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN - Query the current value const queryResult = await handle.queries.getCurrentValue({}); // THEN - Should return current value - expect(queryResult.isOk()).toBe(true); - if (queryResult.isOk()) { + expect(isOk(queryResult)).toBe(true); + if (isOk(queryResult)) { expect(queryResult.value).toEqual({ value: 42, }); @@ -447,16 +451,16 @@ describe("Worker Package - Integration Tests", () => { args: { initialValue: 5 }, }); - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN - Send update to multiply value const updateResult = await handle.updates.multiply({ factor: 3 }); // THEN - Update should return the new value - expect(updateResult.isOk()).toBe(true); - if (updateResult.isOk()) { + expect(isOk(updateResult)).toBe(true); + if (isOk(updateResult)) { expect(updateResult.value).toEqual({ newValue: 15, // 5 * 3 }); @@ -464,8 +468,8 @@ describe("Worker Package - Integration Tests", () => { // Workflow should complete with the multiplied value const result = await handle.result(); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toEqual({ finalValue: 15, }); @@ -482,16 +486,16 @@ describe("Worker Package - Integration Tests", () => { args: { value: "describe-me" }, }); - expect(handleResult.isOk()).toBe(true); - if (!handleResult.isOk()) throw new Error("Expected Ok result"); + expect(isOk(handleResult)).toBe(true); + if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN const describeResult = await handle.describe(); // THEN - expect(describeResult.isOk()).toBe(true); - if (describeResult.isOk()) { + expect(isOk(describeResult)).toBe(true); + if (isOk(describeResult)) { expect(describeResult.value).toEqual( expect.objectContaining({ workflowId, @@ -519,8 +523,8 @@ describe("Worker Package - Integration Tests", () => { // THEN — at the workflow boundary Temporal wraps the activity's // ApplicationFailure in an ActivityFailure (cause is the original // ApplicationFailure with the type/message/details preserved). - expect(result.isErr()).toBe(true); - if (result.isOk()) throw new Error("Expected error result"); + expect(isErr(result)).toBe(true); + if (!isErr(result)) throw new Error("Expected error result"); const error = result.error; expect(error.message).toMatch(/failableActivity failed/); // Inner cause carries the ApplicationFailure from the activity. diff --git a/packages/worker/src/activity.spec.ts b/packages/worker/src/activity.spec.ts index f7058809..301a3b9c 100644 --- a/packages/worker/src/activity.spec.ts +++ b/packages/worker/src/activity.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ResultAsync, okAsync, errAsync } from "neverthrow"; +import { ok, err, fromSafePromise, type AsyncResult } from "unthrown"; import { z } from "zod"; import { ActivityDefinitionNotFoundError, @@ -9,7 +9,11 @@ import { import type { ContractDefinition } from "@temporal-contract/contract"; import { ApplicationFailure, declareActivitiesHandler } from "./activity.js"; -describe("Worker neverthrow Package", () => { +// unthrown has no `okAsync`/`errAsync`; lift a sync `Result` with `.toAsync()`. +const okAsync = (value: T): AsyncResult => ok(value).toAsync(); +const errAsync = (error: E): AsyncResult => err(error).toAsync(); + +describe("Worker unthrown Package", () => { describe("declareActivitiesHandler", () => { it("should create an activities handler with Result pattern", () => { // GIVEN @@ -112,7 +116,7 @@ describe("Worker neverthrow Package", () => { activities: { fetchData: ( _args, - ): ResultAsync<{ data: string; timestamp: number }, ApplicationFailure> => + ): AsyncResult<{ data: string; timestamp: number }, ApplicationFailure> => // @ts-expect-error - intentionally returning invalid output okAsync({ data: "test" }), // Missing timestamp }, @@ -243,7 +247,7 @@ describe("Worker neverthrow Package", () => { contract, activities: { asyncActivity: (args) => - ResultAsync.fromSafePromise<{ completed: boolean }, ApplicationFailure>( + fromSafePromise<{ completed: boolean }>( new Promise((resolve) => { setTimeout(() => resolve({ completed: true }), args.delay); }), diff --git a/packages/worker/src/activity.ts b/packages/worker/src/activity.ts index d5aef4e0..d807ee6f 100644 --- a/packages/worker/src/activity.ts +++ b/packages/worker/src/activity.ts @@ -1,8 +1,8 @@ // Entry point for activity implementations. // -// Activities run *outside* the workflow sandbox, so they use neverthrow's -// `ResultAsync` directly. Workflow code (see workflow.ts) uses the same -// neverthrow API — neverthrow's evaluation is compatible with Temporal's +// Activities run *outside* the workflow sandbox, so they use unthrown's +// `AsyncResult` directly. Workflow code (see workflow.ts) uses the same +// unthrown API — unthrown's evaluation is compatible with Temporal's // deterministic replay machinery. // // Errors flow through Temporal's `ApplicationFailure` (re-exported from @@ -11,7 +11,7 @@ // `nonRetryable`, `type`, `details`, and `category` natively, and survives // the activity → workflow serialization boundary unchanged. import { ActivityDefinition, ContractDefinition } from "@temporal-contract/contract"; -import type { ResultAsync } from "neverthrow"; +import type { AsyncResult } from "unthrown"; import { ApplicationFailure } from "@temporalio/common"; import { WorkerInferInput, WorkerInferOutput } from "./types.js"; import { @@ -33,17 +33,18 @@ export { export { ApplicationFailure } from "@temporalio/common"; /** - * Activity implementation using neverthrow's `ResultAsync`. + * Activity implementation using unthrown's `AsyncResult`. * - * Returns `ResultAsync` for explicit error + * Returns `AsyncResult` for explicit error * handling instead of throwing. The wrapper rethrows `err()` payloads at * the activity boundary; Temporal recognizes `ApplicationFailure` natively * and applies the configured retry policy (with `nonRetryable: true` - * opting an instance out per-call). + * opting an instance out per-call). An unexpected throw surfaces as a + * `defect` and is re-thrown with its original cause. */ type ResultActivityImplementation = ( args: WorkerInferInput, -) => ResultAsync, ApplicationFailure>; +) => AsyncResult, ApplicationFailure>; /** * Map of all activity implementations for a contract (global + all workflow-specific). @@ -139,7 +140,7 @@ export type ActivitiesHandler = * * This wraps all activity implementations with: * - Validation at network boundaries - * - `ResultAsync` pattern for explicit error handling + * - `AsyncResult` pattern for explicit error handling * - Automatic conversion from Result to Promise (throwing on Error) * * TypeScript ensures ALL activities (global + workflow-specific) are implemented. @@ -149,15 +150,15 @@ export type ActivitiesHandler = * @example * ```ts * import { declareActivitiesHandler, ApplicationFailure } from '@temporal-contract/worker/activity'; - * import { ResultAsync, errAsync, okAsync } from 'neverthrow'; + * import { fromPromise } from 'unthrown'; * import myContract from './contract'; * * export const activities = declareActivitiesHandler({ * contract: myContract, * activities: { - * // Activity returns ResultAsync instead of throwing. + * // Activity returns AsyncResult instead of throwing. * sendEmail: (args) => - * ResultAsync.fromPromise( + * fromPromise( * emailService.send(args), * (error) => * // Wrap technical errors in ApplicationFailure. `nonRetryable` @@ -185,10 +186,11 @@ export type ActivitiesHandler = * * @remarks * The wrapper accepts implementations in the - * `ResultAsync` shape and produces ordinary + * `AsyncResult` shape and produces ordinary * Promise-returning Temporal handlers (`err(...)` → thrown * `ApplicationFailure`; `ok(...)` → output validated against the - * contract and resolved). It does **not** hide Temporal's + * contract and resolved; `defect` → original cause re-thrown). It does + * **not** hide Temporal's * `@temporalio/activity` runtime: inside the body you can still call * `Context.current()` from `@temporalio/activity` to access heartbeats * (`heartbeat(details)`, `heartbeatDetails`), activity info (attempt @@ -208,7 +210,7 @@ export function declareActivitiesHandler( function makeWrapped( activityName: string, activityDef: ActivityDefinition, - activityImpl: (args: unknown) => ResultAsync, + activityImpl: (args: unknown) => AsyncResult, ) { return async (...args: unknown[]) => { const input = extractHandlerInput(args); @@ -219,24 +221,31 @@ export function declareActivitiesHandler( throw new ActivityInputValidationError(activityName, inputResult.issues); } - // Execute neverthrow activity (returns ResultAsync); + // Execute unthrown activity (returns AsyncResult); // awaiting yields a Result. const result = await activityImpl(inputResult.value); - // Process result: validate output or throw error - if (result.isOk()) { - // Validate output on success - const outputResult = await activityDef.output["~standard"].validate(result.value); - if (outputResult.issues) { - throw new ActivityOutputValidationError(activityName, outputResult.issues); - } - return outputResult.value; - } else { + // Fold the three channels: validate output on `ok`, surface the modeled + // `ApplicationFailure` on `err`, and re-throw a `defect`'s original cause + // (an unexpected throw inside the activity is a bug, not a domain error). + return result.match({ + ok: async (value) => { + const outputResult = await activityDef.output["~standard"].validate(value); + if (outputResult.issues) { + throw new ActivityOutputValidationError(activityName, outputResult.issues); + } + return outputResult.value; + }, // Convert err(...) payload to thrown ApplicationFailure for Temporal. - // Temporal recognizes this class natively and applies the - // configured retry policy (honoring `nonRetryable: true`). - throw result.error; - } + // Temporal recognizes this class natively and applies the configured + // retry policy (honoring `nonRetryable: true`). + err: (error) => { + throw error; + }, + defect: (cause) => { + throw cause; + }, + }); }; } @@ -257,7 +266,7 @@ export function declareActivitiesHandler( (wrappedActivities as Record)[activityName] = makeWrapped( activityName, activityDef, - impl as (args: unknown) => ResultAsync, + impl as (args: unknown) => AsyncResult, ); } } @@ -288,7 +297,7 @@ export function declareActivitiesHandler( (wrappedActivities as Record)[activityName] = makeWrapped( `${workflowName}.${activityName}`, activityDef, - impl as (args: unknown) => ResultAsync, + impl as (args: unknown) => AsyncResult, ); } } diff --git a/packages/worker/src/cancellation.spec.ts b/packages/worker/src/cancellation.spec.ts index 8d72770e..af71bde9 100644 --- a/packages/worker/src/cancellation.spec.ts +++ b/packages/worker/src/cancellation.spec.ts @@ -4,22 +4,23 @@ * * Mocks `@temporalio/workflow` so the helpers can be exercised outside a * real workflow context. Asserts that: - * - successful resolution surfaces as `Result.Ok`, - * - cancellation surfaces as `Result.Error(WorkflowCancelledError)` + * - successful resolution surfaces as `ok`, + * - cancellation surfaces as `err(WorkflowCancelledError)` * (matched via the mocked `isCancellation` predicate), - * - non-cancellation errors surface as `Result.Error(WorkflowScopeError)` - * with the original error preserved on `cause` — closing the prior - * unhandled-rejection gap where `throw error` inside the helper became a - * raw `ResultAsync` rejection (`new ResultAsync(promise)` does not catch), - * - synchronous throws thrown before the first `await` of the work - * function are caught and routed through the same `WorkflowScopeError` - * channel rather than escaping as an unhandled rejection, + * - non-cancellation errors are *unmodeled* failures and surface on the + * `defect` channel with the original error on `cause` — they no longer ride + * the modeled error channel, since a thrown non-cancellation error is a bug + * rather than an anticipated domain outcome, + * - synchronous throws thrown before the first `await` of the work function + * are likewise captured as defects rather than escaping as an unhandled + * rejection, * - the helpers route through `CancellationScope.cancellable` / * `CancellationScope.nonCancellable` respectively. * * Closes #183. */ import { describe, expect, it, vi } from "vitest"; +import { isOk, isErr, isDefect } from "unthrown"; import { z } from "zod"; import { defineContract, defineWorkflow } from "@temporal-contract/contract"; @@ -39,13 +40,13 @@ vi.mock("@temporalio/workflow", () => ({ const { CancellationScope } = await import("@temporalio/workflow"); const { cancellableScope, nonCancellableScope } = await import("./cancellation.js"); const { declareWorkflow } = await import("./workflow.js"); -const { WorkflowCancelledError, WorkflowScopeError } = await import("./errors.js"); +const { WorkflowCancelledError } = await import("./errors.js"); describe("cancellableScope", () => { it("returns Result.Ok with the resolved value on success", async () => { const result = await cancellableScope(async () => 42); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toBe(42); } }); @@ -60,46 +61,38 @@ describe("cancellableScope", () => { const result = await cancellableScope(async () => { throw new Error(CANCEL_MARKER); }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowCancelledError); // Cause is preserved so debug tooling can see the underlying failure. expect((result.error.cause as Error).message).toBe(CANCEL_MARKER); } }); - it("returns Result.Error(WorkflowScopeError) when a non-cancellation error is thrown", async () => { - // Previously these errors were re-thrown out of the helper, becoming a - // ResultAsync rejection that escaped neverthrow's railway as an unhandled - // rejection. The helper now routes them through the typed err(...) channel - // alongside WorkflowCancelledError so result.match(...) is exhaustive. + it("routes a non-cancellation error to the defect channel", async () => { + // A thrown non-cancellation error is an *unmodeled* failure: the helper + // re-throws it so the `makeResultAsync` boundary captures it as a defect, + // with the original error on `cause`, rather than a typed err(...). const original = new Error("activity exploded"); const result = await cancellableScope(async () => { throw original; }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(WorkflowScopeError); - expect(result.error).not.toBeInstanceOf(WorkflowCancelledError); - // Original error preserved on `cause` so callers can introspect it. - expect(result.error.cause).toBe(original); + expect(isDefect(result)).toBe(true); + if (isDefect(result)) { + expect(result.cause).toBe(original); } }); - it("catches synchronous throws from the work body as Result.Error(WorkflowScopeError)", async () => { - // The wrapper's safety net: if `fn` throws *before* its first await, - // `new ResultAsync(work())` would surface that as an unhandled rejection. - // makeResultAsync's catch funnels it back into the typed err(...) channel. + it("captures synchronous throws from the work body as a defect", async () => { + // If `fn` throws *before* its first await, `fromPromise`'s thunk form still + // captures it — as a defect carrying the original cause. const original = new Error("sync explosion"); const result = await cancellableScope(() => { throw original; }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(WorkflowScopeError); - // The CancellationScope mock awaits fn(), so the throw is caught inside - // the inner try/catch and lands here with the original error on `cause`. - expect(result.error.cause).toBe(original); + expect(isDefect(result)).toBe(true); + if (isDefect(result)) { + expect(result.cause).toBe(original); } }); }); @@ -107,8 +100,8 @@ describe("cancellableScope", () => { describe("nonCancellableScope", () => { it("returns Result.Ok with the resolved value on success", async () => { const result = await nonCancellableScope(async () => "released"); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toBe("released"); } }); @@ -126,34 +119,31 @@ describe("nonCancellableScope", () => { const result = await nonCancellableScope(async () => { throw new Error(CANCEL_MARKER); }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { + expect(isErr(result)).toBe(true); + if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowCancelledError); } }); - it("returns Result.Error(WorkflowScopeError) when a non-cancellation error is thrown", async () => { + it("routes a non-cancellation error to the defect channel", async () => { const original = new Error("cleanup failure"); const result = await nonCancellableScope(async () => { throw original; }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(WorkflowScopeError); - expect(result.error).not.toBeInstanceOf(WorkflowCancelledError); - expect(result.error.cause).toBe(original); + expect(isDefect(result)).toBe(true); + if (isDefect(result)) { + expect(result.cause).toBe(original); } }); - it("catches synchronous throws from the work body as Result.Error(WorkflowScopeError)", async () => { + it("captures synchronous throws from the work body as a defect", async () => { const original = new Error("sync cleanup explosion"); const result = await nonCancellableScope(() => { throw original; }); - expect(result.isErr()).toBe(true); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(WorkflowScopeError); - expect(result.error.cause).toBe(original); + expect(isDefect(result)).toBe(true); + if (isDefect(result)) { + expect(result.cause).toBe(original); } }); }); @@ -164,16 +154,16 @@ describe("scope helpers accept synchronous callbacks", () => { // so workflows mutating purely-local state don't have to write `async () =>`. it("cancellableScope wraps a sync return as Result.Ok", async () => { const result = await cancellableScope(() => "sync-ok"); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toBe("sync-ok"); } }); it("nonCancellableScope wraps a sync return as Result.Ok", async () => { const result = await nonCancellableScope(() => 7); - expect(result.isOk()).toBe(true); - if (result.isOk()) { + expect(isOk(result)).toBe(true); + if (isOk(result)) { expect(result.value).toBe(7); } }); diff --git a/packages/worker/src/cancellation.ts b/packages/worker/src/cancellation.ts index 546ddb7e..ba229bdc 100644 --- a/packages/worker/src/cancellation.ts +++ b/packages/worker/src/cancellation.ts @@ -2,32 +2,31 @@ * Typed wrappers around Temporal's `CancellationScope` so workflows can * opt into cancellation control without reaching for * `@temporalio/workflow` directly. The wrappers fold cancellation into - * the same `ResultAsync<...>` shape used elsewhere in the worker + * the same `AsyncResult<...>` shape used elsewhere in the worker * context — callers branch on `err(WorkflowCancelledError)` instead of * catching `CancelledFailure`. * - * Non-cancellation errors thrown inside the scope are wrapped in a - * {@link WorkflowScopeError} (with the original error preserved on - * `cause`) and surfaced on the same `err(...)` channel. Together with - * `WorkflowCancelledError` this makes the failure modes exhaustive on - * `result.match(...)` — nothing escapes as an unhandled rejection. + * Non-cancellation errors thrown inside the scope are *unmodeled* failures: + * they ride unthrown's `defect` channel (re-thrown at the edge / inspectable + * via `result.isDefect()` and `result.cause`) rather than a typed `err(...)`, + * keeping the modeled error channel to the single anticipated outcome — + * cancellation. */ import { CancellationScope, isCancellation } from "@temporalio/workflow"; -import { type ResultAsync, type Result, ok, err } from "neverthrow"; -import { WorkflowCancelledError, WorkflowScopeError } from "./errors.js"; +import { type AsyncResult, type Result, ok, err } from "unthrown"; +import { WorkflowCancelledError } from "./errors.js"; import { makeResultAsync } from "./internal.js"; /** * Run `fn` inside a cancellable Temporal scope. If the workflow (or an * ancestor scope) is cancelled while the function is in flight, the - * resulting ResultAsync resolves to `err(WorkflowCancelledError)`, + * resulting AsyncResult resolves to `err(WorkflowCancelledError)`, * letting callers handle cancellation explicitly — typically to perform * a graceful exit from the current step. * - * Non-cancellation errors thrown by `fn` resolve to - * `err(WorkflowScopeError)` (with the original error on `cause`) so - * domain failures surface on the same typed error channel rather than - * leaking as unhandled rejections. + * Non-cancellation errors thrown by `fn` are unmodeled failures: they surface + * on the `defect` channel rather than as a typed `err(...)`, so a genuine bug + * is not silently treated as an anticipated domain outcome. * * @example * ```ts @@ -35,22 +34,21 @@ import { makeResultAsync } from "./internal.js"; * return await context.activities.processStep(...); * }); * - * result.match( - * (output) => { ... }, - * (error) => { - * if (error instanceof WorkflowCancelledError) { - * // graceful exit - * } else { - * // error instanceof WorkflowScopeError — domain failure on `cause` - * } + * result.match({ + * ok: (output) => { ... }, + * err: (error) => { + * // error instanceof WorkflowCancelledError — graceful exit * }, - * ); + * defect: (cause) => { + * // a non-cancellation failure thrown inside the scope (a bug) + * }, + * }); * ``` */ export function cancellableScope( fn: () => T | Promise, -): ResultAsync { - const work = async (): Promise> => { +): AsyncResult { + const work = async (): Promise> => { try { // Wrap so synchronous returns satisfy CancellationScope.cancellable's // `() => Promise` signature without forcing every caller to write @@ -61,15 +59,12 @@ export function cancellableScope( if (isCancellation(error)) { return err(new WorkflowCancelledError(error)); } - return err(new WorkflowScopeError(error)); + // Non-cancellation throw → re-throw so `makeResultAsync`'s boundary + // routes it through the `defect` channel as an unmodeled failure. + throw error; } }; - // makeResultAsync is the shared safety net from `@temporal-contract/contract` - // — `new ResultAsync(work())` does not catch, so a synchronous throw inside - // `work` (e.g. a buggy refactor) would escape neverthrow's railway. The - // catch-all here funnels that back into `err(WorkflowScopeError)` for - // parity with the in-band path above. - return makeResultAsync(work, (e) => new WorkflowScopeError(e)); + return makeResultAsync(work); } /** @@ -78,10 +73,10 @@ export function cancellableScope( * to perform cleanup that must not be interrupted (e.g. releasing a * resource after a graceful shutdown). * - * Mirrors `cancellableScope`'s `ResultAsync<...>` shape for symmetry; the + * Mirrors `cancellableScope`'s `AsyncResult<...>` shape for symmetry; the * `err(WorkflowCancelledError)` branch only triggers when cancellation is - * raised from inside the scope (rare). Non-cancellation errors surface as - * `err(WorkflowScopeError)`. + * raised from inside the scope (rare). Non-cancellation errors surface on the + * `defect` channel. * * @example * ```ts @@ -92,8 +87,8 @@ export function cancellableScope( */ export function nonCancellableScope( fn: () => T | Promise, -): ResultAsync { - const work = async (): Promise> => { +): AsyncResult { + const work = async (): Promise> => { try { const value = await CancellationScope.nonCancellable(async () => fn()); return ok(value); @@ -101,8 +96,8 @@ export function nonCancellableScope( if (isCancellation(error)) { return err(new WorkflowCancelledError(error)); } - return err(new WorkflowScopeError(error)); + throw error; } }; - return makeResultAsync(work, (e) => new WorkflowScopeError(e)); + return makeResultAsync(work); } diff --git a/packages/worker/src/child-workflow.ts b/packages/worker/src/child-workflow.ts index 5ef4f06b..8f08ab77 100644 --- a/packages/worker/src/child-workflow.ts +++ b/packages/worker/src/child-workflow.ts @@ -10,7 +10,7 @@ import { executeChild, startChild, } from "@temporalio/workflow"; -import { ResultAsync, type Result, ok, err } from "neverthrow"; +import { type AsyncResult, type Result, ok, err, isOk, isErr } from "unthrown"; import { ChildWorkflowCancelledError, ChildWorkflowError, @@ -36,13 +36,13 @@ export type TypedChildWorkflowOptions< }; /** - * Typed handle for a child workflow with neverthrow `ResultAsync` pattern. + * Typed handle for a child workflow with unthrown `AsyncResult` pattern. */ export type TypedChildWorkflowHandle = { /** - * Get child workflow result with `ResultAsync` pattern. + * Get child workflow result with `AsyncResult` pattern. */ - result: () => ResultAsync< + result: () => AsyncResult< ClientInferOutput, ChildWorkflowError | ChildWorkflowCancelledError >; @@ -83,7 +83,7 @@ async function getAndValidateChildWorkflow< validatedInput: WorkerInferInput; taskQueue: string; }, - ChildWorkflowError + ChildWorkflowError | ChildWorkflowNotFoundError > > { const childDefinition = childContract.workflows[childWorkflowName]; @@ -125,7 +125,7 @@ function createTypedChildHandle( ): TypedChildWorkflowHandle { return { workflowId: handle.workflowId, - result: (): ResultAsync< + result: (): AsyncResult< ClientInferOutput, ChildWorkflowError | ChildWorkflowCancelledError > => { @@ -139,14 +139,7 @@ function createTypedChildHandle( return err(classifyChildWorkflowError("result", error, childWorkflowName)); } }; - return makeResultAsync( - work, - (error) => - new ChildWorkflowError( - `Child workflow execution failed: ${error instanceof Error ? error.message : String(error)}`, - error, - ), - ); + return makeResultAsync(work); }, }; } @@ -158,7 +151,7 @@ export function createStartChildWorkflow< childContract: TChildContract, childWorkflowName: TChildWorkflowName, options: TypedChildWorkflowOptions, -): ResultAsync< +): AsyncResult< TypedChildWorkflowHandle, ChildWorkflowError | ChildWorkflowCancelledError | ChildWorkflowNotFoundError > { @@ -172,9 +165,14 @@ export function createStartChildWorkflow< options.args, ); - if (validationResult.isErr()) { + if (isErr(validationResult)) { return err(validationResult.error); } + // `getAndValidateChildWorkflow` only ever builds ok/err; a defect would be + // a genuine bug — re-throw so it rides the defect channel. + if (!isOk(validationResult)) { + throw validationResult.cause; + } const { definition: childDefinition, validatedInput, taskQueue } = validationResult.value; @@ -193,14 +191,7 @@ export function createStartChildWorkflow< return err(classifyChildWorkflowError("startChild", error, String(childWorkflowName))); } }; - return makeResultAsync( - work, - (error) => - new ChildWorkflowError( - `Failed to start child workflow: ${error instanceof Error ? error.message : String(error)}`, - error, - ), - ); + return makeResultAsync(work); } export function createExecuteChildWorkflow< @@ -210,7 +201,7 @@ export function createExecuteChildWorkflow< childContract: TChildContract, childWorkflowName: TChildWorkflowName, options: TypedChildWorkflowOptions, -): ResultAsync< +): AsyncResult< ClientInferOutput, ChildWorkflowError | ChildWorkflowCancelledError | ChildWorkflowNotFoundError > { @@ -224,9 +215,12 @@ export function createExecuteChildWorkflow< options.args, ); - if (validationResult.isErr()) { + if (isErr(validationResult)) { return err(validationResult.error); } + if (!isOk(validationResult)) { + throw validationResult.cause; + } const { definition: childDefinition, validatedInput, taskQueue } = validationResult.value; @@ -244,21 +238,17 @@ export function createExecuteChildWorkflow< childWorkflowName, ); - if (outputValidationResult.isErr()) { + if (isErr(outputValidationResult)) { return err(outputValidationResult.error); } + if (!isOk(outputValidationResult)) { + throw outputValidationResult.cause; + } return ok(outputValidationResult.value as Ok); } catch (error) { return err(classifyChildWorkflowError("executeChild", error, String(childWorkflowName))); } }; - return makeResultAsync( - work, - (error) => - new ChildWorkflowError( - `Failed to execute child workflow: ${error instanceof Error ? error.message : String(error)}`, - error, - ), - ); + return makeResultAsync(work); } diff --git a/packages/worker/src/errors.ts b/packages/worker/src/errors.ts index ac67881e..cfce39ff 100644 --- a/packages/worker/src/errors.ts +++ b/packages/worker/src/errors.ts @@ -1,20 +1,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { summarizeIssues } from "@temporal-contract/contract"; import { ApplicationFailure } from "@temporalio/common"; - -/** - * Base error class for worker errors - */ -abstract class WorkerError extends Error { - protected constructor(message: string, cause?: unknown) { - super(message, { cause }); - this.name = "WorkerError"; - // Maintains proper stack trace for where our error was thrown (only available on V8) - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor); - } - } -} +import { TaggedError } from "unthrown"; /** * Base class for the contract's runtime validation failures — workflow and @@ -74,16 +61,20 @@ export abstract class ValidationError extends ApplicationFailure { /** * Error thrown when an activity definition is not found in the contract */ -export class ActivityDefinitionNotFoundError extends WorkerError { - constructor( - public readonly activityName: string, - public readonly availableDefinitions: readonly string[] = [], - ) { +export class ActivityDefinitionNotFoundError extends TaggedError( + "ActivityDefinitionNotFoundError", +)<{ + activityName: string; + availableDefinitions: readonly string[]; + message: string; +}> { + constructor(activityName: string, availableDefinitions: readonly string[] = []) { const available = availableDefinitions.length > 0 ? availableDefinitions.join(", ") : "none"; - super( - `Activity definition not found for: "${activityName}". Available activities: ${available}`, - ); - this.name = "ActivityDefinitionNotFoundError"; + super({ + activityName, + availableDefinitions, + message: `Activity definition not found for: "${activityName}". Available activities: ${available}`, + }); } } @@ -243,14 +234,18 @@ export class UpdateOutputValidationError extends ValidationError { /** * Error thrown when a child workflow is not found in the contract */ -export class ChildWorkflowNotFoundError extends WorkerError { - constructor( - public readonly workflowName: string, - public readonly availableWorkflows: readonly string[] = [], - ) { +export class ChildWorkflowNotFoundError extends TaggedError("ChildWorkflowNotFoundError")<{ + workflowName: string; + availableWorkflows: readonly string[]; + message: string; +}> { + constructor(workflowName: string, availableWorkflows: readonly string[] = []) { const available = availableWorkflows.length > 0 ? availableWorkflows.join(", ") : "none"; - super(`Child workflow not found: "${workflowName}". Available workflows: ${available}`); - this.name = "ChildWorkflowNotFoundError"; + super({ + workflowName, + availableWorkflows, + message: `Child workflow not found: "${workflowName}". Available workflows: ${available}`, + }); } } @@ -263,92 +258,57 @@ export class ChildWorkflowNotFoundError extends WorkerError { * mirroring the client-side `WorkflowFailedError.cause` behavior, so callers * can branch on the failure category in one step instead of unwrapping twice. */ -export class ChildWorkflowError extends WorkerError { +export class ChildWorkflowError extends TaggedError("ChildWorkflowError")<{ + message: string; + cause?: unknown; +}> { constructor(message: string, cause?: unknown) { - super(message, cause); - this.name = "ChildWorkflowError"; + super({ message, cause }); } } /** - * Discriminated variant of {@link ChildWorkflowError} surfaced when a child - * workflow operation (start, execute, or wait-for-result) was cancelled — - * either because the parent workflow itself was cancelled, the child was - * explicitly cancelled, or its enclosing cancellation scope was. Detected via - * `@temporalio/workflow`'s `isCancellation(...)`, which sees through nested - * `ChildWorkflowFailure` / `CancelledFailure` chains. + * Discriminated variant surfaced when a child workflow operation (start, + * execute, or wait-for-result) was cancelled — either because the parent + * workflow itself was cancelled, the child was explicitly cancelled, or its + * enclosing cancellation scope was. Detected via `@temporalio/workflow`'s + * `isCancellation(...)`, which sees through nested `ChildWorkflowFailure` / + * `CancelledFailure` chains. * - * Extends {@link ChildWorkflowError} so existing `instanceof ChildWorkflowError` - * checks still match cancellation, while `instanceof ChildWorkflowCancelledError` - * lets call sites narrow further when they need to branch on cancellation - * explicitly without inspecting `error.cause` against a Temporal SDK class — - * the worker-side analogue of the client-side cause-forwarding pattern. + * A sibling of {@link ChildWorkflowError} rather than a subclass: both are + * distinct {@link TaggedError}s, so call sites discriminate on the `_tag` + * (or `instanceof ChildWorkflowCancelledError`) instead of relying on an + * `instanceof ChildWorkflowError` that also matches cancellation. `matchTags` + * folds the `ChildWorkflowError | ChildWorkflowCancelledError` union + * exhaustively. */ -export class ChildWorkflowCancelledError extends ChildWorkflowError { - constructor( - public readonly workflowName: string, - cause?: unknown, - ) { - super(`Child workflow "${workflowName}" was cancelled`, cause); - this.name = "ChildWorkflowCancelledError"; +export class ChildWorkflowCancelledError extends TaggedError("ChildWorkflowCancelledError")<{ + workflowName: string; + cause?: unknown; + message: string; +}> { + constructor(workflowName: string, cause?: unknown) { + super({ workflowName, cause, message: `Child workflow "${workflowName}" was cancelled` }); } } /** - * Error surfaced in the `err(...)` branch of a `ResultAsync` when a typed + * Error surfaced in the `err(...)` branch of an `AsyncResult` when a typed * cancellation scope is cancelled via Temporal's cancellation propagation. * Returned by both `context.cancellableScope` (when the workflow or an * ancestor scope cancels) and `context.nonCancellableScope` (when * cancellation is raised from inside the scope). Distinct from arbitrary * thrown errors so call sites can branch on cancellation explicitly. * - * Non-cancellation errors thrown inside a scope surface as a sibling - * {@link WorkflowScopeError} on the same `err(...)` channel, so callers can - * use `instanceof` to discriminate without falling back to `try/catch`. + * Non-cancellation errors thrown inside a scope are *unmodeled* failures: they + * surface on the scope's `defect` channel (re-thrown at the edge / inspectable + * via `result.isDefect()` and `result.cause`), not as a typed `err(...)`. */ -export class WorkflowCancelledError extends WorkerError { +export class WorkflowCancelledError extends TaggedError("WorkflowCancelledError")<{ + cause?: unknown; + message: string; +}> { constructor(cause?: unknown) { - super("Workflow cancellation scope was cancelled", cause); - this.name = "WorkflowCancelledError"; - } -} - -/** - * Error surfaced in the `err(...)` branch of a `ResultAsync` when the - * function passed to `cancellableScope` / `nonCancellableScope` throws a - * non-cancellation error. - * - * The original error is preserved on `cause` so call sites can introspect - * it without losing identity: - * - * @example - * ```ts - * const result = await context.cancellableScope(async () => { - * return await context.activities.processStep(args); - * }); - * - * if (result.isErr()) { - * if (result.error instanceof WorkflowCancelledError) { - * // graceful cancellation - * } else if (result.error instanceof WorkflowScopeError) { - * // domain error — `result.error.cause` is the original throwable - * } - * } - * ``` - * - * Introduced so the scope helpers route every failure through neverthrow's - * railway. Previously, non-cancellation errors were re-thrown out of the - * helper, which became a `ResultAsync` rejection (`new ResultAsync(promise)` - * does not catch) — they leaked as unhandled rejections rather than - * surfacing on the typed error channel callers actually inspect. - */ -export class WorkflowScopeError extends WorkerError { - constructor(cause: unknown) { - const message = - cause instanceof Error - ? `Workflow cancellation scope caught a non-cancellation error: ${cause.message}` - : "Workflow cancellation scope caught a non-cancellation error"; - super(message, cause); - this.name = "WorkflowScopeError"; + super({ cause, message: "Workflow cancellation scope was cancelled" }); } } diff --git a/packages/worker/src/internal.ts b/packages/worker/src/internal.ts index 99f55ac4..530a9755 100644 --- a/packages/worker/src/internal.ts +++ b/packages/worker/src/internal.ts @@ -17,7 +17,6 @@ import { import { ChildWorkflowCancelledError, ChildWorkflowError, - ChildWorkflowNotFoundError, WorkflowInputValidationError, } from "./errors.js"; @@ -35,13 +34,12 @@ export function formatChildWorkflowValidationMessage( return `Child workflow "${workflowName}" ${direction} validation failed: ${summarizeIssues(issues)}`; } -// Re-export the shared `_internal_makeResultAsync` helper from the contract +// Re-export the shared `_internal_makeAsyncResult` helper from the contract // package so worker call sites can wrap their `() => Promise>` -// work functions identically to the client side. This closes the -// `new ResultAsync(work())` gap — the bare constructor doesn't catch -// rejections, so a synchronous throw or a rejected promise from `work()` -// would otherwise escape neverthrow's railway as an unhandled rejection. -export { _internal_makeResultAsync as makeResultAsync } from "@temporal-contract/contract/result-async"; +// work functions identically to the client side. Unanticipated rejections +// (a synchronous throw or a rejected promise from `work()`) are routed through +// unthrown's `defect` channel rather than escaping as an unhandled rejection. +export { _internal_makeAsyncResult as makeResultAsync } from "@temporal-contract/contract/result-async"; /** * Extract the single payload from a Temporal handler's `...args` array. @@ -294,7 +292,7 @@ export function classifyChildWorkflowError( operation: "startChild" | "executeChild" | "result", error: unknown, childWorkflowName: string, -): ChildWorkflowError | ChildWorkflowCancelledError | ChildWorkflowNotFoundError { +): ChildWorkflowError | ChildWorkflowCancelledError { // Cancellation takes priority: a cancelled child surfaces as a // `ChildWorkflowFailure` whose cause is a `CancelledFailure`, and we want // the cancellation discriminant rather than the generic wrapper. diff --git a/packages/worker/src/workflow.ts b/packages/worker/src/workflow.ts index 1a217f10..7fa722ef 100644 --- a/packages/worker/src/workflow.ts +++ b/packages/worker/src/workflow.ts @@ -1,10 +1,10 @@ // Entry point for workflow implementations. // // Workflows run inside Temporal's deterministic sandbox, which intercepts -// timers, randomness, and Promise scheduling for replay. neverthrow's -// `Result`/`ResultAsync` rely only on Promise scheduling, so they replay +// timers, randomness, and Promise scheduling for replay. unthrown's +// `Result`/`AsyncResult` rely only on Promise scheduling, so they replay // deterministically alongside Temporal's machinery. Activity code (see -// activity.ts) uses the same neverthrow API. +// activity.ts) uses the same unthrown API. import type { ActivityDefinition, ContractDefinition, @@ -22,7 +22,6 @@ import { WorkflowCancelledError, WorkflowInputValidationError, WorkflowOutputValidationError, - WorkflowScopeError, } from "./errors.js"; import { cancellableScope, nonCancellableScope } from "./cancellation.js"; import { @@ -39,7 +38,7 @@ import { WorkerInferInput, WorkerInferOutput, } from "./types.js"; -import type { ResultAsync } from "neverthrow"; +import type { AsyncResult } from "unthrown"; import { buildRawActivitiesProxy, createContinueAsNew, @@ -73,7 +72,6 @@ export { WorkflowCancelledError, WorkflowInputValidationError, WorkflowOutputValidationError, - WorkflowScopeError, } from "./errors.js"; /** @@ -518,7 +516,7 @@ type WorkflowContext< ) => void; /** - * Start a child workflow and return a typed handle with ResultAsync pattern + * Start a child workflow and return a typed handle with AsyncResult pattern * * Supports both same-contract and cross-contract child workflows: * - Same contract: Pass workflowName from current contract @@ -538,13 +536,14 @@ type WorkflowContext< * args: { message: 'Hello' } * }); * - * childResult.match( - * async (handle) => { + * await childResult.match({ + * ok: async (handle) => { * const result = await handle.result(); * // ... handle result * }, - * (error) => console.error('Failed to start:', error), - * ); + * err: (error) => console.error('Failed to start:', error), + * defect: (cause) => console.error('Unexpected failure:', cause), + * }); * ``` */ startChildWorkflow: < @@ -554,13 +553,13 @@ type WorkflowContext< contract: TChildContract, workflowName: TChildWorkflowName, options: TypedChildWorkflowOptions, - ) => ResultAsync< + ) => AsyncResult< TypedChildWorkflowHandle, ChildWorkflowError | ChildWorkflowCancelledError | ChildWorkflowNotFoundError >; /** - * Execute a child workflow (start and wait for result) with ResultAsync pattern + * Execute a child workflow (start and wait for result) with AsyncResult pattern * * Supports both same-contract and cross-contract child workflows: * - Same contract: Pass workflowName from current contract @@ -580,10 +579,11 @@ type WorkflowContext< * args: { message: 'Hello' } * }); * - * result.match( - * (output) => console.log('Payment processed:', output), - * (error) => console.error('Processing failed:', error), - * ); + * await result.match({ + * ok: (output) => console.log('Payment processed:', output), + * err: (error) => console.error('Processing failed:', error), + * defect: (cause) => console.error('Unexpected failure:', cause), + * }); * ``` */ executeChildWorkflow: < @@ -593,7 +593,7 @@ type WorkflowContext< contract: TChildContract, workflowName: TChildWorkflowName, options: TypedChildWorkflowOptions, - ) => ResultAsync< + ) => AsyncResult< ClientInferOutput, ChildWorkflowError | ChildWorkflowCancelledError | ChildWorkflowNotFoundError >; @@ -601,57 +601,50 @@ type WorkflowContext< /** * Run `fn` inside a cancellable Temporal scope. If the workflow (or an * ancestor scope) is cancelled while `fn` is in flight, the resulting - * ResultAsync resolves to `err(WorkflowCancelledError)` instead of + * AsyncResult resolves to `err(WorkflowCancelledError)` instead of * rejecting — letting callers handle cancellation explicitly, typically * to perform a graceful exit from the current step. * - * Non-cancellation errors thrown by `fn` resolve to - * `err(WorkflowScopeError)` (with the original error preserved on - * `cause`). Both failure modes ride neverthrow's railway, so - * `result.match(...)` is exhaustive — nothing escapes as an unhandled - * rejection. + * Non-cancellation errors thrown by `fn` are *unmodeled* failures: they ride + * unthrown's `defect` channel (inspectable via `result.isDefect()` / + * `result.cause`, re-thrown at the edge), keeping the modeled error channel + * to the single anticipated outcome — cancellation. * * @example * ```ts + * import { isErr } from "unthrown"; + * * implementation: async (context, args) => { * const result = await context.cancellableScope(async () => { * return context.activities.processStep(args); * }); * - * if (result.isErr()) { - * if (result.error instanceof WorkflowCancelledError) { - * // workflow was cancelled — perform cleanup that must not be cancelled: - * await context.nonCancellableScope(async () => { - * await context.activities.releaseResources(args); - * }); - * return { status: "cancelled" }; - * } - * // result.error instanceof WorkflowScopeError — domain failure - * return { status: "failed" }; + * if (isErr(result) && result.error instanceof WorkflowCancelledError) { + * // workflow was cancelled — perform cleanup that must not be cancelled: + * await context.nonCancellableScope(async () => { + * await context.activities.releaseResources(args); + * }); + * return { status: "cancelled" }; * } * * return { status: "ok" }; * } * ``` */ - cancellableScope: ( - fn: () => T | Promise, - ) => ResultAsync; + cancellableScope: (fn: () => T | Promise) => AsyncResult; /** * Run `fn` inside a non-cancellable Temporal scope. Cancellation requests * from outside the scope are ignored for its duration — the idiomatic way * to perform cleanup work that must not be interrupted. * - * Returns the same `ResultAsync<...>` shape as + * Returns the same `AsyncResult<...>` shape as * {@link WorkflowContext.cancellableScope} for symmetry; the * `err(WorkflowCancelledError)` branch only triggers when cancellation is * raised from *inside* the scope, which is rare. Non-cancellation errors - * surface as `err(WorkflowScopeError)`. + * surface on the `defect` channel. */ - nonCancellableScope: ( - fn: () => T | Promise, - ) => ResultAsync; + nonCancellableScope: (fn: () => T | Promise) => AsyncResult; /** * Continue this workflow execution as a new run, optionally with a different diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84fc89ba..a67ecb2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,9 +45,6 @@ catalogs: lefthook: specifier: 2.1.9 version: 2.1.9 - neverthrow: - specifier: 8.2.0 - version: 8.2.0 oxfmt: specifier: 0.54.0 version: 0.54.0 @@ -84,6 +81,9 @@ catalogs: typescript: specifier: 6.0.3 version: 6.0.3 + unthrown: + specifier: 0.1.0 + version: 0.1.0 valibot: specifier: 1.4.1 version: 1.4.1 @@ -182,6 +182,9 @@ importers: ts-pattern: specifier: 'catalog:' version: 5.9.0 + unthrown: + specifier: 'catalog:' + version: 0.1.0 zod: specifier: 'catalog:' version: 4.4.3 @@ -235,15 +238,15 @@ importers: '@temporalio/workflow': specifier: 'catalog:' version: 1.18.1 - neverthrow: - specifier: 'catalog:' - version: 8.2.0 pino: specifier: 'catalog:' version: 10.3.1 pino-pretty: specifier: 'catalog:' version: 13.1.3 + unthrown: + specifier: 'catalog:' + version: 0.1.0 zod: specifier: 'catalog:' version: 4.4.3 @@ -312,9 +315,6 @@ importers: '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.8(vitest@4.1.8) - neverthrow: - specifier: 'catalog:' - version: 8.2.0 tsdown: specifier: 'catalog:' version: 0.22.3(oxc-resolver@11.20.0)(tsx@4.22.4)(typescript@6.0.3) @@ -327,6 +327,9 @@ importers: typescript: specifier: 'catalog:' version: 6.0.3 + unthrown: + specifier: 'catalog:' + version: 0.1.0 vitest: specifier: 'catalog:' version: 4.1.8(@types/node@24.13.2)(@vitest/coverage-v8@4.1.8)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) @@ -355,9 +358,6 @@ importers: arktype: specifier: 'catalog:' version: 2.2.1 - neverthrow: - specifier: 'catalog:' - version: 8.2.0 tsdown: specifier: 'catalog:' version: 0.22.3(oxc-resolver@11.20.0)(tsx@4.22.4)(typescript@6.0.3) @@ -370,6 +370,9 @@ importers: typescript: specifier: 'catalog:' version: 6.0.3 + unthrown: + specifier: 'catalog:' + version: 0.1.0 valibot: specifier: 'catalog:' version: 1.4.1(typescript@6.0.3) @@ -453,9 +456,6 @@ importers: '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.8(vitest@4.1.8) - neverthrow: - specifier: 'catalog:' - version: 8.2.0 tsdown: specifier: 'catalog:' version: 0.22.3(oxc-resolver@11.20.0)(tsx@4.22.4)(typescript@6.0.3) @@ -468,6 +468,9 @@ importers: typescript: specifier: 'catalog:' version: 6.0.3 + unthrown: + specifier: 'catalog:' + version: 0.1.0 vitest: specifier: 'catalog:' version: 4.1.8(@types/node@24.13.2)(@vitest/coverage-v8@4.1.8)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) @@ -3521,6 +3524,7 @@ packages: git-raw-commits@5.0.1: resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} engines: {node: '>=18'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true glob-parent@5.1.2: @@ -4048,10 +4052,6 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - neverthrow@8.2.0: - resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==} - engines: {node: '>=18'} - nexus-rpc@0.0.2: resolution: {integrity: sha512-IWjIExdVYlmwXuzHdY/Q3lXCv1gbqoAXPazQhy2w4Xgtgha3H0OOujEESVPQcFUFMWm+pAk2gKnb57g8S41JZg==} engines: {node: '>= 20.0.0'} @@ -4814,6 +4814,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + unthrown@0.1.0: + resolution: {integrity: sha512-UkSp74t10tusRkbYJ1N+TMrGJHSVlfgNgdftUZnmf60StSl/MnujHOKH9vZnvnFq8uUg+vlN/9s4lWXq2Lix7w==} + engines: {node: '>=22.19'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -5952,6 +5956,13 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -6091,7 +6102,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': @@ -6319,7 +6330,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@rolldown/binding-wasm32-wasi@1.1.2': @@ -8400,10 +8411,6 @@ snapshots: neo-async@2.6.2: {} - neverthrow@8.2.0: - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': 4.61.1 - nexus-rpc@0.0.2: {} node-releases@2.0.47: {} @@ -9278,6 +9285,8 @@ snapshots: universalify@0.1.2: {} + unthrown@0.1.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c824740d..b08d3c70 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -58,7 +58,6 @@ catalog: arktype: 2.2.1 knip: 6.16.1 lefthook: 2.1.9 - neverthrow: 8.2.0 oxfmt: 0.54.0 oxlint: 1.69.0 pino: 10.3.1 @@ -71,6 +70,7 @@ catalog: typedoc: 0.28.19 typedoc-plugin-markdown: 4.12.0 typescript: 6.0.3 + unthrown: 0.1.0 valibot: 1.4.1 vitest: 4.1.8 zod: 4.4.3 @@ -84,3 +84,7 @@ allowBuilds: ssh2: true minimumReleaseAgeStrict: true +# First-party btravstack package — the maturity delay guards against +# third-party supply-chain risk, which does not apply to our own org's library. +minimumReleaseAgeExclude: + - "unthrown" From beccd6f42a9e3aa0f7690c2b97f65d1d0f477a53 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 26 Jun 2026 15:57:10 +0200 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20rename=20helper,=20fix=20comment=20+=20grammar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename the internal `makeResultAsync` helper to `makeAsyncResult` across the worker and client packages so the name matches its `AsyncResult` return type and the contract's `_internal_makeAsyncResult`. - Fix a misleading test comment that attributed pre-await throw capture to `fromPromise` instead of `makeAsyncResult` (via `fromSafePromise`). - Grammar: "a AsyncResult" -> "an AsyncResult" in the troubleshooting guide. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guide/troubleshooting.md | 4 ++-- packages/client/src/client.ts | 14 +++++++------- packages/client/src/internal.ts | 2 +- packages/client/src/schedule.ts | 4 ++-- packages/worker/src/cancellation.spec.ts | 7 ++++--- packages/worker/src/cancellation.ts | 8 ++++---- packages/worker/src/child-workflow.ts | 8 ++++---- packages/worker/src/internal.ts | 2 +- 8 files changed, 25 insertions(+), 24 deletions(-) diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 00eaef05..bbffb1b0 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -439,9 +439,9 @@ Error: Cannot find module './workflows' TypeError: Cannot read property 'match' of undefined ``` -**Cause:** Activity returned `undefined` instead of a `AsyncResult`. +**Cause:** Activity returned `undefined` instead of an `AsyncResult`. -**Solution:** Always return a `AsyncResult` from activities: +**Solution:** Always return an `AsyncResult` from activities: ```typescript // ❌ Returns undefined diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index feefdcb2..7cc0adbe 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -35,7 +35,7 @@ import { classifyHandleError, classifyResultError, classifyStartError, - makeResultAsync, + makeAsyncResult, toTypedSearchAttributes, } from "./internal.js"; import { WorkflowExecutionAlreadyStartedError } from "@temporalio/client"; @@ -486,7 +486,7 @@ export class TypedClient { return err(classifyStartError("startWorkflow", error)); } }; - return makeResultAsync(work); + return makeAsyncResult(work); } /** @@ -593,7 +593,7 @@ export class TypedClient { return err(classifyStartError("signalWithStart", error)); } }; - return makeResultAsync(work); + return makeAsyncResult(work); } /** @@ -704,7 +704,7 @@ export class TypedClient { return err(createRuntimeClientError("executeWorkflow", error)); } }; - return makeResultAsync(work); + return makeAsyncResult(work); } /** @@ -744,7 +744,7 @@ export class TypedClient { return err(createRuntimeClientError("getHandle", error)); } }; - return makeResultAsync(work); + return makeAsyncResult(work); } private createTypedHandle( @@ -819,7 +819,7 @@ export class TypedClient { return err(classifyResultError("result", error, workflowHandle.workflowId)); } }; - return makeResultAsync(work); + return makeAsyncResult(work); }, terminate: ( reason?: string, @@ -949,7 +949,7 @@ function buildValidatedProxy(work: () => Promise>): AsyncResult { +export function makeAsyncResult(work: () => Promise>): AsyncResult { return _internal_makeAsyncResult(work); } diff --git a/packages/client/src/schedule.ts b/packages/client/src/schedule.ts index 2815c0a6..c95c4b9a 100644 --- a/packages/client/src/schedule.ts +++ b/packages/client/src/schedule.ts @@ -12,7 +12,7 @@ import { type AsyncResult, type Result, ok, err, isOk, isErr, fromPromise } from import type { TypedSearchAttributeMap } from "./client.js"; import type { ClientInferInput } from "./types.js"; import { RuntimeClientError, WorkflowNotFoundError, WorkflowValidationError } from "./errors.js"; -import { makeResultAsync, toTypedSearchAttributes } from "./internal.js"; +import { makeAsyncResult, toTypedSearchAttributes } from "./internal.js"; /** * Workflow-action–level overrides forwarded to Temporal's @@ -198,7 +198,7 @@ export class TypedScheduleClient { return err(new RuntimeClientError("schedule.create", error)); } }; - return makeResultAsync(work); + return makeAsyncResult(work); } /** diff --git a/packages/worker/src/cancellation.spec.ts b/packages/worker/src/cancellation.spec.ts index af71bde9..540550bb 100644 --- a/packages/worker/src/cancellation.spec.ts +++ b/packages/worker/src/cancellation.spec.ts @@ -71,7 +71,7 @@ describe("cancellableScope", () => { it("routes a non-cancellation error to the defect channel", async () => { // A thrown non-cancellation error is an *unmodeled* failure: the helper - // re-throws it so the `makeResultAsync` boundary captures it as a defect, + // re-throws it so the `makeAsyncResult` boundary captures it as a defect, // with the original error on `cause`, rather than a typed err(...). const original = new Error("activity exploded"); const result = await cancellableScope(async () => { @@ -84,8 +84,9 @@ describe("cancellableScope", () => { }); it("captures synchronous throws from the work body as a defect", async () => { - // If `fn` throws *before* its first await, `fromPromise`'s thunk form still - // captures it — as a defect carrying the original cause. + // If `fn` throws *before* its first await, `makeAsyncResult` (via unthrown's + // `fromSafePromise`) still captures it — as a defect carrying the original + // cause. const original = new Error("sync explosion"); const result = await cancellableScope(() => { throw original; diff --git a/packages/worker/src/cancellation.ts b/packages/worker/src/cancellation.ts index ba229bdc..8d8d7f22 100644 --- a/packages/worker/src/cancellation.ts +++ b/packages/worker/src/cancellation.ts @@ -15,7 +15,7 @@ import { CancellationScope, isCancellation } from "@temporalio/workflow"; import { type AsyncResult, type Result, ok, err } from "unthrown"; import { WorkflowCancelledError } from "./errors.js"; -import { makeResultAsync } from "./internal.js"; +import { makeAsyncResult } from "./internal.js"; /** * Run `fn` inside a cancellable Temporal scope. If the workflow (or an @@ -59,12 +59,12 @@ export function cancellableScope( if (isCancellation(error)) { return err(new WorkflowCancelledError(error)); } - // Non-cancellation throw → re-throw so `makeResultAsync`'s boundary + // Non-cancellation throw → re-throw so `makeAsyncResult`'s boundary // routes it through the `defect` channel as an unmodeled failure. throw error; } }; - return makeResultAsync(work); + return makeAsyncResult(work); } /** @@ -99,5 +99,5 @@ export function nonCancellableScope( throw error; } }; - return makeResultAsync(work); + return makeAsyncResult(work); } diff --git a/packages/worker/src/child-workflow.ts b/packages/worker/src/child-workflow.ts index 8f08ab77..efb31511 100644 --- a/packages/worker/src/child-workflow.ts +++ b/packages/worker/src/child-workflow.ts @@ -19,7 +19,7 @@ import { import { classifyChildWorkflowError, formatChildWorkflowValidationMessage, - makeResultAsync, + makeAsyncResult, } from "./internal.js"; import type { ClientInferInput, ClientInferOutput, WorkerInferInput } from "./types.js"; @@ -139,7 +139,7 @@ function createTypedChildHandle( return err(classifyChildWorkflowError("result", error, childWorkflowName)); } }; - return makeResultAsync(work); + return makeAsyncResult(work); }, }; } @@ -191,7 +191,7 @@ export function createStartChildWorkflow< return err(classifyChildWorkflowError("startChild", error, String(childWorkflowName))); } }; - return makeResultAsync(work); + return makeAsyncResult(work); } export function createExecuteChildWorkflow< @@ -250,5 +250,5 @@ export function createExecuteChildWorkflow< return err(classifyChildWorkflowError("executeChild", error, String(childWorkflowName))); } }; - return makeResultAsync(work); + return makeAsyncResult(work); } diff --git a/packages/worker/src/internal.ts b/packages/worker/src/internal.ts index 530a9755..a763d0ae 100644 --- a/packages/worker/src/internal.ts +++ b/packages/worker/src/internal.ts @@ -39,7 +39,7 @@ export function formatChildWorkflowValidationMessage( // work functions identically to the client side. Unanticipated rejections // (a synchronous throw or a rejected promise from `work()`) are routed through // unthrown's `defect` channel rather than escaping as an unhandled rejection. -export { _internal_makeAsyncResult as makeResultAsync } from "@temporal-contract/contract/result-async"; +export { _internal_makeAsyncResult as makeAsyncResult } from "@temporal-contract/contract/result-async"; /** * Extract the single payload from a Temporal handler's `...args` array. From 087599e7f86ca0ea7b0e01dccc2793b5cd082e63 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 26 Jun 2026 16:19:54 +0200 Subject: [PATCH 3/6] refactor: namespace TaggedError tags with @temporal-contract/ Address PR review: prefix every TaggedError `_tag` with the package scope (e.g. "@temporal-contract/WorkflowExecutionNotFoundError") so the library's error discriminants never collide with a consumer's own tags or another library's. Each constructor overrides `this.name` back to the bare class name, so logs / stack traces / Temporal UI keep the conventional identifier and stay consistent with the ValidationError family. The tags are internal discriminants that do not cross the Temporal wire (only the ApplicationFailure-based ValidationErrors do, and their `type` is unchanged). Docs (result-pattern, migrating-to-unthrown), the agent rule, and the changeset note the namespacing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/migrate-to-unthrown.md | 2 +- AGENTS.md | 2 +- docs/guide/migrating-to-unthrown.md | 7 ++++++ docs/guide/result-pattern.md | 8 +++++++ packages/client/src/errors.ts | 35 +++++++++++++++++++++-------- packages/worker/src/errors.ts | 23 ++++++++++++++----- 6 files changed, 61 insertions(+), 16 deletions(-) diff --git a/.changeset/migrate-to-unthrown.md b/.changeset/migrate-to-unthrown.md index 70b50ae6..610f0e98 100644 --- a/.changeset/migrate-to-unthrown.md +++ b/.changeset/migrate-to-unthrown.md @@ -15,6 +15,6 @@ Replace `neverthrow` with [`unthrown`](https://github.com/btravstack/unthrown) f - **New `defect` channel.** Unanticipated throws (a thrown exception the code did not model) now surface on `unthrown`'s third `defect` channel — inspected via `isDefect(result)` / `result.cause` and re-thrown at the edge — rather than as a typed `err`. Deliberate boundary classification (e.g. mapping a Temporal SDK rejection to `WorkflowExecutionNotFoundError`) still produces a modeled `err`. `result.match({ ok, err, defect })` folds all three. - **`WorkflowScopeError` removed.** Non-cancellation errors thrown inside `cancellableScope` / `nonCancellableScope` are unmodeled failures and now ride the `defect` channel. The scopes' error union narrows to `WorkflowCancelledError`. - **The client's "unexpected" `RuntimeClientError` wrap is gone.** An unanticipated rejection in a client operation now surfaces as a defect, not a manufactured `RuntimeClientError`. `RuntimeClientError` is still produced by deliberate boundary classification. -- **Error classes use `TaggedError`.** The worker `WorkerError` hierarchy and the entire client `TypedClientError` hierarchy are now built with `unthrown`'s `TaggedError("Name")<{ ...payload }>`, each carrying a `_tag` discriminant (foldable with `matchTags`). `ChildWorkflowCancelledError` is now a sibling of `ChildWorkflowError` (distinct `_tag`) rather than a subclass — discriminate on `_tag` / `instanceof ChildWorkflowCancelledError` instead of relying on `instanceof ChildWorkflowError` matching cancellation. The worker's `ValidationError` subclasses are unchanged — they still extend Temporal's `ApplicationFailure` for terminal-failure semantics. +- **Error classes use `TaggedError`.** The worker `WorkerError` hierarchy and the entire client `TypedClientError` hierarchy are now built with `unthrown`'s `TaggedError`, each carrying a `_tag` discriminant (foldable with `matchTags`). The `_tag` is **package-namespaced** — e.g. `"@temporal-contract/WorkflowExecutionNotFoundError"` — so it never collides with a consumer's own tags; each error's `.name` stays the bare class name for readable logs. `ChildWorkflowCancelledError` is now a sibling of `ChildWorkflowError` (distinct `_tag`) rather than a subclass — discriminate on `_tag` / `instanceof ChildWorkflowCancelledError` instead of relying on `instanceof ChildWorkflowError` matching cancellation. The worker's `ValidationError` subclasses are unchanged — they still extend Temporal's `ApplicationFailure` for terminal-failure semantics. See the [Migrating from neverthrow](https://btravstack.github.io/temporal-contract/guide/migrating-to-unthrown) guide. diff --git a/AGENTS.md b/AGENTS.md index e0cf70ae..84e12981 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ This file is the source of truth for agent guidance in this repo. `CLAUDE.md` an ## The 6 rules that prevent broken PRs 1. **Workflow code is deterministic.** No `Date.now()`, `Math.random()`, `setTimeout`, `crypto.randomUUID()`, native I/O, or `process.env` reads inside `declareWorkflow`'s `implementation`. Use `@temporalio/workflow` primitives (`sleep`, `uuid4`, the patched `Date`) or push the side effect into an activity. See [.agents/rules/workflow-determinism.md](.agents/rules/workflow-determinism.md). This is the #1 cause of broken Temporal workflows — read that file before touching workflow code. -2. **Activities and the typed client return `AsyncResult` from `unthrown`.** Never throw — wrap technical errors in `ApplicationFailure` and surface them via `err(...).toAsync()` (or `fromPromise(promise, qualify)`, where `qualify` returns the modeled error `E`). unthrown has no `okAsync`/`errAsync`: lift a sync `Result` with `.toAsync()`. The client uses unthrown's `Result` for sync returns. unthrown adds a third **`defect`** channel for _unanticipated_ failures — a thrown exception the code didn't model surfaces as a defect (inspectable via `isDefect(result)` / `result.cause`, re-thrown at the edge), not a typed `err`. Narrow with the **free functions** `isOk(r)`/`isErr(r)`/`isDefect(r)` (the `r.isOk()` _methods_ return plain `boolean` and do **not** narrow). Error classes are built with `TaggedError("Name")<{ ...payload }>` — except the worker's `ValidationError` subclasses, which must stay `ApplicationFailure` for Temporal's terminal-failure semantics. There is no `neverthrow`, no `@swan-io/boxed`, and no `@temporal-contract/boxed` package — those were removed. +2. **Activities and the typed client return `AsyncResult` from `unthrown`.** Never throw — wrap technical errors in `ApplicationFailure` and surface them via `err(...).toAsync()` (or `fromPromise(promise, qualify)`, where `qualify` returns the modeled error `E`). unthrown has no `okAsync`/`errAsync`: lift a sync `Result` with `.toAsync()`. The client uses unthrown's `Result` for sync returns. unthrown adds a third **`defect`** channel for _unanticipated_ failures — a thrown exception the code didn't model surfaces as a defect (inspectable via `isDefect(result)` / `result.cause`, re-thrown at the edge), not a typed `err`. Narrow with the **free functions** `isOk(r)`/`isErr(r)`/`isDefect(r)` (the `r.isOk()` _methods_ return plain `boolean` and do **not** narrow). Error classes are built with `TaggedError("@temporal-contract/Name")<{ ...payload }>` — the `_tag` is package-namespaced to avoid collisions; each constructor overrides `this.name` back to the bare class name for readable logs. The worker's `ValidationError` subclasses are the exception — they must stay `ApplicationFailure` for Temporal's terminal-failure semantics. There is no `neverthrow`, no `@swan-io/boxed`, and no `@temporal-contract/boxed` package — those were removed. 3. **No `any`.** Use `unknown` and narrow. Enforced by oxlint. 4. **`.js` extensions in every import.** TypeScript files import each other as `./foo.js`, never `./foo` or `./foo.ts`. Required by ESM module resolution. 5. **ESM only.** All packages are `"type": "module"`. No CommonJS in source. diff --git a/docs/guide/migrating-to-unthrown.md b/docs/guide/migrating-to-unthrown.md index a9194548..3e8363a3 100644 --- a/docs/guide/migrating-to-unthrown.md +++ b/docs/guide/migrating-to-unthrown.md @@ -188,6 +188,13 @@ const message = matchTags(result, { > 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):** diff --git a/docs/guide/result-pattern.md b/docs/guide/result-pattern.md index 081cdd7b..7137c14e 100644 --- a/docs/guide/result-pattern.md +++ b/docs/guide/result-pattern.md @@ -594,6 +594,14 @@ class GatewayTimeout extends TaggedError("GatewayTimeout")<{ > The worker's `ValidationError` subclasses are the exception — they still > extend Temporal's `ApplicationFailure` rather than `TaggedError`. +> [!NOTE] +> temporal-contract's own error classes namespace their tag with the package +> scope — e.g. `_tag === "@temporal-contract/WorkflowExecutionNotFoundError"` — +> so they never collide with a `_tag` from your own code or another library. +> Their `.name` stays the bare class name (e.g. `"WorkflowExecutionNotFoundError"`) +> for readable logs. When folding library errors, the `matchTags` keys carry the +> prefix: `matchTags(result, { "@temporal-contract/WorkflowExecutionNotFoundError": ... })`. + Because every tagged error carries a `_tag`, unthrown's `matchTags` folds a `Result` exhaustively by tag, with dedicated `Ok` and `Defect` channels: diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 2bd78c0a..3708b0b1 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -33,7 +33,7 @@ export type TemporalFailure = /** * Generic runtime failure wrapper when no specific error type applies */ -export class RuntimeClientError extends TaggedError("RuntimeClientError")<{ +export class RuntimeClientError extends TaggedError("@temporal-contract/RuntimeClientError")<{ operation: string; cause?: unknown; message: string; @@ -46,13 +46,16 @@ export class RuntimeClientError extends TaggedError("RuntimeClientError")<{ cause instanceof Error ? cause.message : String(cause ?? "unknown error") }`, }); + // Keep the conventional class name in logs; the package namespace lives + // only on `_tag`. + this.name = "RuntimeClientError"; } } /** * Thrown when a workflow is not found in the contract */ -export class WorkflowNotFoundError extends TaggedError("WorkflowNotFoundError")<{ +export class WorkflowNotFoundError extends TaggedError("@temporal-contract/WorkflowNotFoundError")<{ workflowName: string; availableWorkflows: string[]; message: string; @@ -63,6 +66,7 @@ export class WorkflowNotFoundError extends TaggedError("WorkflowNotFoundError")< availableWorkflows, message: `Workflow "${workflowName}" not found in contract. Available workflows: ${availableWorkflows.join(", ")}`, }); + this.name = "WorkflowNotFoundError"; } } @@ -77,7 +81,9 @@ export class WorkflowNotFoundError extends TaggedError("WorkflowNotFoundError")< * branch on it explicitly (e.g. fetch the existing handle and continue) * without inspecting `error.cause` against a Temporal SDK class. */ -export class WorkflowAlreadyStartedError extends TaggedError("WorkflowAlreadyStartedError")<{ +export class WorkflowAlreadyStartedError extends TaggedError( + "@temporal-contract/WorkflowAlreadyStartedError", +)<{ workflowType: string; workflowId: string; cause?: unknown; @@ -90,6 +96,7 @@ export class WorkflowAlreadyStartedError extends TaggedError("WorkflowAlreadySta cause, message: `Workflow "${workflowType}" with ID "${workflowId}" is already started or in retention.`, }); + this.name = "WorkflowAlreadyStartedError"; } } @@ -105,7 +112,9 @@ export class WorkflowAlreadyStartedError extends TaggedError("WorkflowAlreadySta * - `executeWorkflow` (when the underlying execute call hits a missing * execution mid-flight) */ -export class WorkflowExecutionNotFoundError extends TaggedError("WorkflowExecutionNotFoundError")<{ +export class WorkflowExecutionNotFoundError extends TaggedError( + "@temporal-contract/WorkflowExecutionNotFoundError", +)<{ workflowId: string; runId?: string | undefined; cause?: unknown; @@ -118,6 +127,7 @@ export class WorkflowExecutionNotFoundError extends TaggedError("WorkflowExecuti cause, message: `Workflow execution "${workflowId}"${runId ? ` (run "${runId}")` : ""} not found in namespace.`, }); + this.name = "WorkflowExecutionNotFoundError"; } } @@ -140,7 +150,7 @@ export class WorkflowExecutionNotFoundError extends TaggedError("WorkflowExecuti * * Returned from `executeWorkflow` and `handle.result()`. */ -export class WorkflowFailedError extends TaggedError("WorkflowFailedError")<{ +export class WorkflowFailedError extends TaggedError("@temporal-contract/WorkflowFailedError")<{ workflowId: string; cause?: TemporalFailure | undefined; message: string; @@ -153,6 +163,7 @@ export class WorkflowFailedError extends TaggedError("WorkflowFailedError")<{ cause, message: `Workflow "${workflowId}" completed with failure: ${causeMessage}`, }); + this.name = "WorkflowFailedError"; } } @@ -164,7 +175,9 @@ export class WorkflowFailedError extends TaggedError("WorkflowFailedError")<{ /** * Thrown when workflow input or output validation fails */ -export class WorkflowValidationError extends TaggedError("WorkflowValidationError")<{ +export class WorkflowValidationError extends TaggedError( + "@temporal-contract/WorkflowValidationError", +)<{ workflowName: string; direction: "input" | "output"; issues: ReadonlyArray; @@ -181,13 +194,14 @@ export class WorkflowValidationError extends TaggedError("WorkflowValidationErro issues, message: `Validation failed for workflow "${workflowName}" ${direction}: ${summarizeIssues(issues)}`, }); + this.name = "WorkflowValidationError"; } } /** * Thrown when query input or output validation fails */ -export class QueryValidationError extends TaggedError("QueryValidationError")<{ +export class QueryValidationError extends TaggedError("@temporal-contract/QueryValidationError")<{ queryName: string; direction: "input" | "output"; issues: ReadonlyArray; @@ -204,13 +218,14 @@ export class QueryValidationError extends TaggedError("QueryValidationError")<{ issues, message: `Validation failed for query "${queryName}" ${direction}: ${summarizeIssues(issues)}`, }); + this.name = "QueryValidationError"; } } /** * Thrown when signal input validation fails */ -export class SignalValidationError extends TaggedError("SignalValidationError")<{ +export class SignalValidationError extends TaggedError("@temporal-contract/SignalValidationError")<{ signalName: string; issues: ReadonlyArray; message: string; @@ -221,13 +236,14 @@ export class SignalValidationError extends TaggedError("SignalValidationError")< issues, message: `Validation failed for signal "${signalName}": ${summarizeIssues(issues)}`, }); + this.name = "SignalValidationError"; } } /** * Thrown when update input or output validation fails */ -export class UpdateValidationError extends TaggedError("UpdateValidationError")<{ +export class UpdateValidationError extends TaggedError("@temporal-contract/UpdateValidationError")<{ updateName: string; direction: "input" | "output"; issues: ReadonlyArray; @@ -244,5 +260,6 @@ export class UpdateValidationError extends TaggedError("UpdateValidationError")< issues, message: `Validation failed for update "${updateName}" ${direction}: ${summarizeIssues(issues)}`, }); + this.name = "UpdateValidationError"; } } diff --git a/packages/worker/src/errors.ts b/packages/worker/src/errors.ts index cfce39ff..3c7641a7 100644 --- a/packages/worker/src/errors.ts +++ b/packages/worker/src/errors.ts @@ -62,7 +62,7 @@ export abstract class ValidationError extends ApplicationFailure { * Error thrown when an activity definition is not found in the contract */ export class ActivityDefinitionNotFoundError extends TaggedError( - "ActivityDefinitionNotFoundError", + "@temporal-contract/ActivityDefinitionNotFoundError", )<{ activityName: string; availableDefinitions: readonly string[]; @@ -75,6 +75,9 @@ export class ActivityDefinitionNotFoundError extends TaggedError( availableDefinitions, message: `Activity definition not found for: "${activityName}". Available activities: ${available}`, }); + // Keep the conventional class name in logs/stack traces; the package + // namespace lives only on `_tag`. + this.name = "ActivityDefinitionNotFoundError"; } } @@ -234,7 +237,9 @@ export class UpdateOutputValidationError extends ValidationError { /** * Error thrown when a child workflow is not found in the contract */ -export class ChildWorkflowNotFoundError extends TaggedError("ChildWorkflowNotFoundError")<{ +export class ChildWorkflowNotFoundError extends TaggedError( + "@temporal-contract/ChildWorkflowNotFoundError", +)<{ workflowName: string; availableWorkflows: readonly string[]; message: string; @@ -246,6 +251,7 @@ export class ChildWorkflowNotFoundError extends TaggedError("ChildWorkflowNotFou availableWorkflows, message: `Child workflow not found: "${workflowName}". Available workflows: ${available}`, }); + this.name = "ChildWorkflowNotFoundError"; } } @@ -258,12 +264,13 @@ export class ChildWorkflowNotFoundError extends TaggedError("ChildWorkflowNotFou * mirroring the client-side `WorkflowFailedError.cause` behavior, so callers * can branch on the failure category in one step instead of unwrapping twice. */ -export class ChildWorkflowError extends TaggedError("ChildWorkflowError")<{ +export class ChildWorkflowError extends TaggedError("@temporal-contract/ChildWorkflowError")<{ message: string; cause?: unknown; }> { constructor(message: string, cause?: unknown) { super({ message, cause }); + this.name = "ChildWorkflowError"; } } @@ -282,13 +289,16 @@ export class ChildWorkflowError extends TaggedError("ChildWorkflowError")<{ * folds the `ChildWorkflowError | ChildWorkflowCancelledError` union * exhaustively. */ -export class ChildWorkflowCancelledError extends TaggedError("ChildWorkflowCancelledError")<{ +export class ChildWorkflowCancelledError extends TaggedError( + "@temporal-contract/ChildWorkflowCancelledError", +)<{ workflowName: string; cause?: unknown; message: string; }> { constructor(workflowName: string, cause?: unknown) { super({ workflowName, cause, message: `Child workflow "${workflowName}" was cancelled` }); + this.name = "ChildWorkflowCancelledError"; } } @@ -304,11 +314,14 @@ export class ChildWorkflowCancelledError extends TaggedError("ChildWorkflowCance * surface on the scope's `defect` channel (re-thrown at the edge / inspectable * via `result.isDefect()` and `result.cause`), not as a typed `err(...)`. */ -export class WorkflowCancelledError extends TaggedError("WorkflowCancelledError")<{ +export class WorkflowCancelledError extends TaggedError( + "@temporal-contract/WorkflowCancelledError", +)<{ cause?: unknown; message: string; }> { constructor(cause?: unknown) { super({ cause, message: "Workflow cancellation scope was cancelled" }); + this.name = "WorkflowCancelledError"; } } From e3a8b32c0c30698db2eb4a691fde640fe4994d69 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 26 Jun 2026 16:51:32 +0200 Subject: [PATCH 4/6] refactor: developer-experience cleanups from the unthrown retrospective MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `_internal_assertNoDefect` (contract) and use it to replace the manual `if (!isOk(r)) throw r.cause` double-guards in the worker child-workflow and client schedule/client work functions — narrows an internally-built Result (known to carry only ok/err) to `Ok | Err` in one call. - Document the activity wrapper's defect handling: a defect re-throws its raw cause so Temporal applies the default (retryable) policy, preserving the pre-unthrown behaviour; we deliberately do not force `nonRetryable`. - Rewrite the example client to the idiomatic unthrown railway: `tap().flatMap()` chains start -> result, then a single exhaustive `matchTags` fold replaces the ts-pattern `P.instanceOf` matching. Drop the now-unused `ts-pattern` dep. - Wire up `@unthrown/vitest` matchers (`toBeOk`/`toBeOkWith`/`toBeErr`/ `toBeDefect`) via a per-package setup file, and convert the discriminant assertions across the specs (narrowing blocks stay for value introspection). Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/order-processing-client/package.json | 1 - .../order-processing-client/src/client.ts | 243 +++++++----------- examples/order-processing-worker/package.json | 1 + .../src/integration.spec.ts | 18 +- .../src/vitest.setup.ts | 6 + .../order-processing-worker/vitest.config.ts | 1 + packages/client/package.json | 1 + packages/client/src/__tests__/client.spec.ts | 50 ++-- packages/client/src/client.spec.ts | 100 +++---- packages/client/src/client.ts | 20 +- packages/client/src/internal.ts | 5 + packages/client/src/schedule.spec.ts | 22 +- packages/client/src/schedule.ts | 10 +- packages/client/src/vitest.setup.ts | 6 + packages/client/vitest.config.ts | 2 + packages/contract/package.json | 1 + packages/contract/src/result-async.spec.ts | 15 +- packages/contract/src/result-async.ts | 32 ++- packages/contract/src/vitest.setup.ts | 6 + packages/contract/vitest.config.ts | 1 + packages/worker/package.json | 1 + packages/worker/src/__tests__/worker.spec.ts | 42 +-- packages/worker/src/activity.ts | 10 + packages/worker/src/cancellation.spec.ts | 20 +- packages/worker/src/child-workflow.ts | 19 +- packages/worker/src/internal.ts | 8 +- packages/worker/src/vitest.setup.ts | 6 + packages/worker/vitest.config.ts | 2 + pnpm-lock.yaml | 37 ++- pnpm-workspace.yaml | 7 +- 30 files changed, 364 insertions(+), 329 deletions(-) create mode 100644 examples/order-processing-worker/src/vitest.setup.ts create mode 100644 packages/client/src/vitest.setup.ts create mode 100644 packages/contract/src/vitest.setup.ts create mode 100644 packages/worker/src/vitest.setup.ts diff --git a/examples/order-processing-client/package.json b/examples/order-processing-client/package.json index df7aa566..7dc10168 100644 --- a/examples/order-processing-client/package.json +++ b/examples/order-processing-client/package.json @@ -13,7 +13,6 @@ "@temporalio/client": "catalog:", "pino": "catalog:", "pino-pretty": "catalog:", - "ts-pattern": "catalog:", "unthrown": "catalog:", "zod": "catalog:" }, diff --git a/examples/order-processing-client/src/client.ts b/examples/order-processing-client/src/client.ts index ffe7ae31..8c1bbf4e 100644 --- a/examples/order-processing-client/src/client.ts +++ b/examples/order-processing-client/src/client.ts @@ -1,20 +1,11 @@ import { Client, Connection } from "@temporalio/client"; -import { - RuntimeClientError, - TypedClient, - WorkflowAlreadyStartedError, - WorkflowExecutionNotFoundError, - WorkflowFailedError, - WorkflowNotFoundError, - WorkflowValidationError, -} from "@temporal-contract/client"; +import { TypedClient } from "@temporal-contract/client"; import { orderProcessingContract, OrderSchema, } from "@temporal-contract/sample-order-processing-contract"; import type { z } from "zod"; -import { match, P } from "ts-pattern"; -import { isOk, isErr, isDefect } from "unthrown"; +import { matchTags } from "unthrown"; import { logger } from "./logger.js"; type Order = z.infer; @@ -84,111 +75,70 @@ async function run() { for (const order of orders) { logger.info({ order }, `📦 Creating order: ${order.orderId}`); - // Start workflow and get handle - const handleResult = await contractClient.startWorkflow("processOrder", { - workflowId: order.orderId, - args: order, - }); - - // Handle workflow start errors - if (isErr(handleResult)) { - const error = handleResult.error; - match(error) - .with(P.instanceOf(WorkflowNotFoundError), (err) => { - logger.error({ error: err, orderId: order.orderId }, "❌ Workflow not found"); - }) - .with(P.instanceOf(WorkflowValidationError), (err) => { - logger.error({ error: err, orderId: order.orderId }, "❌ Workflow validation failed"); - }) - .with(P.instanceOf(WorkflowAlreadyStartedError), (err) => { - // Idempotent fast-path: a workflow with this ID is already running - // (or in retention). Production callers can re-fetch the existing - // handle and continue; here we just log and move on. - logger.warn( - { error: err, orderId: order.orderId }, - "⏭️ Workflow already started — skipping", - ); - }) - .with(P.instanceOf(RuntimeClientError), (err) => { - logger.error({ error: err, orderId: order.orderId }, "❌ Failed to start workflow"); - }) - .exhaustive(); - continue; - } - // A defect is an unmodeled failure (a bug) — surfaced on its own channel - // rather than as a typed domain error. - if (isDefect(handleResult)) { - logger.error( - { cause: handleResult.cause, orderId: order.orderId }, - "❌ Unexpected failure starting workflow", - ); - continue; - } - - const handle = handleResult.value; - logger.info({ workflowId: handle.workflowId }, `✅ Workflow started: ${handle.workflowId}`); - logger.info("⌛ Waiting for workflow result..."); - - // Get workflow result - const result = await handle.result(); - - // Handle workflow execution result - if (isErr(result)) { - const error = result.error; - match(error) - .with(P.instanceOf(WorkflowValidationError), (err) => { - logger.error( - { error: err, orderId: order.orderId }, - "❌ Workflow result validation failed", - ); - }) - .with(P.instanceOf(WorkflowFailedError), (err) => { - logger.error( - { error: err, orderId: order.orderId, cause: err.cause }, - "❌ Workflow completed with failure", + // Chain start → result on the AsyncResult railway: `tap` logs the started + // handle without leaving the railway, `flatMap` sequences the dependent + // `handle.result()` call, and its error union widens to cover both phases. + // A single `matchTags` then folds the combined result exhaustively — every + // modeled error tag (package-namespaced `@temporal-contract/...`) plus `Ok` + // and `Defect` must be handled, or it is a compile error. + const result = await contractClient + .startWorkflow("processOrder", { workflowId: order.orderId, args: order }) + .tap((handle) => { + logger.info({ workflowId: handle.workflowId }, `✅ Workflow started: ${handle.workflowId}`); + logger.info("⌛ Waiting for workflow result..."); + }) + .flatMap((handle) => handle.result()); + + matchTags(result, { + Ok: (output) => { + if (output.status === "completed") { + logger.info( + { + orderId: output.orderId, + transactionId: output.transactionId, + trackingNumber: output.trackingNumber, + }, + `🎉 Order ${output.orderId} completed successfully!`, ); - }) - .with(P.instanceOf(WorkflowExecutionNotFoundError), (err) => { + } else { logger.error( - { error: err, orderId: order.orderId }, - "❌ Workflow execution not found in namespace", + { + orderId: output.orderId, + failureReason: output.failureReason, + errorCode: output.errorCode, + }, + `❌ Order ${output.orderId} failed`, ); - }) - .with(P.instanceOf(RuntimeClientError), (err) => { - logger.error({ error: err, orderId: order.orderId }, "❌ Workflow execution failed"); - }) - .exhaustive(); - continue; - } - if (isDefect(result)) { - logger.error( - { cause: result.cause, orderId: order.orderId }, - "❌ Unexpected failure waiting for workflow result", - ); - continue; - } - - const output = result.value; - // Handle successful result - if (output.status === "completed") { - logger.info( - { - orderId: output.orderId, - transactionId: output.transactionId, - trackingNumber: output.trackingNumber, - }, - `🎉 Order ${output.orderId} completed successfully!`, - ); - } else { - logger.error( - { - orderId: output.orderId, - failureReason: output.failureReason, - errorCode: output.errorCode, - }, - `❌ Order ${output.orderId} failed`, - ); - } + } + }, + "@temporal-contract/WorkflowNotFoundError": (err) => + logger.error({ error: err, orderId: order.orderId }, "❌ Workflow not found"), + "@temporal-contract/WorkflowValidationError": (err) => + logger.error({ error: err, orderId: order.orderId }, "❌ Workflow validation failed"), + // Idempotent fast-path: a workflow with this ID is already running (or in + // retention). Production callers can re-fetch the existing handle; here we + // just log and move on. + "@temporal-contract/WorkflowAlreadyStartedError": (err) => + logger.warn( + { error: err, orderId: order.orderId }, + "⏭️ Workflow already started — skipping", + ), + "@temporal-contract/WorkflowFailedError": (err) => + logger.error( + { error: err, orderId: order.orderId, cause: err.cause }, + "❌ Workflow completed with failure", + ), + "@temporal-contract/WorkflowExecutionNotFoundError": (err) => + logger.error( + { error: err, orderId: order.orderId }, + "❌ Workflow execution not found in namespace", + ), + "@temporal-contract/RuntimeClientError": (err) => + logger.error({ error: err, orderId: order.orderId }, "❌ Workflow execution failed"), + // A defect is an unmodeled failure (a bug), not an anticipated outcome. + Defect: (cause) => + logger.error({ cause, orderId: order.orderId }, "❌ Unexpected failure processing order"), + }); } // Example using executeWorkflow with AsyncResult pattern @@ -213,44 +163,35 @@ async function run() { args: exampleOrder, }); - // Handle result with pattern matching - if (isOk(result)) { - const output = result.value; - const summary = { - id: output.orderId, - success: output.status === "completed", - message: - output.status === "completed" - ? `Order completed with tracking: ${output.trackingNumber}` - : `Order failed: ${output.failureReason}`, - }; - logger.info({ data: summary }, `📊 Order summary: ${summary.message}`); - } else if (isErr(result)) { - // Handle modeled errors - match(result.error) - .with(P.instanceOf(WorkflowNotFoundError), (err) => { - logger.error({ error: err }, "❌ Workflow not found"); - }) - .with(P.instanceOf(WorkflowValidationError), (err) => { - logger.error({ error: err }, "❌ Validation failed"); - }) - .with(P.instanceOf(WorkflowAlreadyStartedError), (err) => { - logger.warn({ error: err }, "⏭️ Workflow already started"); - }) - .with(P.instanceOf(WorkflowFailedError), (err) => { - logger.error({ error: err, cause: err.cause }, "❌ Workflow completed with failure"); - }) - .with(P.instanceOf(WorkflowExecutionNotFoundError), (err) => { - logger.error({ error: err }, "❌ Workflow execution not found in namespace"); - }) - .with(P.instanceOf(RuntimeClientError), (err) => { - logger.error({ error: err }, "❌ Workflow execution failed"); - }) - .exhaustive(); - } else { + // Handle the result by tag — `executeWorkflow` can surface both start-phase + // and result-phase errors, so its union is the widest. + matchTags(result, { + Ok: (output) => { + const summary = { + id: output.orderId, + success: output.status === "completed", + message: + output.status === "completed" + ? `Order completed with tracking: ${output.trackingNumber}` + : `Order failed: ${output.failureReason}`, + }; + logger.info({ data: summary }, `📊 Order summary: ${summary.message}`); + }, + "@temporal-contract/WorkflowNotFoundError": (err) => + logger.error({ error: err }, "❌ Workflow not found"), + "@temporal-contract/WorkflowValidationError": (err) => + logger.error({ error: err }, "❌ Validation failed"), + "@temporal-contract/WorkflowAlreadyStartedError": (err) => + logger.warn({ error: err }, "⏭️ Workflow already started"), + "@temporal-contract/WorkflowFailedError": (err) => + logger.error({ error: err, cause: err.cause }, "❌ Workflow completed with failure"), + "@temporal-contract/WorkflowExecutionNotFoundError": (err) => + logger.error({ error: err }, "❌ Workflow execution not found in namespace"), + "@temporal-contract/RuntimeClientError": (err) => + logger.error({ error: err }, "❌ Workflow execution failed"), // A defect is an unmodeled failure (a bug), not an anticipated outcome. - logger.error({ cause: result.cause }, "❌ Unexpected failure executing workflow"); - } + Defect: (cause) => logger.error({ cause }, "❌ Unexpected failure executing workflow"), + }); logger.info("\n✨ Done!"); logger.info(""); @@ -259,7 +200,7 @@ async function run() { logger.info(" - Type-safe error values"); logger.info(" - Functional composition with flatMap, map, mapErr, orElse"); logger.info(" - Railway-oriented programming"); - logger.info(" - Exhaustive error matching with ts-pattern"); + logger.info(" - Exhaustive error matching with unthrown matchTags"); process.exit(0); } diff --git a/examples/order-processing-worker/package.json b/examples/order-processing-worker/package.json index bff4ea11..0841f525 100644 --- a/examples/order-processing-worker/package.json +++ b/examples/order-processing-worker/package.json @@ -24,6 +24,7 @@ "@temporal-contract/tsconfig": "workspace:*", "@temporalio/client": "catalog:", "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", "@vitest/coverage-v8": "catalog:", "tsx": "catalog:", "typescript": "catalog:", diff --git a/examples/order-processing-worker/src/integration.spec.ts b/examples/order-processing-worker/src/integration.spec.ts index 4318350e..a30a2dd1 100644 --- a/examples/order-processing-worker/src/integration.spec.ts +++ b/examples/order-processing-worker/src/integration.spec.ts @@ -95,7 +95,7 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ orderId: order.orderId, @@ -128,13 +128,13 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; expect(handle.workflowId).toBe(order.orderId); const result = await handle.result(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ orderId: order.orderId, @@ -169,13 +169,13 @@ describe("Order Processing Workflow - Integration Tests", () => { // THEN const handleResult = await client.getHandle("processOrder", order.orderId); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; expect(handle.workflowId).toBe(order.orderId); const result = await handle.result(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ orderId: order.orderId, @@ -208,12 +208,12 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; const describeResult = await handle.describe(); - expect(isOk(describeResult)).toBe(true); + expect(describeResult).toBeOk(); if (isOk(describeResult)) { expect(describeResult.value).toEqual( expect.objectContaining({ workflowId: order.orderId, type: "processOrder" }), @@ -245,7 +245,7 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - expect(isErr(execution)).toBe(true); + expect(execution).toBeErr(); if (isErr(execution)) { expect(execution.error).toBeInstanceOf(WorkflowValidationError); const validationError = execution.error as WorkflowValidationError; @@ -290,7 +290,7 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - Should return failed status - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ status: "failed", diff --git a/examples/order-processing-worker/src/vitest.setup.ts b/examples/order-processing-worker/src/vitest.setup.ts new file mode 100644 index 00000000..0d6f5ac4 --- /dev/null +++ b/examples/order-processing-worker/src/vitest.setup.ts @@ -0,0 +1,6 @@ +// Registers `@unthrown/vitest`'s custom matchers (`toBeOk`, `toBeOkWith`, `toBeErr`, +// `toBeErrTagged`, `toBeDefect`) on Vitest's `expect`, and brings their +// `declare module "vitest"` type augmentation into the compilation so the +// matchers type-check in the spec files. Referenced from `setupFiles` in +// `vitest.config.ts`; not part of the package's published surface. +import "@unthrown/vitest"; diff --git a/examples/order-processing-worker/vitest.config.ts b/examples/order-processing-worker/vitest.config.ts index 311b60ad..4a008282 100644 --- a/examples/order-processing-worker/vitest.config.ts +++ b/examples/order-processing-worker/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { globalSetup: "@temporal-contract/testing/global-setup", reporters: ["default"], + setupFiles: ["./src/vitest.setup.ts"], coverage: { provider: "v8", reporter: ["text", "json", "json-summary", "html"], diff --git a/packages/client/package.json b/packages/client/package.json index 7c6aa3d3..3ce93c42 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -63,6 +63,7 @@ "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", "@vitest/coverage-v8": "catalog:", "tsdown": "catalog:", "typedoc": "catalog:", diff --git a/packages/client/src/__tests__/client.spec.ts b/packages/client/src/__tests__/client.spec.ts index 4bb35604..87bf5da2 100644 --- a/packages/client/src/__tests__/client.spec.ts +++ b/packages/client/src/__tests__/client.spec.ts @@ -94,7 +94,7 @@ describe("Client Package - Integration Tests", () => { }); // THEN - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: test-data" }); } @@ -113,14 +113,14 @@ describe("Client Package - Integration Tests", () => { }); // THEN - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; expect(handle.workflowId).toBe(workflowId); const result = await handle.result(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: async-test" }); } @@ -140,12 +140,12 @@ describe("Client Package - Integration Tests", () => { const handleResult = await client.getHandle("simpleWorkflow", workflowId); // THEN - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; const result = await handle.result(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: get-handle-test" }); } @@ -164,7 +164,7 @@ describe("Client Package - Integration Tests", () => { }); // THEN - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ result: "HELLO WORLD" }); } @@ -185,7 +185,7 @@ describe("Client Package - Integration Tests", () => { args: invalidInput as { value: string }, }); - expect(isErr(execution)).toBe(true); + expect(execution).toBeErr(); if (isErr(execution)) { expect(execution.error).toBeInstanceOf(WorkflowValidationError); } @@ -202,7 +202,7 @@ describe("Client Package - Integration Tests", () => { }); // THEN - Should succeed with proper validation - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); }); }); @@ -215,7 +215,7 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 10 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -225,7 +225,7 @@ describe("Client Package - Integration Tests", () => { // THEN - Workflow should complete with updated value const result = await handle.result(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ finalValue: 18 }); // 10 + 5 + 3 } @@ -239,7 +239,7 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 42 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -247,7 +247,7 @@ describe("Client Package - Integration Tests", () => { const queryResult = await handle.queries.getCurrentValue({}); // THEN - Should return current value - expect(isOk(queryResult)).toBe(true); + expect(queryResult).toBeOk(); if (isOk(queryResult)) { expect(queryResult.value).toEqual({ value: 42 }); } @@ -264,7 +264,7 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 5 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -272,14 +272,14 @@ describe("Client Package - Integration Tests", () => { const updateResult = await handle.updates.multiply({ factor: 3 }); // THEN - Update should return the new value - expect(isOk(updateResult)).toBe(true); + expect(updateResult).toBeOk(); if (isOk(updateResult)) { expect(updateResult.value).toEqual({ newValue: 15 }); // 5 * 3 } // Workflow should complete with the multiplied value const result = await handle.result(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ finalValue: 15 }); } @@ -295,7 +295,7 @@ describe("Client Package - Integration Tests", () => { args: { value: "describe-me" }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -303,7 +303,7 @@ describe("Client Package - Integration Tests", () => { const describeResult = await handle.describe(); // THEN - expect(isOk(describeResult)).toBe(true); + expect(describeResult).toBeOk(); if (isOk(describeResult)) { expect(describeResult.value).toEqual( expect.objectContaining({ workflowId, type: "simpleWorkflow" }), @@ -322,7 +322,7 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 10 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -330,11 +330,11 @@ describe("Client Package - Integration Tests", () => { const cancelResult = await handle.cancel(); // THEN - expect(isOk(cancelResult)).toBe(true); + expect(cancelResult).toBeOk(); // Result should throw or return error const result = await handle.result(); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); }); it("should terminate a running workflow", async ({ client }) => { @@ -345,7 +345,7 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 10 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -353,11 +353,11 @@ describe("Client Package - Integration Tests", () => { const terminateResult = await handle.terminate("Test termination"); // THEN - expect(isOk(terminateResult)).toBe(true); + expect(terminateResult).toBeOk(); // Result should throw or return error const result = await handle.result(); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); }); }); @@ -373,7 +373,7 @@ describe("Client Package - Integration Tests", () => { }); // THEN - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: test" }); } @@ -418,7 +418,7 @@ describe("Client Package - Integration Tests", () => { // THEN const mapped = result.map((value) => value.result.toUpperCase()); - expect(isOk(mapped)).toBe(true); + expect(mapped).toBeOk(); if (isOk(mapped)) { expect(mapped.value).toBe("PROCESSED: TEST"); } diff --git a/packages/client/src/client.spec.ts b/packages/client/src/client.spec.ts index c87576c8..6f5a0e11 100644 --- a/packages/client/src/client.spec.ts +++ b/packages/client/src/client.spec.ts @@ -167,7 +167,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual( expect.objectContaining({ @@ -189,7 +189,7 @@ describe("TypedClient", () => { args: { name: "hello", value: "not-a-number" as unknown as number }, }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } @@ -204,7 +204,7 @@ describe("TypedClient", () => { }, ); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } @@ -220,7 +220,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ result: "success" }); } @@ -240,7 +240,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } @@ -254,7 +254,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); }); }); @@ -294,7 +294,7 @@ describe("TypedClient", () => { signalArgs: [50], }); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value.workflowId).toBe("test-123"); expect(result.value.signaledRunId).toBe("run-abc"); @@ -321,7 +321,7 @@ describe("TypedClient", () => { }, ); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } @@ -337,7 +337,7 @@ describe("TypedClient", () => { signalArgs: [50], }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } @@ -353,7 +353,7 @@ describe("TypedClient", () => { signalArgs: ["not a number"], }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(SignalValidationError); } @@ -370,7 +370,7 @@ describe("TypedClient", () => { signalArgs: [50], }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("signalWithStart"); @@ -396,7 +396,7 @@ describe("TypedClient", () => { const result = await typedClient.getHandle("testWorkflow", "test-123"); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual(expect.objectContaining({ workflowId: "test-123" })); } @@ -408,7 +408,7 @@ describe("TypedClient", () => { "test-123", ); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } @@ -456,12 +456,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (isOk(handleResult)) { const result = await handleResult.value.result(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ result: "success" }); } @@ -476,12 +476,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (isOk(handleResult)) { const result = await handleResult.value.queries.getStatus([]); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual("running"); } @@ -494,12 +494,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (isOk(handleResult)) { const result = await handleResult.value.signals.updateProgress([50]); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); expect(mockHandle.signal).toHaveBeenCalledWith("updateProgress", [50]); } }); @@ -512,12 +512,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (isOk(handleResult)) { const result = await handleResult.value.updates.setConfig([{ value: "new-config" }]); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual(true); } @@ -530,12 +530,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (isOk(handleResult)) { const result = await handleResult.value.terminate("test reason"); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); expect(mockHandle.terminate).toHaveBeenCalledWith("test reason"); } }); @@ -546,12 +546,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (isOk(handleResult)) { const result = await handleResult.value.cancel(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); expect(mockHandle.cancel).toHaveBeenCalled(); } }); @@ -562,12 +562,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (isOk(handleResult)) { const result = await handleResult.value.describe(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual(expect.objectContaining({ workflowId: "test-123" })); } @@ -591,7 +591,7 @@ describe("TypedClient", () => { [123], ); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(QueryValidationError); expect((result.error as QueryValidationError).direction).toBe("input"); @@ -611,7 +611,7 @@ describe("TypedClient", () => { const result = await handleResult.value.queries.getStatus([]); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(QueryValidationError); expect((result.error as QueryValidationError).direction).toBe("output"); @@ -629,7 +629,7 @@ describe("TypedClient", () => { const result = await handleResult.value.queries.getStatus([]); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("query"); @@ -649,7 +649,7 @@ describe("TypedClient", () => { ["not a number"], ); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(SignalValidationError); } @@ -667,7 +667,7 @@ describe("TypedClient", () => { const result = await handleResult.value.signals.updateProgress([50]); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("signal"); @@ -688,7 +688,7 @@ describe("TypedClient", () => { [{ value: 99 }], ); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(UpdateValidationError); expect((result.error as UpdateValidationError).direction).toBe("input"); @@ -708,7 +708,7 @@ describe("TypedClient", () => { const result = await handleResult.value.updates.setConfig([{ value: "ok" }]); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(UpdateValidationError); expect((result.error as UpdateValidationError).direction).toBe("output"); @@ -726,7 +726,7 @@ describe("TypedClient", () => { const result = await handleResult.value.updates.setConfig([{ value: "ok" }]); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("update"); @@ -771,7 +771,7 @@ describe("TypedClient", () => { const mapped = result.map((value) => value.result.toUpperCase()); - expect(isOk(mapped)).toBe(true); + expect(mapped).toBeOk(); if (isOk(mapped)) { expect(mapped.value).toEqual("SUCCESS"); } @@ -840,7 +840,7 @@ describe("TypedClient", () => { }, }); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); const call = mockWorkflow.start.mock.calls[0]; expect(call?.[0]).toBe("processOrder"); const passed = call?.[1] as { typedSearchAttributes?: TypedSearchAttributes }; @@ -930,7 +930,7 @@ describe("TypedClient", () => { }, }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); const op = (result.error as RuntimeClientError).operation; @@ -959,7 +959,7 @@ describe("TypedClient", () => { }, ); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("searchAttributes"); @@ -1012,7 +1012,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowAlreadyStartedError); const err = result.error as WorkflowAlreadyStartedError; @@ -1030,7 +1030,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect(result.error).not.toBeInstanceOf(WorkflowAlreadyStartedError); @@ -1050,7 +1050,7 @@ describe("TypedClient", () => { signalArgs: [50], }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowAlreadyStartedError); } @@ -1066,7 +1066,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowAlreadyStartedError); } @@ -1088,7 +1088,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowFailedError); const err = result.error as WorkflowFailedError; @@ -1109,7 +1109,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); const err = result.error as WorkflowExecutionNotFoundError; @@ -1141,7 +1141,7 @@ describe("TypedClient", () => { if (!isOk(handleResult)) throw new Error("getHandle should succeed"); const result = await handleResult.value.result(); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowFailedError); const err = result.error as WorkflowFailedError; @@ -1178,7 +1178,7 @@ describe("TypedClient", () => { if (!isOk(handleResult)) throw new Error("getHandle should succeed"); const result = await handleResult.value.cancel(); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); const err = result.error as WorkflowExecutionNotFoundError; @@ -1208,7 +1208,7 @@ describe("TypedClient", () => { if (!isOk(handleResult)) throw new Error("getHandle should succeed"); const result = await handleResult.value.terminate("done"); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); } @@ -1234,7 +1234,7 @@ describe("TypedClient", () => { if (!isOk(handleResult)) throw new Error("getHandle should succeed"); const result = await handleResult.value.signals.updateProgress([50]); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); } @@ -1260,7 +1260,7 @@ describe("TypedClient", () => { if (!isOk(handleResult)) throw new Error("getHandle should succeed"); const result = await handleResult.value.describe(); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { const err = result.error as WorkflowExecutionNotFoundError; expect(err).toBeInstanceOf(WorkflowExecutionNotFoundError); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 7cc0adbe..c5ab1595 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -17,7 +17,7 @@ import type { ClientInferWorkflowSignals, ClientInferWorkflowUpdates, } from "./types.js"; -import { type AsyncResult, type Result, ok, err, isOk, isErr, fromPromise } from "unthrown"; +import { type AsyncResult, type Result, ok, err, isErr, fromPromise } from "unthrown"; import { type TemporalFailure, WorkflowAlreadyStartedError, @@ -32,6 +32,7 @@ import { } from "./errors.js"; import { TypedScheduleClient } from "./schedule.js"; import { + assertNoDefect, classifyHandleError, classifyResultError, classifyStartError, @@ -326,9 +327,10 @@ async function resolveDefinitionAndValidateInput< workflowName, searchAttributes, ); + // `toTypedSearchAttributes` only ever builds ok/err; assert away the + // impossible defect so `.error` / `.value` narrow cleanly. + assertNoDefect(searchAttributesResult); if (isErr(searchAttributesResult)) return err(searchAttributesResult.error); - // `toTypedSearchAttributes` only ever builds ok/err; a defect would be a bug. - if (!isOk(searchAttributesResult)) throw searchAttributesResult.cause; const typedSearchAttributes = searchAttributesResult.value; return ok({ @@ -469,9 +471,9 @@ export class TypedClient { args, searchAttributes as Record | undefined, ); + // The resolver only ever builds ok/err; assert away the impossible defect. + assertNoDefect(resolved); if (isErr(resolved)) return err(resolved.error); - // The resolver only ever builds ok/err; a defect would be a genuine bug. - if (!isOk(resolved)) throw resolved.cause; const { definition, validatedInput, typedSearchAttributes } = resolved.value; try { @@ -551,9 +553,9 @@ export class TypedClient { args, searchAttributes as Record | undefined, ); + // The resolver only ever builds ok/err; assert away the impossible defect. + assertNoDefect(resolved); if (isErr(resolved)) return err(resolved.error); - // The resolver only ever builds ok/err; a defect would be a genuine bug. - if (!isOk(resolved)) throw resolved.cause; const { definition, validatedInput, typedSearchAttributes } = resolved.value; // Validate signal input — call-site-specific, kept inline. @@ -646,9 +648,9 @@ export class TypedClient { args, searchAttributes as Record | undefined, ); + // The resolver only ever builds ok/err; assert away the impossible defect. + assertNoDefect(resolved); if (isErr(resolved)) return err(resolved.error); - // The resolver only ever builds ok/err; a defect would be a genuine bug. - if (!isOk(resolved)) throw resolved.cause; const { definition, validatedInput, typedSearchAttributes } = resolved.value; try { diff --git a/packages/client/src/internal.ts b/packages/client/src/internal.ts index 26ee37d8..e7cc6a24 100644 --- a/packages/client/src/internal.ts +++ b/packages/client/src/internal.ts @@ -16,6 +16,11 @@ import { import type { AnyWorkflowDefinition, SearchAttributeDefinition } from "@temporal-contract/contract"; import { _internal_makeAsyncResult } from "@temporal-contract/contract/result-async"; import { ok, err, type AsyncResult, type Result } from "unthrown"; + +// `assertNoDefect` narrows an internally-built `Result` (known to carry only +// ok/err) to `Ok | Err`, re-throwing a stray defect's cause — so call sites +// reach `.value` / `.error` without a manual "impossible defect" guard. +export { _internal_assertNoDefect as assertNoDefect } from "@temporal-contract/contract/result-async"; import { RuntimeClientError, type TemporalFailure, diff --git a/packages/client/src/schedule.spec.ts b/packages/client/src/schedule.spec.ts index 946b811f..e7460efc 100644 --- a/packages/client/src/schedule.spec.ts +++ b/packages/client/src/schedule.spec.ts @@ -84,7 +84,7 @@ describe("TypedClient.schedule", () => { args: { orderId: "sweep" }, }); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value.scheduleId).toBe("daily-sweep"); } @@ -114,7 +114,7 @@ describe("TypedClient.schedule", () => { }, ); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } @@ -129,7 +129,7 @@ describe("TypedClient.schedule", () => { args: { orderId: 123 }, }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } @@ -145,7 +145,7 @@ describe("TypedClient.schedule", () => { args: { orderId: "sweep" }, }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("schedule.create"); @@ -292,7 +292,7 @@ describe("TypedClient.schedule", () => { }, }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("searchAttributes"); @@ -310,16 +310,16 @@ describe("TypedClient.schedule", () => { const handle = client.schedule.getHandle("daily-sweep"); expect(handle.scheduleId).toBe("daily-sweep"); - expect((await handle.pause("test")).isOk()).toBe(true); + expect(await handle.pause("test")).toBeOk(); expect(tempHandle.pause).toHaveBeenCalledWith("test"); - expect((await handle.unpause()).isOk()).toBe(true); + expect(await handle.unpause()).toBeOk(); expect(tempHandle.unpause).toHaveBeenCalled(); - expect((await handle.trigger()).isOk()).toBe(true); + expect(await handle.trigger()).toBeOk(); expect(tempHandle.trigger).toHaveBeenCalled(); - expect((await handle.delete()).isOk()).toBe(true); + expect(await handle.delete()).toBeOk(); expect(tempHandle.delete).toHaveBeenCalled(); }); @@ -330,7 +330,7 @@ describe("TypedClient.schedule", () => { const handle = client.schedule.getHandle("missing"); const result = await handle.pause(); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("schedule.pause"); @@ -345,7 +345,7 @@ describe("TypedClient.schedule", () => { const handle = client.schedule.getHandle("daily-sweep"); const result = await handle.describe(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect((result.value as { scheduleId: string }).scheduleId).toBe("daily-sweep"); } diff --git a/packages/client/src/schedule.ts b/packages/client/src/schedule.ts index c95c4b9a..6193524b 100644 --- a/packages/client/src/schedule.ts +++ b/packages/client/src/schedule.ts @@ -8,11 +8,11 @@ import type { ScheduleSpec, } from "@temporalio/client"; import type { ContractDefinition } from "@temporal-contract/contract"; -import { type AsyncResult, type Result, ok, err, isOk, isErr, fromPromise } from "unthrown"; +import { type AsyncResult, type Result, ok, err, isErr, fromPromise } from "unthrown"; import type { TypedSearchAttributeMap } from "./client.js"; import type { ClientInferInput } from "./types.js"; import { RuntimeClientError, WorkflowNotFoundError, WorkflowValidationError } from "./errors.js"; -import { makeAsyncResult, toTypedSearchAttributes } from "./internal.js"; +import { assertNoDefect, makeAsyncResult, toTypedSearchAttributes } from "./internal.js"; /** * Workflow-action–level overrides forwarded to Temporal's @@ -151,10 +151,10 @@ export class TypedScheduleClient { workflowName, options.searchAttributes as Record | undefined, ); + // `toTypedSearchAttributes` only ever builds ok/err; assert away the + // impossible defect so `.error` / `.value` narrow cleanly. + assertNoDefect(searchAttributesResult); if (isErr(searchAttributesResult)) return err(searchAttributesResult.error); - // `toTypedSearchAttributes` only ever builds ok/err; a defect would be a - // genuine bug — re-throw so it rides the defect channel. - if (!isOk(searchAttributesResult)) throw searchAttributesResult.cause; const typedSearchAttributes = searchAttributesResult.value; try { diff --git a/packages/client/src/vitest.setup.ts b/packages/client/src/vitest.setup.ts new file mode 100644 index 00000000..0d6f5ac4 --- /dev/null +++ b/packages/client/src/vitest.setup.ts @@ -0,0 +1,6 @@ +// Registers `@unthrown/vitest`'s custom matchers (`toBeOk`, `toBeOkWith`, `toBeErr`, +// `toBeErrTagged`, `toBeDefect`) on Vitest's `expect`, and brings their +// `declare module "vitest"` type augmentation into the compilation so the +// matchers type-check in the spec files. Referenced from `setupFiles` in +// `vitest.config.ts`; not part of the package's published surface. +import "@unthrown/vitest"; diff --git a/packages/client/vitest.config.ts b/packages/client/vitest.config.ts index 87710892..12afe347 100644 --- a/packages/client/vitest.config.ts +++ b/packages/client/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ name: "unit", include: ["src/**/*.spec.ts"], exclude: ["src/**/__tests__/*.spec.ts"], + setupFiles: ["./src/vitest.setup.ts"], }, }, { @@ -22,6 +23,7 @@ export default defineConfig({ globalSetup: "@temporal-contract/testing/global-setup", include: ["src/**/__tests__/*.spec.ts"], testTimeout: 10_000, + setupFiles: ["./src/vitest.setup.ts"], }, }, ], diff --git a/packages/contract/package.json b/packages/contract/package.json index 5f57fbb1..aacbd953 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -63,6 +63,7 @@ "devDependencies": { "@temporal-contract/tsconfig": "workspace:*", "@temporal-contract/typedoc": "workspace:*", + "@unthrown/vitest": "catalog:", "@vitest/coverage-v8": "catalog:", "arktype": "catalog:", "tsdown": "catalog:", diff --git a/packages/contract/src/result-async.spec.ts b/packages/contract/src/result-async.spec.ts index 425c637e..18d921c7 100644 --- a/packages/contract/src/result-async.spec.ts +++ b/packages/contract/src/result-async.spec.ts @@ -10,7 +10,7 @@ * and `worker/src/internal.ts`. */ import { describe, expect, it } from "vitest"; -import { ok, err, isOk, isErr, isDefect } from "unthrown"; +import { ok, err, isErr, isDefect } from "unthrown"; import { _internal_makeAsyncResult } from "./result-async.js"; class TestError extends Error { @@ -23,16 +23,13 @@ class TestError extends Error { describe("_internal_makeAsyncResult", () => { it("returns ok(...) when the work function resolves with ok(...)", async () => { const result = await _internal_makeAsyncResult(async () => ok(42)); - expect(isOk(result)).toBe(true); - if (isOk(result)) { - expect(result.value).toBe(42); - } + expect(result).toBeOkWith(42); }); it("returns err(...) unchanged when the work function resolves with err(...)", async () => { const domainError = new TestError("domain"); const result = await _internal_makeAsyncResult(async () => err(domainError)); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { // Identity preserved — the domain `err(...)` flows through untouched. expect(result.error).toBe(domainError); @@ -49,7 +46,7 @@ describe("_internal_makeAsyncResult", () => { await Promise.resolve(); throw thrown; }); - expect(isDefect(result)).toBe(true); + expect(result).toBeDefect(); if (isDefect(result)) { expect(result.cause).toBe(thrown); } @@ -63,7 +60,7 @@ describe("_internal_makeAsyncResult", () => { const result = await _internal_makeAsyncResult(() => { throw thrown; }); - expect(isDefect(result)).toBe(true); + expect(result).toBeDefect(); if (isDefect(result)) { expect(result.cause).toBe(thrown); } @@ -77,7 +74,7 @@ describe("_internal_makeAsyncResult", () => { await Promise.resolve(); throw thrown; }); - expect(isDefect(result)).toBe(true); + expect(result).toBeDefect(); if (isDefect(result)) { expect(result.cause).toBe(thrown); } diff --git a/packages/contract/src/result-async.ts b/packages/contract/src/result-async.ts index 0710963e..2a4c6e04 100644 --- a/packages/contract/src/result-async.ts +++ b/packages/contract/src/result-async.ts @@ -10,7 +10,14 @@ * so users don't import it by accident — there is no semver guarantee on * this entry point. */ -import { fromSafePromise, type AsyncResult, type Result } from "unthrown"; +import { + fromSafePromise, + isDefect, + type AsyncResult, + type ErrView, + type OkView, + type Result, +} from "unthrown"; /** * Wrap an async function returning `Promise>` in an @@ -35,3 +42,26 @@ export function _internal_makeAsyncResult( ): AsyncResult { return fromSafePromise(work).flatMap((inner) => inner); } + +/** + * Assert that a `Result` is not a `Defect`, narrowing it to `Ok | Err`. + * + * unthrown's `Result` type always includes the out-of-band `Defect` + * variant, so `if (isErr(r)) … else r.value` does not type-check — the `else` + * branch is still `Ok | Defect`. For an internally-produced result that is + * *known* to be built only from `ok(...)` / `err(...)`, this collapses the + * "impossible defect" case in one call: it re-throws a present defect's cause + * (so a genuine bug still rides the defect channel at the boundary) and + * narrows the result to `Ok | Err` for the caller, which can then branch on + * `isErr` / `isOk` and reach `.value` / `.error` cleanly. + * + * @internal — exported under `_internal_assertNoDefect` for the sibling client + * and worker packages. Not part of the public API. + */ +export function _internal_assertNoDefect( + result: Result, +): asserts result is OkView | ErrView { + if (isDefect(result)) { + throw result.cause; + } +} diff --git a/packages/contract/src/vitest.setup.ts b/packages/contract/src/vitest.setup.ts new file mode 100644 index 00000000..15ce6fc0 --- /dev/null +++ b/packages/contract/src/vitest.setup.ts @@ -0,0 +1,6 @@ +// Registers `@unthrown/vitest`'s custom matchers (`toBeOk`, `toBeOkWith`, +// `toBeErr`, `toBeErrTagged`, `toBeDefect`) on Vitest's `expect`, and brings +// their `declare module "vitest"` type augmentation into the compilation so +// the matchers type-check in the spec files. Referenced from `setupFiles` in +// `vitest.config.ts`; not part of the package's published surface. +import "@unthrown/vitest"; diff --git a/packages/contract/vitest.config.ts b/packages/contract/vitest.config.ts index 57017e20..fc4a5d1e 100644 --- a/packages/contract/vitest.config.ts +++ b/packages/contract/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ name: "unit", include: ["src/**/*.spec.ts"], exclude: ["src/**/__tests__/*.spec.ts"], + setupFiles: ["./src/vitest.setup.ts"], }, }, ], diff --git a/packages/worker/package.json b/packages/worker/package.json index d05cc097..df461aa5 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -81,6 +81,7 @@ "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", "@vitest/coverage-v8": "catalog:", "tsdown": "catalog:", "typedoc": "catalog:", diff --git a/packages/worker/src/__tests__/worker.spec.ts b/packages/worker/src/__tests__/worker.spec.ts index 7fec438f..aac0279a 100644 --- a/packages/worker/src/__tests__/worker.spec.ts +++ b/packages/worker/src/__tests__/worker.spec.ts @@ -135,7 +135,7 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: test-data", @@ -156,14 +156,14 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; expect(handle.workflowId).toBe(workflowId); const result = await handle.result(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: async-test", @@ -185,12 +185,12 @@ describe("Worker Package - Integration Tests", () => { const handleResult = await client.getHandle("simpleWorkflow", workflowId); // THEN - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; const result = await handle.result(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ result: "Processed: get-handle-test", @@ -214,7 +214,7 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ orderId: "ORD-123", @@ -243,7 +243,7 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ orderId: "INVALID-123", @@ -267,7 +267,7 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ orderId: "ORD-456", @@ -292,7 +292,7 @@ describe("Worker Package - Integration Tests", () => { args: invalidInput, }); - expect(isErr(execution)).toBe(true); + expect(execution).toBeErr(); if (isErr(execution)) { expect(execution.error).toBeInstanceOf(WorkflowValidationError); } @@ -315,7 +315,7 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - Should succeed with proper validation - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); }); }); @@ -371,7 +371,7 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ results: ["Child 0 completed", "Child 1 completed", "Child 2 completed"], @@ -398,7 +398,7 @@ describe("Worker Package - Integration Tests", () => { args: { initialValue: 10 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -408,7 +408,7 @@ describe("Worker Package - Integration Tests", () => { // THEN - Workflow should complete with updated value const result = await handle.result(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ finalValue: 18, // 10 + 5 + 3 @@ -424,7 +424,7 @@ describe("Worker Package - Integration Tests", () => { args: { initialValue: 42 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -432,7 +432,7 @@ describe("Worker Package - Integration Tests", () => { const queryResult = await handle.queries.getCurrentValue({}); // THEN - Should return current value - expect(isOk(queryResult)).toBe(true); + expect(queryResult).toBeOk(); if (isOk(queryResult)) { expect(queryResult.value).toEqual({ value: 42, @@ -451,7 +451,7 @@ describe("Worker Package - Integration Tests", () => { args: { initialValue: 5 }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -459,7 +459,7 @@ describe("Worker Package - Integration Tests", () => { const updateResult = await handle.updates.multiply({ factor: 3 }); // THEN - Update should return the new value - expect(isOk(updateResult)).toBe(true); + expect(updateResult).toBeOk(); if (isOk(updateResult)) { expect(updateResult.value).toEqual({ newValue: 15, // 5 * 3 @@ -468,7 +468,7 @@ describe("Worker Package - Integration Tests", () => { // Workflow should complete with the multiplied value const result = await handle.result(); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toEqual({ finalValue: 15, @@ -486,7 +486,7 @@ describe("Worker Package - Integration Tests", () => { args: { value: "describe-me" }, }); - expect(isOk(handleResult)).toBe(true); + expect(handleResult).toBeOk(); if (!isOk(handleResult)) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -494,7 +494,7 @@ describe("Worker Package - Integration Tests", () => { const describeResult = await handle.describe(); // THEN - expect(isOk(describeResult)).toBe(true); + expect(describeResult).toBeOk(); if (isOk(describeResult)) { expect(describeResult.value).toEqual( expect.objectContaining({ @@ -523,7 +523,7 @@ describe("Worker Package - Integration Tests", () => { // THEN — at the workflow boundary Temporal wraps the activity's // ApplicationFailure in an ActivityFailure (cause is the original // ApplicationFailure with the type/message/details preserved). - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (!isErr(result)) throw new Error("Expected error result"); const error = result.error; expect(error.message).toMatch(/failableActivity failed/); diff --git a/packages/worker/src/activity.ts b/packages/worker/src/activity.ts index d807ee6f..f17b439c 100644 --- a/packages/worker/src/activity.ts +++ b/packages/worker/src/activity.ts @@ -242,6 +242,16 @@ export function declareActivitiesHandler( err: (error) => { throw error; }, + // A defect is an *unanticipated* throw inside the activity. Re-throw the + // original cause unwrapped: Temporal wraps a non-`ApplicationFailure` + // error as `ApplicationFailure(type: "Error")` and applies the default + // (retryable) policy — preserving the pre-unthrown behaviour where an + // uncaught activity throw was simply retried. We deliberately do NOT + // coerce it to `nonRetryable`: not every unexpected throw is permanent + // (a transient I/O fault is also "unmodeled"), and forcing fail-fast + // here would silently change retry semantics. An activity that wants a + // permanent failure should return `err(ApplicationFailure.create({ + // nonRetryable: true }))` explicitly. defect: (cause) => { throw cause; }, diff --git a/packages/worker/src/cancellation.spec.ts b/packages/worker/src/cancellation.spec.ts index 540550bb..cae7180a 100644 --- a/packages/worker/src/cancellation.spec.ts +++ b/packages/worker/src/cancellation.spec.ts @@ -45,7 +45,7 @@ const { WorkflowCancelledError } = await import("./errors.js"); describe("cancellableScope", () => { it("returns Result.Ok with the resolved value on success", async () => { const result = await cancellableScope(async () => 42); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toBe(42); } @@ -61,7 +61,7 @@ describe("cancellableScope", () => { const result = await cancellableScope(async () => { throw new Error(CANCEL_MARKER); }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowCancelledError); // Cause is preserved so debug tooling can see the underlying failure. @@ -77,7 +77,7 @@ describe("cancellableScope", () => { const result = await cancellableScope(async () => { throw original; }); - expect(isDefect(result)).toBe(true); + expect(result).toBeDefect(); if (isDefect(result)) { expect(result.cause).toBe(original); } @@ -91,7 +91,7 @@ describe("cancellableScope", () => { const result = await cancellableScope(() => { throw original; }); - expect(isDefect(result)).toBe(true); + expect(result).toBeDefect(); if (isDefect(result)) { expect(result.cause).toBe(original); } @@ -101,7 +101,7 @@ describe("cancellableScope", () => { describe("nonCancellableScope", () => { it("returns Result.Ok with the resolved value on success", async () => { const result = await nonCancellableScope(async () => "released"); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toBe("released"); } @@ -120,7 +120,7 @@ describe("nonCancellableScope", () => { const result = await nonCancellableScope(async () => { throw new Error(CANCEL_MARKER); }); - expect(isErr(result)).toBe(true); + expect(result).toBeErr(); if (isErr(result)) { expect(result.error).toBeInstanceOf(WorkflowCancelledError); } @@ -131,7 +131,7 @@ describe("nonCancellableScope", () => { const result = await nonCancellableScope(async () => { throw original; }); - expect(isDefect(result)).toBe(true); + expect(result).toBeDefect(); if (isDefect(result)) { expect(result.cause).toBe(original); } @@ -142,7 +142,7 @@ describe("nonCancellableScope", () => { const result = await nonCancellableScope(() => { throw original; }); - expect(isDefect(result)).toBe(true); + expect(result).toBeDefect(); if (isDefect(result)) { expect(result.cause).toBe(original); } @@ -155,7 +155,7 @@ describe("scope helpers accept synchronous callbacks", () => { // so workflows mutating purely-local state don't have to write `async () =>`. it("cancellableScope wraps a sync return as Result.Ok", async () => { const result = await cancellableScope(() => "sync-ok"); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toBe("sync-ok"); } @@ -163,7 +163,7 @@ describe("scope helpers accept synchronous callbacks", () => { it("nonCancellableScope wraps a sync return as Result.Ok", async () => { const result = await nonCancellableScope(() => 7); - expect(isOk(result)).toBe(true); + expect(result).toBeOk(); if (isOk(result)) { expect(result.value).toBe(7); } diff --git a/packages/worker/src/child-workflow.ts b/packages/worker/src/child-workflow.ts index efb31511..0d2dbe37 100644 --- a/packages/worker/src/child-workflow.ts +++ b/packages/worker/src/child-workflow.ts @@ -10,13 +10,14 @@ import { executeChild, startChild, } from "@temporalio/workflow"; -import { type AsyncResult, type Result, ok, err, isOk, isErr } from "unthrown"; +import { type AsyncResult, type Result, ok, err, isErr } from "unthrown"; import { ChildWorkflowCancelledError, ChildWorkflowError, ChildWorkflowNotFoundError, } from "./errors.js"; import { + assertNoDefect, classifyChildWorkflowError, formatChildWorkflowValidationMessage, makeAsyncResult, @@ -165,14 +166,12 @@ export function createStartChildWorkflow< options.args, ); + // `getAndValidateChildWorkflow` only ever builds ok/err; assert away the + // impossible defect so `.error` / `.value` narrow cleanly below. + assertNoDefect(validationResult); if (isErr(validationResult)) { return err(validationResult.error); } - // `getAndValidateChildWorkflow` only ever builds ok/err; a defect would be - // a genuine bug — re-throw so it rides the defect channel. - if (!isOk(validationResult)) { - throw validationResult.cause; - } const { definition: childDefinition, validatedInput, taskQueue } = validationResult.value; @@ -215,12 +214,10 @@ export function createExecuteChildWorkflow< options.args, ); + assertNoDefect(validationResult); if (isErr(validationResult)) { return err(validationResult.error); } - if (!isOk(validationResult)) { - throw validationResult.cause; - } const { definition: childDefinition, validatedInput, taskQueue } = validationResult.value; @@ -238,12 +235,10 @@ export function createExecuteChildWorkflow< childWorkflowName, ); + assertNoDefect(outputValidationResult); if (isErr(outputValidationResult)) { return err(outputValidationResult.error); } - if (!isOk(outputValidationResult)) { - throw outputValidationResult.cause; - } return ok(outputValidationResult.value as Ok); } catch (error) { diff --git a/packages/worker/src/internal.ts b/packages/worker/src/internal.ts index a763d0ae..0c82f870 100644 --- a/packages/worker/src/internal.ts +++ b/packages/worker/src/internal.ts @@ -39,7 +39,13 @@ export function formatChildWorkflowValidationMessage( // work functions identically to the client side. Unanticipated rejections // (a synchronous throw or a rejected promise from `work()`) are routed through // unthrown's `defect` channel rather than escaping as an unhandled rejection. -export { _internal_makeAsyncResult as makeAsyncResult } from "@temporal-contract/contract/result-async"; +// `assertNoDefect` narrows an internally-built `Result` (known to carry only +// ok/err) to `Ok | Err`, re-throwing a stray defect's cause — so call sites +// reach `.value` / `.error` without a manual "impossible defect" guard. +export { + _internal_makeAsyncResult as makeAsyncResult, + _internal_assertNoDefect as assertNoDefect, +} from "@temporal-contract/contract/result-async"; /** * Extract the single payload from a Temporal handler's `...args` array. diff --git a/packages/worker/src/vitest.setup.ts b/packages/worker/src/vitest.setup.ts new file mode 100644 index 00000000..0d6f5ac4 --- /dev/null +++ b/packages/worker/src/vitest.setup.ts @@ -0,0 +1,6 @@ +// Registers `@unthrown/vitest`'s custom matchers (`toBeOk`, `toBeOkWith`, `toBeErr`, +// `toBeErrTagged`, `toBeDefect`) on Vitest's `expect`, and brings their +// `declare module "vitest"` type augmentation into the compilation so the +// matchers type-check in the spec files. Referenced from `setupFiles` in +// `vitest.config.ts`; not part of the package's published surface. +import "@unthrown/vitest"; diff --git a/packages/worker/vitest.config.ts b/packages/worker/vitest.config.ts index 87710892..12afe347 100644 --- a/packages/worker/vitest.config.ts +++ b/packages/worker/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ name: "unit", include: ["src/**/*.spec.ts"], exclude: ["src/**/__tests__/*.spec.ts"], + setupFiles: ["./src/vitest.setup.ts"], }, }, { @@ -22,6 +23,7 @@ export default defineConfig({ globalSetup: "@temporal-contract/testing/global-setup", include: ["src/**/__tests__/*.spec.ts"], testTimeout: 10_000, + setupFiles: ["./src/vitest.setup.ts"], }, }, ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a67ecb2a..6da25e82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ catalogs: '@types/node': specifier: 24.13.2 version: 24.13.2 + '@unthrown/vitest': + specifier: 0.1.0 + version: 0.1.0 '@vitest/coverage-v8': specifier: 4.1.8 version: 4.1.8 @@ -60,9 +63,6 @@ catalogs: testcontainers: specifier: 12.0.3 version: 12.0.3 - ts-pattern: - specifier: 5.9.0 - version: 5.9.0 tsdown: specifier: 0.22.3 version: 0.22.3 @@ -179,9 +179,6 @@ importers: pino-pretty: specifier: 'catalog:' version: 13.1.3 - ts-pattern: - specifier: 'catalog:' - version: 5.9.0 unthrown: specifier: 'catalog:' version: 0.1.0 @@ -266,6 +263,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.13.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 0.1.0(vitest@4.1.8) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.8(vitest@4.1.8) @@ -312,6 +312,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.13.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 0.1.0(vitest@4.1.8) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.8(vitest@4.1.8) @@ -352,6 +355,9 @@ importers: '@temporal-contract/typedoc': specifier: workspace:* version: link:../../tools/typedoc + '@unthrown/vitest': + specifier: 'catalog:' + version: 0.1.0(vitest@4.1.8) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.8(vitest@4.1.8) @@ -453,6 +459,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.13.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 0.1.0(vitest@4.1.8) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.8(vitest@4.1.8) @@ -2544,6 +2553,12 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@unthrown/vitest@0.1.0': + resolution: {integrity: sha512-RaNWnzdNaxrxKqlH4culj46fqrmSxZC+YltxwLnFM4aUbIM1AcE/F2ed19kd39BLu2bq7mBHDdJb/X3+/40ASw==} + engines: {node: '>=22.19'} + peerDependencies: + vitest: ^4 + '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -4702,9 +4717,6 @@ packages: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} - ts-pattern@5.9.0: - resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} - tsdown@0.22.3: resolution: {integrity: sha512-louqbfA8Qf//B9jTTL0FPtXTNpjCWv1VPkbcmQMph2pTpzs+LnB1tbe4tDDRVpo2BjF5SgUXaTZe45SxB8pWHg==} engines: {node: ^22.18.0 || >=24.11.0} @@ -6858,6 +6870,11 @@ snapshots: '@ungap/structured-clone@1.3.1': {} + '@unthrown/vitest@0.1.0(vitest@4.1.8)': + dependencies: + unthrown: 0.1.0 + vitest: 4.1.8(@types/node@24.13.2)(@vitest/coverage-v8@4.1.8)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + '@upsetjs/venn.js@2.0.0': optionalDependencies: d3-selection: 3.0.0 @@ -9179,8 +9196,6 @@ snapshots: ts-dedent@2.2.0: {} - ts-pattern@5.9.0: {} - tsdown@0.22.3(oxc-resolver@11.20.0)(tsx@4.22.4)(typescript@6.0.3): dependencies: ansis: 4.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b08d3c70..68b01301 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,6 +54,7 @@ catalog: "@temporalio/worker": 1.18.1 "@temporalio/workflow": 1.18.1 "@types/node": 24.13.2 + "@unthrown/vitest": 0.1.0 "@vitest/coverage-v8": 4.1.8 arktype: 2.2.1 knip: 6.16.1 @@ -63,7 +64,6 @@ catalog: pino: 10.3.1 pino-pretty: 13.1.3 testcontainers: 12.0.3 - ts-pattern: 5.9.0 tsdown: 0.22.3 tsx: 4.22.4 turbo: 2.9.18 @@ -84,7 +84,8 @@ allowBuilds: ssh2: true minimumReleaseAgeStrict: true -# First-party btravstack package — the maturity delay guards against -# third-party supply-chain risk, which does not apply to our own org's library. +# First-party btravstack packages — the maturity delay guards against +# third-party supply-chain risk, which does not apply to our own org's libraries. minimumReleaseAgeExclude: - "unthrown" + - "@unthrown/vitest" From d7412b5216567d1f96b9a9c04f8b16c6e6f76fca Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 26 Jun 2026 18:34:42 +0200 Subject: [PATCH 5/6] feat!: upgrade to unthrown 0.2.0, adopt the narrowing + name fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unthrown 0.2.0 resolves the friction points surfaced during the migration: - `TaggedError(tag, { name })` decouples the discriminant from `Error.name`. Use it to set the package-namespaced `_tag` and the bare-class-name `Error.name` declaratively, removing the 14 manual `this.name = "..."` constructor overrides. - `.isOk()` / `.isErr()` / `.isDefect()` methods are now type guards (like the free functions). The codebase keeps using the free functions, but the docs and agent rules that claimed the methods "don't narrow" are corrected. - `fromPromise` now returns `AsyncResult>` and `matchTags` has an async overload — both available if needed; current call sites are unaffected. Bumps the `unthrown` / `@unthrown/vitest` catalog entries and the published packages' `unthrown` peer range to `^0.2` (the `{ name }` option is used at runtime, so 0.2.0 is the minimum). Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/rules/code-style.md | 2 +- .changeset/migrate-to-unthrown.md | 2 +- AGENTS.md | 2 +- docs/guide/migrating-to-unthrown.md | 31 ++++++++++++---------- docs/guide/result-pattern.md | 12 ++++----- packages/client/package.json | 2 +- packages/client/src/errors.ts | 37 ++++++++++++++------------ packages/contract/package.json | 2 +- packages/worker/package.json | 2 +- packages/worker/src/errors.ts | 15 +++++------ pnpm-lock.yaml | 40 ++++++++++++++--------------- pnpm-workspace.yaml | 4 +-- 12 files changed, 80 insertions(+), 71 deletions(-) diff --git a/.agents/rules/code-style.md b/.agents/rules/code-style.md index 018c721c..15ad2e8a 100644 --- a/.agents/rules/code-style.md +++ b/.agents/rules/code-style.md @@ -35,7 +35,7 @@ ApplicationFailure.create({ - Use unthrown's `Result` / `AsyncResult` instead of throwing exceptions - Activities return `AsyncResult` - Client methods return `AsyncResult` with specific error types -- Narrow results with the **free functions** `isOk(r)` / `isErr(r)` / `isDefect(r)` — the `r.isOk()` methods return plain `boolean` and do not narrow `.value` / `.error` / `.cause` +- Narrow results before reaching `.value` / `.error` / `.cause` — both the `r.isOk()` method and the `isOk(r)` free function are type guards (same for `isErr` / `isDefect`); the codebase prefers the free functions - An unanticipated throw surfaces on unthrown's third **`defect`** channel, not as a typed `err`; build error classes with `TaggedError("Name")<{ ...payload }>` - Wrap technical exceptions in `ApplicationFailure` (re-exported from `@temporal-contract/worker/activity`) with a `type` field; set `nonRetryable: true` for permanent failures diff --git a/.changeset/migrate-to-unthrown.md b/.changeset/migrate-to-unthrown.md index 610f0e98..0a6ef650 100644 --- a/.changeset/migrate-to-unthrown.md +++ b/.changeset/migrate-to-unthrown.md @@ -11,7 +11,7 @@ Replace `neverthrow` with [`unthrown`](https://github.com/btravstack/unthrown) f - **`ResultAsync` → `AsyncResult`.** Every activity, workflow-context, child-workflow, schedule, and typed-client method that returned a `ResultAsync` now returns an `AsyncResult`. The `unthrown` peer dependency replaces `neverthrow`. - **No `okAsync` / `errAsync`.** Lift a synchronous `Result` with `.toAsync()` instead: `ok(value).toAsync()`, `err(failure).toAsync()`. Promise boundaries use `fromPromise(promise, qualify)` / `fromSafePromise(promise)`. -- **Narrowing uses free functions.** `unthrown`'s `result.isOk()` / `isErr()` _methods_ return a plain `boolean` and do not narrow. Use `isOk(result)` / `isErr(result)` / `isDefect(result)` (imported from `unthrown`) before accessing `.value` / `.error` / `.cause`. +- **Narrow before accessing the payload.** Both the `result.isOk()` / `isErr()` / `isDefect()` methods and the matching free functions `isOk(result)` / `isErr(result)` / `isDefect(result)` (imported from `unthrown`) are type guards; the codebase uses the free functions. Narrow before touching `.value` / `.error` / `.cause`. - **New `defect` channel.** Unanticipated throws (a thrown exception the code did not model) now surface on `unthrown`'s third `defect` channel — inspected via `isDefect(result)` / `result.cause` and re-thrown at the edge — rather than as a typed `err`. Deliberate boundary classification (e.g. mapping a Temporal SDK rejection to `WorkflowExecutionNotFoundError`) still produces a modeled `err`. `result.match({ ok, err, defect })` folds all three. - **`WorkflowScopeError` removed.** Non-cancellation errors thrown inside `cancellableScope` / `nonCancellableScope` are unmodeled failures and now ride the `defect` channel. The scopes' error union narrows to `WorkflowCancelledError`. - **The client's "unexpected" `RuntimeClientError` wrap is gone.** An unanticipated rejection in a client operation now surfaces as a defect, not a manufactured `RuntimeClientError`. `RuntimeClientError` is still produced by deliberate boundary classification. diff --git a/AGENTS.md b/AGENTS.md index 84e12981..250c87d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ This file is the source of truth for agent guidance in this repo. `CLAUDE.md` an ## The 6 rules that prevent broken PRs 1. **Workflow code is deterministic.** No `Date.now()`, `Math.random()`, `setTimeout`, `crypto.randomUUID()`, native I/O, or `process.env` reads inside `declareWorkflow`'s `implementation`. Use `@temporalio/workflow` primitives (`sleep`, `uuid4`, the patched `Date`) or push the side effect into an activity. See [.agents/rules/workflow-determinism.md](.agents/rules/workflow-determinism.md). This is the #1 cause of broken Temporal workflows — read that file before touching workflow code. -2. **Activities and the typed client return `AsyncResult` from `unthrown`.** Never throw — wrap technical errors in `ApplicationFailure` and surface them via `err(...).toAsync()` (or `fromPromise(promise, qualify)`, where `qualify` returns the modeled error `E`). unthrown has no `okAsync`/`errAsync`: lift a sync `Result` with `.toAsync()`. The client uses unthrown's `Result` for sync returns. unthrown adds a third **`defect`** channel for _unanticipated_ failures — a thrown exception the code didn't model surfaces as a defect (inspectable via `isDefect(result)` / `result.cause`, re-thrown at the edge), not a typed `err`. Narrow with the **free functions** `isOk(r)`/`isErr(r)`/`isDefect(r)` (the `r.isOk()` _methods_ return plain `boolean` and do **not** narrow). Error classes are built with `TaggedError("@temporal-contract/Name")<{ ...payload }>` — the `_tag` is package-namespaced to avoid collisions; each constructor overrides `this.name` back to the bare class name for readable logs. The worker's `ValidationError` subclasses are the exception — they must stay `ApplicationFailure` for Temporal's terminal-failure semantics. There is no `neverthrow`, no `@swan-io/boxed`, and no `@temporal-contract/boxed` package — those were removed. +2. **Activities and the typed client return `AsyncResult` from `unthrown`.** Never throw — wrap technical errors in `ApplicationFailure` and surface them via `err(...).toAsync()` (or `fromPromise(promise, qualify)`, where `qualify` returns the modeled error `E`). unthrown has no `okAsync`/`errAsync`: lift a sync `Result` with `.toAsync()`. The client uses unthrown's `Result` for sync returns. unthrown adds a third **`defect`** channel for _unanticipated_ failures — a thrown exception the code didn't model surfaces as a defect (inspectable via `isDefect(result)` / `result.cause`, re-thrown at the edge), not a typed `err`. Narrow before reaching `.value`/`.error`/`.cause` — both the `r.isOk()` method and the `isOk(r)` free function are type guards (same for `isErr`/`isDefect`); the codebase uses the free functions. Error classes are built with `TaggedError("@temporal-contract/Name", { name: "Name" })<{ ...payload }>` — the `_tag` is package-namespaced to avoid collisions, while `options.name` keeps `Error.name` the bare class name for readable logs. The worker's `ValidationError` subclasses are the exception — they must stay `ApplicationFailure` for Temporal's terminal-failure semantics. There is no `neverthrow`, no `@swan-io/boxed`, and no `@temporal-contract/boxed` package — those were removed. 3. **No `any`.** Use `unknown` and narrow. Enforced by oxlint. 4. **`.js` extensions in every import.** TypeScript files import each other as `./foo.js`, never `./foo` or `./foo.ts`. Required by ESM module resolution. 5. **ESM only.** All packages are `"type": "module"`. No CommonJS in source. diff --git a/docs/guide/migrating-to-unthrown.md b/docs/guide/migrating-to-unthrown.md index 3e8363a3..9ca58eef 100644 --- a/docs/guide/migrating-to-unthrown.md +++ b/docs/guide/migrating-to-unthrown.md @@ -82,23 +82,28 @@ it to an `AsyncResult` with `.toAsync()`: + return err(new MyError()).toAsync(); ``` -## Narrowing: free functions, not methods +## Narrowing: methods or free functions -In neverthrow you narrowed with the `.isOk()` / `.isErr()` **methods**. In -unthrown those methods return a plain boolean and do **not** narrow the type. -Use the free functions `isOk` / `isErr` / `isDefect` instead: +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 +prefers the free functions, but either reaches `.value` / `.error` / `.cause`: -```diff -- if (result.isErr()) { -+ import { isErr } from "unthrown"; -+ -+ if (isErr(result)) { - console.error(result.error); - return; - } - console.log(result.value); +```ts +import { isErr } from "unthrown"; + +if (isErr(result)) { + console.error(result.error); + return; +} +// result is narrowed to `Ok | Defect` here — a `Defect` still needs handling +// (see below) before `.value` is reachable ``` +> [!NOTE] +> 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: diff --git a/docs/guide/result-pattern.md b/docs/guide/result-pattern.md index 7137c14e..797c4d96 100644 --- a/docs/guide/result-pattern.md +++ b/docs/guide/result-pattern.md @@ -43,12 +43,12 @@ pnpm add unthrown so the type behaves like a lazy task — call sites that already `await` the value before checking `isOk(result)` / `isErr(result)` need no changes. -> [!IMPORTANT] -> unthrown narrows with **free functions** — `isOk(result)`, `isErr(result)`, -> `isDefect(result)` imported from `"unthrown"`. The `result.isOk()` / -> `result.isErr()` **methods** return a plain boolean and do **not** narrow -> the type, so reach for the free functions before touching `.value` / -> `.error` / `.cause`. +> [!TIP] +> Narrow before touching `.value` / `.error` / `.cause`. Both forms are type +> guards: the `result.isOk()` / `result.isErr()` / `result.isDefect()` +> **methods** and the matching **free functions** `isOk(result)` / +> `isErr(result)` / `isDefect(result)` imported from `"unthrown"`. This +> codebase uses the free functions; either works. ## Basic Usage diff --git a/packages/client/package.json b/packages/client/package.json index 3ce93c42..bd7d33fc 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -76,7 +76,7 @@ "peerDependencies": { "@temporalio/client": "^1", "@temporalio/common": "^1", - "unthrown": "^0.1" + "unthrown": "^0.2" }, "engines": { "node": ">=22.19.0" diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 3708b0b1..ff4de8a7 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -33,7 +33,9 @@ export type TemporalFailure = /** * Generic runtime failure wrapper when no specific error type applies */ -export class RuntimeClientError extends TaggedError("@temporal-contract/RuntimeClientError")<{ +export class RuntimeClientError extends TaggedError("@temporal-contract/RuntimeClientError", { + name: "RuntimeClientError", +})<{ operation: string; cause?: unknown; message: string; @@ -46,16 +48,16 @@ export class RuntimeClientError extends TaggedError("@temporal-contract/RuntimeC cause instanceof Error ? cause.message : String(cause ?? "unknown error") }`, }); - // Keep the conventional class name in logs; the package namespace lives // only on `_tag`. - this.name = "RuntimeClientError"; } } /** * Thrown when a workflow is not found in the contract */ -export class WorkflowNotFoundError extends TaggedError("@temporal-contract/WorkflowNotFoundError")<{ +export class WorkflowNotFoundError extends TaggedError("@temporal-contract/WorkflowNotFoundError", { + name: "WorkflowNotFoundError", +})<{ workflowName: string; availableWorkflows: string[]; message: string; @@ -66,7 +68,6 @@ export class WorkflowNotFoundError extends TaggedError("@temporal-contract/Workf availableWorkflows, message: `Workflow "${workflowName}" not found in contract. Available workflows: ${availableWorkflows.join(", ")}`, }); - this.name = "WorkflowNotFoundError"; } } @@ -83,6 +84,7 @@ export class WorkflowNotFoundError extends TaggedError("@temporal-contract/Workf */ export class WorkflowAlreadyStartedError extends TaggedError( "@temporal-contract/WorkflowAlreadyStartedError", + { name: "WorkflowAlreadyStartedError" }, )<{ workflowType: string; workflowId: string; @@ -96,7 +98,6 @@ export class WorkflowAlreadyStartedError extends TaggedError( cause, message: `Workflow "${workflowType}" with ID "${workflowId}" is already started or in retention.`, }); - this.name = "WorkflowAlreadyStartedError"; } } @@ -114,6 +115,7 @@ export class WorkflowAlreadyStartedError extends TaggedError( */ export class WorkflowExecutionNotFoundError extends TaggedError( "@temporal-contract/WorkflowExecutionNotFoundError", + { name: "WorkflowExecutionNotFoundError" }, )<{ workflowId: string; runId?: string | undefined; @@ -127,7 +129,6 @@ export class WorkflowExecutionNotFoundError extends TaggedError( cause, message: `Workflow execution "${workflowId}"${runId ? ` (run "${runId}")` : ""} not found in namespace.`, }); - this.name = "WorkflowExecutionNotFoundError"; } } @@ -150,7 +151,9 @@ export class WorkflowExecutionNotFoundError extends TaggedError( * * Returned from `executeWorkflow` and `handle.result()`. */ -export class WorkflowFailedError extends TaggedError("@temporal-contract/WorkflowFailedError")<{ +export class WorkflowFailedError extends TaggedError("@temporal-contract/WorkflowFailedError", { + name: "WorkflowFailedError", +})<{ workflowId: string; cause?: TemporalFailure | undefined; message: string; @@ -163,7 +166,6 @@ export class WorkflowFailedError extends TaggedError("@temporal-contract/Workflo cause, message: `Workflow "${workflowId}" completed with failure: ${causeMessage}`, }); - this.name = "WorkflowFailedError"; } } @@ -177,6 +179,7 @@ export class WorkflowFailedError extends TaggedError("@temporal-contract/Workflo */ export class WorkflowValidationError extends TaggedError( "@temporal-contract/WorkflowValidationError", + { name: "WorkflowValidationError" }, )<{ workflowName: string; direction: "input" | "output"; @@ -194,14 +197,15 @@ export class WorkflowValidationError extends TaggedError( issues, message: `Validation failed for workflow "${workflowName}" ${direction}: ${summarizeIssues(issues)}`, }); - this.name = "WorkflowValidationError"; } } /** * Thrown when query input or output validation fails */ -export class QueryValidationError extends TaggedError("@temporal-contract/QueryValidationError")<{ +export class QueryValidationError extends TaggedError("@temporal-contract/QueryValidationError", { + name: "QueryValidationError", +})<{ queryName: string; direction: "input" | "output"; issues: ReadonlyArray; @@ -218,14 +222,15 @@ export class QueryValidationError extends TaggedError("@temporal-contract/QueryV issues, message: `Validation failed for query "${queryName}" ${direction}: ${summarizeIssues(issues)}`, }); - this.name = "QueryValidationError"; } } /** * Thrown when signal input validation fails */ -export class SignalValidationError extends TaggedError("@temporal-contract/SignalValidationError")<{ +export class SignalValidationError extends TaggedError("@temporal-contract/SignalValidationError", { + name: "SignalValidationError", +})<{ signalName: string; issues: ReadonlyArray; message: string; @@ -236,14 +241,15 @@ export class SignalValidationError extends TaggedError("@temporal-contract/Signa issues, message: `Validation failed for signal "${signalName}": ${summarizeIssues(issues)}`, }); - this.name = "SignalValidationError"; } } /** * Thrown when update input or output validation fails */ -export class UpdateValidationError extends TaggedError("@temporal-contract/UpdateValidationError")<{ +export class UpdateValidationError extends TaggedError("@temporal-contract/UpdateValidationError", { + name: "UpdateValidationError", +})<{ updateName: string; direction: "input" | "output"; issues: ReadonlyArray; @@ -260,6 +266,5 @@ export class UpdateValidationError extends TaggedError("@temporal-contract/Updat issues, message: `Validation failed for update "${updateName}" ${direction}: ${summarizeIssues(issues)}`, }); - this.name = "UpdateValidationError"; } } diff --git a/packages/contract/package.json b/packages/contract/package.json index aacbd953..b58de22b 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -75,7 +75,7 @@ "vitest": "catalog:" }, "peerDependencies": { - "unthrown": "^0.1" + "unthrown": "^0.2" }, "peerDependenciesMeta": { "unthrown": { diff --git a/packages/worker/package.json b/packages/worker/package.json index df461aa5..bd18cc08 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -95,7 +95,7 @@ "@temporalio/common": "^1", "@temporalio/worker": "^1", "@temporalio/workflow": "^1", - "unthrown": "^0.1" + "unthrown": "^0.2" }, "engines": { "node": ">=22.19.0" diff --git a/packages/worker/src/errors.ts b/packages/worker/src/errors.ts index 3c7641a7..48f9a3c6 100644 --- a/packages/worker/src/errors.ts +++ b/packages/worker/src/errors.ts @@ -63,6 +63,7 @@ export abstract class ValidationError extends ApplicationFailure { */ export class ActivityDefinitionNotFoundError extends TaggedError( "@temporal-contract/ActivityDefinitionNotFoundError", + { name: "ActivityDefinitionNotFoundError" }, )<{ activityName: string; availableDefinitions: readonly string[]; @@ -75,9 +76,6 @@ export class ActivityDefinitionNotFoundError extends TaggedError( availableDefinitions, message: `Activity definition not found for: "${activityName}". Available activities: ${available}`, }); - // Keep the conventional class name in logs/stack traces; the package - // namespace lives only on `_tag`. - this.name = "ActivityDefinitionNotFoundError"; } } @@ -239,6 +237,7 @@ export class UpdateOutputValidationError extends ValidationError { */ export class ChildWorkflowNotFoundError extends TaggedError( "@temporal-contract/ChildWorkflowNotFoundError", + { name: "ChildWorkflowNotFoundError" }, )<{ workflowName: string; availableWorkflows: readonly string[]; @@ -251,7 +250,6 @@ export class ChildWorkflowNotFoundError extends TaggedError( availableWorkflows, message: `Child workflow not found: "${workflowName}". Available workflows: ${available}`, }); - this.name = "ChildWorkflowNotFoundError"; } } @@ -264,13 +262,14 @@ export class ChildWorkflowNotFoundError extends TaggedError( * mirroring the client-side `WorkflowFailedError.cause` behavior, so callers * can branch on the failure category in one step instead of unwrapping twice. */ -export class ChildWorkflowError extends TaggedError("@temporal-contract/ChildWorkflowError")<{ +export class ChildWorkflowError extends TaggedError("@temporal-contract/ChildWorkflowError", { + name: "ChildWorkflowError", +})<{ message: string; cause?: unknown; }> { constructor(message: string, cause?: unknown) { super({ message, cause }); - this.name = "ChildWorkflowError"; } } @@ -291,6 +290,7 @@ export class ChildWorkflowError extends TaggedError("@temporal-contract/ChildWor */ export class ChildWorkflowCancelledError extends TaggedError( "@temporal-contract/ChildWorkflowCancelledError", + { name: "ChildWorkflowCancelledError" }, )<{ workflowName: string; cause?: unknown; @@ -298,7 +298,6 @@ export class ChildWorkflowCancelledError extends TaggedError( }> { constructor(workflowName: string, cause?: unknown) { super({ workflowName, cause, message: `Child workflow "${workflowName}" was cancelled` }); - this.name = "ChildWorkflowCancelledError"; } } @@ -316,12 +315,12 @@ export class ChildWorkflowCancelledError extends TaggedError( */ export class WorkflowCancelledError extends TaggedError( "@temporal-contract/WorkflowCancelledError", + { name: "WorkflowCancelledError" }, )<{ cause?: unknown; message: string; }> { constructor(cause?: unknown) { super({ cause, message: "Workflow cancellation scope was cancelled" }); - this.name = "WorkflowCancelledError"; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6da25e82..70b2ade0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,8 +34,8 @@ catalogs: specifier: 24.13.2 version: 24.13.2 '@unthrown/vitest': - specifier: 0.1.0 - version: 0.1.0 + specifier: 0.2.0 + version: 0.2.0 '@vitest/coverage-v8': specifier: 4.1.8 version: 4.1.8 @@ -82,8 +82,8 @@ catalogs: specifier: 6.0.3 version: 6.0.3 unthrown: - specifier: 0.1.0 - version: 0.1.0 + specifier: 0.2.0 + version: 0.2.0 valibot: specifier: 1.4.1 version: 1.4.1 @@ -181,7 +181,7 @@ importers: version: 13.1.3 unthrown: specifier: 'catalog:' - version: 0.1.0 + version: 0.2.0 zod: specifier: 'catalog:' version: 4.4.3 @@ -243,7 +243,7 @@ importers: version: 13.1.3 unthrown: specifier: 'catalog:' - version: 0.1.0 + version: 0.2.0 zod: specifier: 'catalog:' version: 4.4.3 @@ -265,7 +265,7 @@ importers: version: 24.13.2 '@unthrown/vitest': specifier: 'catalog:' - version: 0.1.0(vitest@4.1.8) + version: 0.2.0(vitest@4.1.8) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.8(vitest@4.1.8) @@ -314,7 +314,7 @@ importers: version: 24.13.2 '@unthrown/vitest': specifier: 'catalog:' - version: 0.1.0(vitest@4.1.8) + version: 0.2.0(vitest@4.1.8) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.8(vitest@4.1.8) @@ -332,7 +332,7 @@ importers: version: 6.0.3 unthrown: specifier: 'catalog:' - version: 0.1.0 + version: 0.2.0 vitest: specifier: 'catalog:' version: 4.1.8(@types/node@24.13.2)(@vitest/coverage-v8@4.1.8)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) @@ -357,7 +357,7 @@ importers: version: link:../../tools/typedoc '@unthrown/vitest': specifier: 'catalog:' - version: 0.1.0(vitest@4.1.8) + version: 0.2.0(vitest@4.1.8) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.8(vitest@4.1.8) @@ -378,7 +378,7 @@ importers: version: 6.0.3 unthrown: specifier: 'catalog:' - version: 0.1.0 + version: 0.2.0 valibot: specifier: 'catalog:' version: 1.4.1(typescript@6.0.3) @@ -461,7 +461,7 @@ importers: version: 24.13.2 '@unthrown/vitest': specifier: 'catalog:' - version: 0.1.0(vitest@4.1.8) + version: 0.2.0(vitest@4.1.8) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.8(vitest@4.1.8) @@ -479,7 +479,7 @@ importers: version: 6.0.3 unthrown: specifier: 'catalog:' - version: 0.1.0 + version: 0.2.0 vitest: specifier: 'catalog:' version: 4.1.8(@types/node@24.13.2)(@vitest/coverage-v8@4.1.8)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) @@ -2553,8 +2553,8 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} - '@unthrown/vitest@0.1.0': - resolution: {integrity: sha512-RaNWnzdNaxrxKqlH4culj46fqrmSxZC+YltxwLnFM4aUbIM1AcE/F2ed19kd39BLu2bq7mBHDdJb/X3+/40ASw==} + '@unthrown/vitest@0.2.0': + resolution: {integrity: sha512-0IavcCbccDw5l6NI9Y8EkF+7tDZkz7mYfYalzsYdPMW5z7f/mTRiaUJkZ6qgd4bVPuD1TiQmKTyK3CFZXGi76Q==} engines: {node: '>=22.19'} peerDependencies: vitest: ^4 @@ -4826,8 +4826,8 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} - unthrown@0.1.0: - resolution: {integrity: sha512-UkSp74t10tusRkbYJ1N+TMrGJHSVlfgNgdftUZnmf60StSl/MnujHOKH9vZnvnFq8uUg+vlN/9s4lWXq2Lix7w==} + unthrown@0.2.0: + resolution: {integrity: sha512-scackbGqniNj2OhQvsr+3x1HaVqcG7NWDrEViTZgtXcalayTl40DDB/0Ck3xkZay/Z8+oGWnFhqZD3RmpqHLUQ==} engines: {node: '>=22.19'} update-browserslist-db@1.2.3: @@ -6870,9 +6870,9 @@ snapshots: '@ungap/structured-clone@1.3.1': {} - '@unthrown/vitest@0.1.0(vitest@4.1.8)': + '@unthrown/vitest@0.2.0(vitest@4.1.8)': dependencies: - unthrown: 0.1.0 + unthrown: 0.2.0 vitest: 4.1.8(@types/node@24.13.2)(@vitest/coverage-v8@4.1.8)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) '@upsetjs/venn.js@2.0.0': @@ -9300,7 +9300,7 @@ snapshots: universalify@0.1.2: {} - unthrown@0.1.0: {} + unthrown@0.2.0: {} update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 68b01301..dedfc9aa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,7 +54,7 @@ catalog: "@temporalio/worker": 1.18.1 "@temporalio/workflow": 1.18.1 "@types/node": 24.13.2 - "@unthrown/vitest": 0.1.0 + "@unthrown/vitest": 0.2.0 "@vitest/coverage-v8": 4.1.8 arktype: 2.2.1 knip: 6.16.1 @@ -70,7 +70,7 @@ catalog: typedoc: 0.28.19 typedoc-plugin-markdown: 4.12.0 typescript: 6.0.3 - unthrown: 0.1.0 + unthrown: 0.2.0 valibot: 1.4.1 vitest: 4.1.8 zod: 4.4.3 From e102856b0f6f4d86e9c1fa956b9fbbb4be1c78ff Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 26 Jun 2026 18:45:58 +0200 Subject: [PATCH 6/6] refactor: use the unthrown method-form narrowing (isOk/isErr/isDefect) Now that unthrown 0.2.0 makes `.isOk()` / `.isErr()` / `.isDefect()` type guards, switch every narrowing site from the free-function form `isOk(r)` to the method form `r.isOk()` and drop the now-unused free-function imports. `assertNoDefect` stays (a `Result` still always includes `Defect`, so it discharges the impossible case before `.value`/`.error`). Docs, agent rules, and the changeset updated to reflect the method form. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/rules/code-style.md | 2 +- .changeset/migrate-to-unthrown.md | 4 +- AGENTS.md | 2 +- docs/guide/migrating-to-unthrown.md | 8 +- docs/guide/result-pattern.md | 2 +- .../src/integration.spec.ts | 19 ++- packages/client/src/__tests__/client.spec.ts | 43 ++++--- packages/client/src/client.spec.ts | 117 +++++++++--------- packages/client/src/client.ts | 12 +- packages/client/src/schedule.spec.ts | 15 ++- packages/client/src/schedule.ts | 4 +- packages/contract/src/result-async.spec.ts | 10 +- packages/contract/src/result-async.ts | 5 +- .../worker/src/__tests__/test.workflows.ts | 5 +- packages/worker/src/__tests__/worker.spec.ts | 42 +++---- packages/worker/src/cancellation.spec.ts | 21 ++-- packages/worker/src/child-workflow.ts | 8 +- packages/worker/src/workflow.ts | 3 +- 18 files changed, 156 insertions(+), 166 deletions(-) diff --git a/.agents/rules/code-style.md b/.agents/rules/code-style.md index 15ad2e8a..9f178008 100644 --- a/.agents/rules/code-style.md +++ b/.agents/rules/code-style.md @@ -35,7 +35,7 @@ ApplicationFailure.create({ - Use unthrown's `Result` / `AsyncResult` instead of throwing exceptions - Activities return `AsyncResult` - Client methods return `AsyncResult` with specific error types -- Narrow results before reaching `.value` / `.error` / `.cause` — both the `r.isOk()` method and the `isOk(r)` free function are type guards (same for `isErr` / `isDefect`); the codebase prefers the free functions +- Narrow results before reaching `.value` / `.error` / `.cause` — both the `r.isOk()` method and the `isOk(r)` free function are type guards (same for `isErr` / `isDefect`); the codebase uses the methods - An unanticipated throw surfaces on unthrown's third **`defect`** channel, not as a typed `err`; build error classes with `TaggedError("Name")<{ ...payload }>` - Wrap technical exceptions in `ApplicationFailure` (re-exported from `@temporal-contract/worker/activity`) with a `type` field; set `nonRetryable: true` for permanent failures diff --git a/.changeset/migrate-to-unthrown.md b/.changeset/migrate-to-unthrown.md index 0a6ef650..e6a94b49 100644 --- a/.changeset/migrate-to-unthrown.md +++ b/.changeset/migrate-to-unthrown.md @@ -11,8 +11,8 @@ Replace `neverthrow` with [`unthrown`](https://github.com/btravstack/unthrown) f - **`ResultAsync` → `AsyncResult`.** Every activity, workflow-context, child-workflow, schedule, and typed-client method that returned a `ResultAsync` now returns an `AsyncResult`. The `unthrown` peer dependency replaces `neverthrow`. - **No `okAsync` / `errAsync`.** Lift a synchronous `Result` with `.toAsync()` instead: `ok(value).toAsync()`, `err(failure).toAsync()`. Promise boundaries use `fromPromise(promise, qualify)` / `fromSafePromise(promise)`. -- **Narrow before accessing the payload.** Both the `result.isOk()` / `isErr()` / `isDefect()` methods and the matching free functions `isOk(result)` / `isErr(result)` / `isDefect(result)` (imported from `unthrown`) are type guards; the codebase uses the free functions. Narrow before touching `.value` / `.error` / `.cause`. -- **New `defect` channel.** Unanticipated throws (a thrown exception the code did not model) now surface on `unthrown`'s third `defect` channel — inspected via `isDefect(result)` / `result.cause` and re-thrown at the edge — rather than as a typed `err`. Deliberate boundary classification (e.g. mapping a Temporal SDK rejection to `WorkflowExecutionNotFoundError`) still produces a modeled `err`. `result.match({ ok, err, defect })` folds all three. +- **Narrow before accessing the payload.** Both the `result.isOk()` / `isErr()` / `isDefect()` methods and the matching free functions `isOk(result)` / `isErr(result)` / `isDefect(result)` (imported from `unthrown`) are type guards; the codebase uses the methods. Narrow before touching `.value` / `.error` / `.cause`. +- **New `defect` channel.** Unanticipated throws (a thrown exception the code did not model) now surface on `unthrown`'s third `defect` channel — inspected via `result.isDefect()` / `result.cause` and re-thrown at the edge — rather than as a typed `err`. Deliberate boundary classification (e.g. mapping a Temporal SDK rejection to `WorkflowExecutionNotFoundError`) still produces a modeled `err`. `result.match({ ok, err, defect })` folds all three. - **`WorkflowScopeError` removed.** Non-cancellation errors thrown inside `cancellableScope` / `nonCancellableScope` are unmodeled failures and now ride the `defect` channel. The scopes' error union narrows to `WorkflowCancelledError`. - **The client's "unexpected" `RuntimeClientError` wrap is gone.** An unanticipated rejection in a client operation now surfaces as a defect, not a manufactured `RuntimeClientError`. `RuntimeClientError` is still produced by deliberate boundary classification. - **Error classes use `TaggedError`.** The worker `WorkerError` hierarchy and the entire client `TypedClientError` hierarchy are now built with `unthrown`'s `TaggedError`, each carrying a `_tag` discriminant (foldable with `matchTags`). The `_tag` is **package-namespaced** — e.g. `"@temporal-contract/WorkflowExecutionNotFoundError"` — so it never collides with a consumer's own tags; each error's `.name` stays the bare class name for readable logs. `ChildWorkflowCancelledError` is now a sibling of `ChildWorkflowError` (distinct `_tag`) rather than a subclass — discriminate on `_tag` / `instanceof ChildWorkflowCancelledError` instead of relying on `instanceof ChildWorkflowError` matching cancellation. The worker's `ValidationError` subclasses are unchanged — they still extend Temporal's `ApplicationFailure` for terminal-failure semantics. diff --git a/AGENTS.md b/AGENTS.md index 250c87d3..01df1e31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ This file is the source of truth for agent guidance in this repo. `CLAUDE.md` an ## The 6 rules that prevent broken PRs 1. **Workflow code is deterministic.** No `Date.now()`, `Math.random()`, `setTimeout`, `crypto.randomUUID()`, native I/O, or `process.env` reads inside `declareWorkflow`'s `implementation`. Use `@temporalio/workflow` primitives (`sleep`, `uuid4`, the patched `Date`) or push the side effect into an activity. See [.agents/rules/workflow-determinism.md](.agents/rules/workflow-determinism.md). This is the #1 cause of broken Temporal workflows — read that file before touching workflow code. -2. **Activities and the typed client return `AsyncResult` from `unthrown`.** Never throw — wrap technical errors in `ApplicationFailure` and surface them via `err(...).toAsync()` (or `fromPromise(promise, qualify)`, where `qualify` returns the modeled error `E`). unthrown has no `okAsync`/`errAsync`: lift a sync `Result` with `.toAsync()`. The client uses unthrown's `Result` for sync returns. unthrown adds a third **`defect`** channel for _unanticipated_ failures — a thrown exception the code didn't model surfaces as a defect (inspectable via `isDefect(result)` / `result.cause`, re-thrown at the edge), not a typed `err`. Narrow before reaching `.value`/`.error`/`.cause` — both the `r.isOk()` method and the `isOk(r)` free function are type guards (same for `isErr`/`isDefect`); the codebase uses the free functions. Error classes are built with `TaggedError("@temporal-contract/Name", { name: "Name" })<{ ...payload }>` — the `_tag` is package-namespaced to avoid collisions, while `options.name` keeps `Error.name` the bare class name for readable logs. The worker's `ValidationError` subclasses are the exception — they must stay `ApplicationFailure` for Temporal's terminal-failure semantics. There is no `neverthrow`, no `@swan-io/boxed`, and no `@temporal-contract/boxed` package — those were removed. +2. **Activities and the typed client return `AsyncResult` from `unthrown`.** Never throw — wrap technical errors in `ApplicationFailure` and surface them via `err(...).toAsync()` (or `fromPromise(promise, qualify)`, where `qualify` returns the modeled error `E`). unthrown has no `okAsync`/`errAsync`: lift a sync `Result` with `.toAsync()`. The client uses unthrown's `Result` for sync returns. unthrown adds a third **`defect`** channel for _unanticipated_ failures — a thrown exception the code didn't model surfaces as a defect (inspectable via `result.isDefect()` / `result.cause`, re-thrown at the edge), not a typed `err`. Narrow before reaching `.value`/`.error`/`.cause` — both the `r.isOk()` method and the `isOk(r)` free function are type guards (same for `isErr`/`isDefect`); the codebase uses the methods. Error classes are built with `TaggedError("@temporal-contract/Name", { name: "Name" })<{ ...payload }>` — the `_tag` is package-namespaced to avoid collisions, while `options.name` keeps `Error.name` the bare class name for readable logs. The worker's `ValidationError` subclasses are the exception — they must stay `ApplicationFailure` for Temporal's terminal-failure semantics. There is no `neverthrow`, no `@swan-io/boxed`, and no `@temporal-contract/boxed` package — those were removed. 3. **No `any`.** Use `unknown` and narrow. Enforced by oxlint. 4. **`.js` extensions in every import.** TypeScript files import each other as `./foo.js`, never `./foo` or `./foo.ts`. Required by ESM module resolution. 5. **ESM only.** All packages are `"type": "module"`. No CommonJS in source. diff --git a/docs/guide/migrating-to-unthrown.md b/docs/guide/migrating-to-unthrown.md index 9ca58eef..12543930 100644 --- a/docs/guide/migrating-to-unthrown.md +++ b/docs/guide/migrating-to-unthrown.md @@ -86,13 +86,11 @@ it to an `AsyncResult` with `.toAsync()`: 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 -prefers the free functions, but either reaches `.value` / `.error` / `.cause`: +matching **free functions** `isOk` / `isErr` / `isDefect`. This codebase uses +the methods, but either reaches `.value` / `.error` / `.cause`: ```ts -import { isErr } from "unthrown"; - -if (isErr(result)) { +if (result.isErr()) { console.error(result.error); return; } diff --git a/docs/guide/result-pattern.md b/docs/guide/result-pattern.md index 797c4d96..dc50d4a0 100644 --- a/docs/guide/result-pattern.md +++ b/docs/guide/result-pattern.md @@ -48,7 +48,7 @@ value before checking `isOk(result)` / `isErr(result)` need no changes. > guards: the `result.isOk()` / `result.isErr()` / `result.isDefect()` > **methods** and the matching **free functions** `isOk(result)` / > `isErr(result)` / `isDefect(result)` imported from `"unthrown"`. This -> codebase uses the free functions; either works. +> codebase uses the methods; either works. ## Basic Usage diff --git a/examples/order-processing-worker/src/integration.spec.ts b/examples/order-processing-worker/src/integration.spec.ts index a30a2dd1..ea7514b9 100644 --- a/examples/order-processing-worker/src/integration.spec.ts +++ b/examples/order-processing-worker/src/integration.spec.ts @@ -1,5 +1,4 @@ import { describe, expect, vi, beforeEach } from "vitest"; -import { isOk, isErr } from "unthrown"; import { Worker } from "@temporalio/worker"; import { TypedClient, WorkflowValidationError } from "@temporal-contract/client"; import { it as baseIt } from "@temporal-contract/testing/extension"; @@ -96,7 +95,7 @@ describe("Order Processing Workflow - Integration Tests", () => { // THEN expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ orderId: order.orderId, status: "completed", @@ -129,13 +128,13 @@ describe("Order Processing Workflow - Integration Tests", () => { // THEN expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; expect(handle.workflowId).toBe(order.orderId); const result = await handle.result(); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ orderId: order.orderId, status: "completed", @@ -170,13 +169,13 @@ describe("Order Processing Workflow - Integration Tests", () => { const handleResult = await client.getHandle("processOrder", order.orderId); expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; expect(handle.workflowId).toBe(order.orderId); const result = await handle.result(); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ orderId: order.orderId, status: "completed", @@ -209,12 +208,12 @@ describe("Order Processing Workflow - Integration Tests", () => { // THEN expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; const describeResult = await handle.describe(); expect(describeResult).toBeOk(); - if (isOk(describeResult)) { + if (describeResult.isOk()) { expect(describeResult.value).toEqual( expect.objectContaining({ workflowId: order.orderId, type: "processOrder" }), ); @@ -246,7 +245,7 @@ describe("Order Processing Workflow - Integration Tests", () => { // THEN expect(execution).toBeErr(); - if (isErr(execution)) { + if (execution.isErr()) { expect(execution.error).toBeInstanceOf(WorkflowValidationError); const validationError = execution.error as WorkflowValidationError; expect(validationError.workflowName).toBe("processOrder"); @@ -291,7 +290,7 @@ describe("Order Processing Workflow - Integration Tests", () => { // THEN - Should return failed status expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ status: "failed", errorCode: "PAYMENT_FAILED", diff --git a/packages/client/src/__tests__/client.spec.ts b/packages/client/src/__tests__/client.spec.ts index 87bf5da2..53210f03 100644 --- a/packages/client/src/__tests__/client.spec.ts +++ b/packages/client/src/__tests__/client.spec.ts @@ -1,5 +1,4 @@ import { describe, expect, vi, beforeEach } from "vitest"; -import { isOk, isErr } from "unthrown"; import { Worker } from "@temporalio/worker"; import { TypedClient } from "../client.js"; import { WorkflowValidationError } from "../errors.js"; @@ -95,7 +94,7 @@ describe("Client Package - Integration Tests", () => { // THEN expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: test-data" }); } expect(logMessages).toContain("Processing: test-data"); @@ -114,14 +113,14 @@ describe("Client Package - Integration Tests", () => { // THEN expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; expect(handle.workflowId).toBe(workflowId); const result = await handle.result(); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: async-test" }); } }); @@ -141,12 +140,12 @@ describe("Client Package - Integration Tests", () => { // THEN expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; const result = await handle.result(); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: get-handle-test" }); } }); @@ -165,7 +164,7 @@ describe("Client Package - Integration Tests", () => { // THEN expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ result: "HELLO WORLD" }); } expect(logMessages).toEqual(expect.arrayContaining(["Activity result: HELLO WORLD"])); @@ -186,7 +185,7 @@ describe("Client Package - Integration Tests", () => { }); expect(execution).toBeErr(); - if (isErr(execution)) { + if (execution.isErr()) { expect(execution.error).toBeInstanceOf(WorkflowValidationError); } }); @@ -216,7 +215,7 @@ describe("Client Package - Integration Tests", () => { }); expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN - Send signals to increment value @@ -226,7 +225,7 @@ describe("Client Package - Integration Tests", () => { // THEN - Workflow should complete with updated value const result = await handle.result(); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ finalValue: 18 }); // 10 + 5 + 3 } }); @@ -240,7 +239,7 @@ describe("Client Package - Integration Tests", () => { }); expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN - Query the current value @@ -248,7 +247,7 @@ describe("Client Package - Integration Tests", () => { // THEN - Should return current value expect(queryResult).toBeOk(); - if (isOk(queryResult)) { + if (queryResult.isOk()) { expect(queryResult.value).toEqual({ value: 42 }); } @@ -265,7 +264,7 @@ describe("Client Package - Integration Tests", () => { }); expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN - Send update to multiply value @@ -273,14 +272,14 @@ describe("Client Package - Integration Tests", () => { // THEN - Update should return the new value expect(updateResult).toBeOk(); - if (isOk(updateResult)) { + if (updateResult.isOk()) { expect(updateResult.value).toEqual({ newValue: 15 }); // 5 * 3 } // Workflow should complete with the multiplied value const result = await handle.result(); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ finalValue: 15 }); } }); @@ -296,7 +295,7 @@ describe("Client Package - Integration Tests", () => { }); expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN @@ -304,7 +303,7 @@ describe("Client Package - Integration Tests", () => { // THEN expect(describeResult).toBeOk(); - if (isOk(describeResult)) { + if (describeResult.isOk()) { expect(describeResult.value).toEqual( expect.objectContaining({ workflowId, type: "simpleWorkflow" }), ); @@ -323,7 +322,7 @@ describe("Client Package - Integration Tests", () => { }); expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN @@ -346,7 +345,7 @@ describe("Client Package - Integration Tests", () => { }); expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN @@ -362,7 +361,7 @@ describe("Client Package - Integration Tests", () => { }); describe("Result Pattern", () => { - it("should support isOk(Result) check", async ({ client }) => { + it("should support Result.isOk() check", async ({ client }) => { // GIVEN const input = { value: "test" }; @@ -374,7 +373,7 @@ describe("Client Package - Integration Tests", () => { // THEN expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: test" }); } }); @@ -419,7 +418,7 @@ describe("Client Package - Integration Tests", () => { // THEN const mapped = result.map((value) => value.result.toUpperCase()); expect(mapped).toBeOk(); - if (isOk(mapped)) { + if (mapped.isOk()) { expect(mapped.value).toBe("PROCESSED: TEST"); } }); diff --git a/packages/client/src/client.spec.ts b/packages/client/src/client.spec.ts index 6f5a0e11..f80045bf 100644 --- a/packages/client/src/client.spec.ts +++ b/packages/client/src/client.spec.ts @@ -1,5 +1,4 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { isOk, isErr } from "unthrown"; import { z } from "zod"; import { defineContract, defineSearchAttribute, defineWorkflow } from "@temporal-contract/contract"; import { readTypedSearchAttributes, TypedClient } from "./client.js"; @@ -168,7 +167,7 @@ describe("TypedClient", () => { }); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual( expect.objectContaining({ workflowId: "test-123", @@ -190,7 +189,7 @@ describe("TypedClient", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } }); @@ -205,7 +204,7 @@ describe("TypedClient", () => { ); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } }); @@ -221,7 +220,7 @@ describe("TypedClient", () => { }); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ result: "success" }); } @@ -241,7 +240,7 @@ describe("TypedClient", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } }); @@ -295,7 +294,7 @@ describe("TypedClient", () => { }); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value.workflowId).toBe("test-123"); expect(result.value.signaledRunId).toBe("run-abc"); } @@ -322,7 +321,7 @@ describe("TypedClient", () => { ); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } expect(mockWorkflow.signalWithStart).not.toHaveBeenCalled(); @@ -338,7 +337,7 @@ describe("TypedClient", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } expect(mockWorkflow.signalWithStart).not.toHaveBeenCalled(); @@ -354,7 +353,7 @@ describe("TypedClient", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(SignalValidationError); } expect(mockWorkflow.signalWithStart).not.toHaveBeenCalled(); @@ -371,7 +370,7 @@ describe("TypedClient", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("signalWithStart"); } @@ -397,7 +396,7 @@ describe("TypedClient", () => { const result = await typedClient.getHandle("testWorkflow", "test-123"); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual(expect.objectContaining({ workflowId: "test-123" })); } }); @@ -409,7 +408,7 @@ describe("TypedClient", () => { ); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } }); @@ -458,11 +457,11 @@ describe("TypedClient", () => { expect(handleResult).toBeOk(); - if (isOk(handleResult)) { + if (handleResult.isOk()) { const result = await handleResult.value.result(); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ result: "success" }); } } @@ -478,11 +477,11 @@ describe("TypedClient", () => { expect(handleResult).toBeOk(); - if (isOk(handleResult)) { + if (handleResult.isOk()) { const result = await handleResult.value.queries.getStatus([]); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual("running"); } } @@ -496,7 +495,7 @@ describe("TypedClient", () => { expect(handleResult).toBeOk(); - if (isOk(handleResult)) { + if (handleResult.isOk()) { const result = await handleResult.value.signals.updateProgress([50]); expect(result).toBeOk(); @@ -514,11 +513,11 @@ describe("TypedClient", () => { expect(handleResult).toBeOk(); - if (isOk(handleResult)) { + if (handleResult.isOk()) { const result = await handleResult.value.updates.setConfig([{ value: "new-config" }]); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual(true); } } @@ -532,7 +531,7 @@ describe("TypedClient", () => { expect(handleResult).toBeOk(); - if (isOk(handleResult)) { + if (handleResult.isOk()) { const result = await handleResult.value.terminate("test reason"); expect(result).toBeOk(); @@ -548,7 +547,7 @@ describe("TypedClient", () => { expect(handleResult).toBeOk(); - if (isOk(handleResult)) { + if (handleResult.isOk()) { const result = await handleResult.value.cancel(); expect(result).toBeOk(); @@ -564,11 +563,11 @@ describe("TypedClient", () => { expect(handleResult).toBeOk(); - if (isOk(handleResult)) { + if (handleResult.isOk()) { const result = await handleResult.value.describe(); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual(expect.objectContaining({ workflowId: "test-123" })); } } @@ -583,7 +582,7 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!isOk(handleResult)) throw new Error("expected Ok"); + if (!handleResult.isOk()) throw new Error("expected Ok"); // getStatus expects z.tuple([]); pass a non-tuple to bypass at runtime const result = await handleResult.value.queries.getStatus( @@ -592,7 +591,7 @@ describe("TypedClient", () => { ); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(QueryValidationError); expect((result.error as QueryValidationError).direction).toBe("input"); } @@ -607,12 +606,12 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!isOk(handleResult)) throw new Error("expected Ok"); + if (!handleResult.isOk()) throw new Error("expected Ok"); const result = await handleResult.value.queries.getStatus([]); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(QueryValidationError); expect((result.error as QueryValidationError).direction).toBe("output"); } @@ -625,12 +624,12 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!isOk(handleResult)) throw new Error("expected Ok"); + if (!handleResult.isOk()) throw new Error("expected Ok"); const result = await handleResult.value.queries.getStatus([]); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("query"); } @@ -641,7 +640,7 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!isOk(handleResult)) throw new Error("expected Ok"); + if (!handleResult.isOk()) throw new Error("expected Ok"); // updateProgress expects z.tuple([z.number()]); pass a string const result = await handleResult.value.signals.updateProgress( @@ -650,7 +649,7 @@ describe("TypedClient", () => { ); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(SignalValidationError); } expect(mockHandle.signal).not.toHaveBeenCalled(); @@ -663,12 +662,12 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!isOk(handleResult)) throw new Error("expected Ok"); + if (!handleResult.isOk()) throw new Error("expected Ok"); const result = await handleResult.value.signals.updateProgress([50]); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("signal"); } @@ -679,7 +678,7 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!isOk(handleResult)) throw new Error("expected Ok"); + if (!handleResult.isOk()) throw new Error("expected Ok"); // setConfig expects z.tuple([z.object({ value: z.string() })]); // pass an object with the wrong shape @@ -689,7 +688,7 @@ describe("TypedClient", () => { ); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(UpdateValidationError); expect((result.error as UpdateValidationError).direction).toBe("input"); } @@ -704,12 +703,12 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!isOk(handleResult)) throw new Error("expected Ok"); + if (!handleResult.isOk()) throw new Error("expected Ok"); const result = await handleResult.value.updates.setConfig([{ value: "ok" }]); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(UpdateValidationError); expect((result.error as UpdateValidationError).direction).toBe("output"); } @@ -722,12 +721,12 @@ describe("TypedClient", () => { workflowId: "test-123", args: { name: "hello", value: 42 }, }); - if (!isOk(handleResult)) throw new Error("expected Ok"); + if (!handleResult.isOk()) throw new Error("expected Ok"); const result = await handleResult.value.updates.setConfig([{ value: "ok" }]); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("update"); } @@ -772,7 +771,7 @@ describe("TypedClient", () => { const mapped = result.map((value) => value.result.toUpperCase()); expect(mapped).toBeOk(); - if (isOk(mapped)) { + if (mapped.isOk()) { expect(mapped.value).toEqual("SUCCESS"); } }); @@ -931,7 +930,7 @@ describe("TypedClient", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); const op = (result.error as RuntimeClientError).operation; expect(op).toBe("searchAttributes"); @@ -960,7 +959,7 @@ describe("TypedClient", () => { ); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("searchAttributes"); expect((result.error as RuntimeClientError).message).toContain("customerId"); @@ -1013,7 +1012,7 @@ describe("TypedClient", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowAlreadyStartedError); const err = result.error as WorkflowAlreadyStartedError; expect(err.workflowId).toBe("test-123"); @@ -1031,7 +1030,7 @@ describe("TypedClient", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect(result.error).not.toBeInstanceOf(WorkflowAlreadyStartedError); expect((result.error as RuntimeClientError).operation).toBe("startWorkflow"); @@ -1051,7 +1050,7 @@ describe("TypedClient", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowAlreadyStartedError); } }); @@ -1067,7 +1066,7 @@ describe("TypedClient", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowAlreadyStartedError); } }); @@ -1089,7 +1088,7 @@ describe("TypedClient", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowFailedError); const err = result.error as WorkflowFailedError; expect(err.workflowId).toBe("test-123"); @@ -1110,7 +1109,7 @@ describe("TypedClient", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); const err = result.error as WorkflowExecutionNotFoundError; expect(err.workflowId).toBe("test-123"); @@ -1138,11 +1137,11 @@ describe("TypedClient", () => { mockWorkflow.getHandle.mockReturnValue(handle); const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); - if (!isOk(handleResult)) throw new Error("getHandle should succeed"); + if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.result(); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowFailedError); const err = result.error as WorkflowFailedError; expect(err.workflowId).toBe("test-123"); @@ -1175,11 +1174,11 @@ describe("TypedClient", () => { mockWorkflow.getHandle.mockReturnValue(handle); const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); - if (!isOk(handleResult)) throw new Error("getHandle should succeed"); + if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.cancel(); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); const err = result.error as WorkflowExecutionNotFoundError; // Fallback applied: the handle's workflowId rather than the @@ -1205,11 +1204,11 @@ describe("TypedClient", () => { mockWorkflow.getHandle.mockReturnValue(handle); const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); - if (!isOk(handleResult)) throw new Error("getHandle should succeed"); + if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.terminate("done"); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); } }); @@ -1231,11 +1230,11 @@ describe("TypedClient", () => { mockWorkflow.getHandle.mockReturnValue(handle); const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); - if (!isOk(handleResult)) throw new Error("getHandle should succeed"); + if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.signals.updateProgress([50]); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); } }); @@ -1257,11 +1256,11 @@ describe("TypedClient", () => { mockWorkflow.getHandle.mockReturnValue(handle); const handleResult = await typedClient.getHandle("testWorkflow", "test-123"); - if (!isOk(handleResult)) throw new Error("getHandle should succeed"); + if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.describe(); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { const err = result.error as WorkflowExecutionNotFoundError; expect(err).toBeInstanceOf(WorkflowExecutionNotFoundError); expect(err.runId).toBe("run-xyz"); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index c5ab1595..9df214ab 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -17,7 +17,7 @@ import type { ClientInferWorkflowSignals, ClientInferWorkflowUpdates, } from "./types.js"; -import { type AsyncResult, type Result, ok, err, isErr, fromPromise } from "unthrown"; +import { type AsyncResult, type Result, ok, err, fromPromise } from "unthrown"; import { type TemporalFailure, WorkflowAlreadyStartedError, @@ -82,7 +82,7 @@ export type TypedSearchAttributeMap = * @example * ```ts * const description = await handle.describe(); - * if (isOk(description)) { + * if (description.isOk()) { * const attrs = readTypedSearchAttributes( * myContract.workflows.processOrder, * description.value.typedSearchAttributes, @@ -330,7 +330,7 @@ async function resolveDefinitionAndValidateInput< // `toTypedSearchAttributes` only ever builds ok/err; assert away the // impossible defect so `.error` / `.value` narrow cleanly. assertNoDefect(searchAttributesResult); - if (isErr(searchAttributesResult)) return err(searchAttributesResult.error); + if (searchAttributesResult.isErr()) return err(searchAttributesResult.error); const typedSearchAttributes = searchAttributesResult.value; return ok({ @@ -473,7 +473,7 @@ export class TypedClient { ); // The resolver only ever builds ok/err; assert away the impossible defect. assertNoDefect(resolved); - if (isErr(resolved)) return err(resolved.error); + if (resolved.isErr()) return err(resolved.error); const { definition, validatedInput, typedSearchAttributes } = resolved.value; try { @@ -555,7 +555,7 @@ export class TypedClient { ); // The resolver only ever builds ok/err; assert away the impossible defect. assertNoDefect(resolved); - if (isErr(resolved)) return err(resolved.error); + if (resolved.isErr()) return err(resolved.error); const { definition, validatedInput, typedSearchAttributes } = resolved.value; // Validate signal input — call-site-specific, kept inline. @@ -650,7 +650,7 @@ export class TypedClient { ); // The resolver only ever builds ok/err; assert away the impossible defect. assertNoDefect(resolved); - if (isErr(resolved)) return err(resolved.error); + if (resolved.isErr()) return err(resolved.error); const { definition, validatedInput, typedSearchAttributes } = resolved.value; try { diff --git a/packages/client/src/schedule.spec.ts b/packages/client/src/schedule.spec.ts index e7460efc..ff7731b0 100644 --- a/packages/client/src/schedule.spec.ts +++ b/packages/client/src/schedule.spec.ts @@ -5,7 +5,6 @@ * Closes #181. */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { isOk, isErr } from "unthrown"; import { z } from "zod"; import type { Client } from "@temporalio/client"; import { TypedSearchAttributes } from "@temporalio/common"; @@ -85,7 +84,7 @@ describe("TypedClient.schedule", () => { }); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value.scheduleId).toBe("daily-sweep"); } @@ -115,7 +114,7 @@ describe("TypedClient.schedule", () => { ); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } expect(mockSchedule.create).not.toHaveBeenCalled(); @@ -130,7 +129,7 @@ describe("TypedClient.schedule", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } expect(mockSchedule.create).not.toHaveBeenCalled(); @@ -146,7 +145,7 @@ describe("TypedClient.schedule", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("schedule.create"); } @@ -293,7 +292,7 @@ describe("TypedClient.schedule", () => { }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("searchAttributes"); expect((result.error as RuntimeClientError).message).toContain("unknownAttr"); @@ -331,7 +330,7 @@ describe("TypedClient.schedule", () => { const handle = client.schedule.getHandle("missing"); const result = await handle.pause(); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("schedule.pause"); } @@ -346,7 +345,7 @@ describe("TypedClient.schedule", () => { const result = await handle.describe(); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect((result.value as { scheduleId: string }).scheduleId).toBe("daily-sweep"); } }); diff --git a/packages/client/src/schedule.ts b/packages/client/src/schedule.ts index 6193524b..1d616fba 100644 --- a/packages/client/src/schedule.ts +++ b/packages/client/src/schedule.ts @@ -8,7 +8,7 @@ import type { ScheduleSpec, } from "@temporalio/client"; import type { ContractDefinition } from "@temporal-contract/contract"; -import { type AsyncResult, type Result, ok, err, isErr, fromPromise } from "unthrown"; +import { type AsyncResult, type Result, ok, err, fromPromise } from "unthrown"; import type { TypedSearchAttributeMap } from "./client.js"; import type { ClientInferInput } from "./types.js"; import { RuntimeClientError, WorkflowNotFoundError, WorkflowValidationError } from "./errors.js"; @@ -154,7 +154,7 @@ export class TypedScheduleClient { // `toTypedSearchAttributes` only ever builds ok/err; assert away the // impossible defect so `.error` / `.value` narrow cleanly. assertNoDefect(searchAttributesResult); - if (isErr(searchAttributesResult)) return err(searchAttributesResult.error); + if (searchAttributesResult.isErr()) return err(searchAttributesResult.error); const typedSearchAttributes = searchAttributesResult.value; try { diff --git a/packages/contract/src/result-async.spec.ts b/packages/contract/src/result-async.spec.ts index 18d921c7..f7f713a9 100644 --- a/packages/contract/src/result-async.spec.ts +++ b/packages/contract/src/result-async.spec.ts @@ -10,7 +10,7 @@ * and `worker/src/internal.ts`. */ import { describe, expect, it } from "vitest"; -import { ok, err, isErr, isDefect } from "unthrown"; +import { ok, err } from "unthrown"; import { _internal_makeAsyncResult } from "./result-async.js"; class TestError extends Error { @@ -30,7 +30,7 @@ describe("_internal_makeAsyncResult", () => { const domainError = new TestError("domain"); const result = await _internal_makeAsyncResult(async () => err(domainError)); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { // Identity preserved — the domain `err(...)` flows through untouched. expect(result.error).toBe(domainError); } @@ -47,7 +47,7 @@ describe("_internal_makeAsyncResult", () => { throw thrown; }); expect(result).toBeDefect(); - if (isDefect(result)) { + if (result.isDefect()) { expect(result.cause).toBe(thrown); } }); @@ -61,7 +61,7 @@ describe("_internal_makeAsyncResult", () => { throw thrown; }); expect(result).toBeDefect(); - if (isDefect(result)) { + if (result.isDefect()) { expect(result.cause).toBe(thrown); } }); @@ -75,7 +75,7 @@ describe("_internal_makeAsyncResult", () => { throw thrown; }); expect(result).toBeDefect(); - if (isDefect(result)) { + if (result.isDefect()) { expect(result.cause).toBe(thrown); } }); diff --git a/packages/contract/src/result-async.ts b/packages/contract/src/result-async.ts index 2a4c6e04..8ad861c0 100644 --- a/packages/contract/src/result-async.ts +++ b/packages/contract/src/result-async.ts @@ -12,7 +12,6 @@ */ import { fromSafePromise, - isDefect, type AsyncResult, type ErrView, type OkView, @@ -47,7 +46,7 @@ export function _internal_makeAsyncResult( * Assert that a `Result` is not a `Defect`, narrowing it to `Ok | Err`. * * unthrown's `Result` type always includes the out-of-band `Defect` - * variant, so `if (isErr(r)) … else r.value` does not type-check — the `else` + * variant, so `if (r.isErr()) … else r.value` does not type-check — the `else` * branch is still `Ok | Defect`. For an internally-produced result that is * *known* to be built only from `ok(...)` / `err(...)`, this collapses the * "impossible defect" case in one call: it re-throws a present defect's cause @@ -61,7 +60,7 @@ export function _internal_makeAsyncResult( export function _internal_assertNoDefect( result: Result, ): asserts result is OkView | ErrView { - if (isDefect(result)) { + if (result.isDefect()) { throw result.cause; } } diff --git a/packages/worker/src/__tests__/test.workflows.ts b/packages/worker/src/__tests__/test.workflows.ts index 98cfba5e..b891b360 100644 --- a/packages/worker/src/__tests__/test.workflows.ts +++ b/packages/worker/src/__tests__/test.workflows.ts @@ -1,4 +1,3 @@ -import { isOk, isErr } from "unthrown"; import { testContract } from "./test.contract.js"; import { declareWorkflow } from "../workflow.js"; import { sleep } from "@temporalio/workflow"; @@ -115,9 +114,9 @@ export const parentWorkflow = declareWorkflow({ args: { id: i }, }); - if (isOk(childResult)) { + if (childResult.isOk()) { results.push(childResult.value.message); - } else if (isErr(childResult)) { + } else if (childResult.isErr()) { results.push(`Error: ${childResult.error.message}`); } else { results.push(`Defect: ${String(childResult.cause)}`); diff --git a/packages/worker/src/__tests__/worker.spec.ts b/packages/worker/src/__tests__/worker.spec.ts index aac0279a..1a220fe6 100644 --- a/packages/worker/src/__tests__/worker.spec.ts +++ b/packages/worker/src/__tests__/worker.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, vi, beforeEach } from "vitest"; import { Worker } from "@temporalio/worker"; import { TypedClient, WorkflowValidationError } from "@temporal-contract/client"; import { it as baseIt } from "@temporal-contract/testing/extension"; -import { ok, err, isOk, isErr, type AsyncResult } from "unthrown"; +import { ok, err, type AsyncResult } from "unthrown"; import { extname } from "node:path"; import { fileURLToPath } from "node:url"; import { testContract } from "./test.contract.js"; @@ -136,7 +136,7 @@ describe("Worker Package - Integration Tests", () => { // THEN expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: test-data", }); @@ -157,14 +157,14 @@ describe("Worker Package - Integration Tests", () => { // THEN expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; expect(handle.workflowId).toBe(workflowId); const result = await handle.result(); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: async-test", }); @@ -186,12 +186,12 @@ describe("Worker Package - Integration Tests", () => { // THEN expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; const result = await handle.result(); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: get-handle-test", }); @@ -215,7 +215,7 @@ describe("Worker Package - Integration Tests", () => { // THEN expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ orderId: "ORD-123", status: "success", @@ -244,7 +244,7 @@ describe("Worker Package - Integration Tests", () => { // THEN expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ orderId: "INVALID-123", status: "failed", @@ -268,7 +268,7 @@ describe("Worker Package - Integration Tests", () => { // THEN expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ orderId: "ORD-456", status: "failed", @@ -293,7 +293,7 @@ describe("Worker Package - Integration Tests", () => { }); expect(execution).toBeErr(); - if (isErr(execution)) { + if (execution.isErr()) { expect(execution.error).toBeInstanceOf(WorkflowValidationError); } }); @@ -372,7 +372,7 @@ describe("Worker Package - Integration Tests", () => { // THEN expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ results: ["Child 0 completed", "Child 1 completed", "Child 2 completed"], }); @@ -399,7 +399,7 @@ describe("Worker Package - Integration Tests", () => { }); expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN - Send signals to increment value @@ -409,7 +409,7 @@ describe("Worker Package - Integration Tests", () => { // THEN - Workflow should complete with updated value const result = await handle.result(); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ finalValue: 18, // 10 + 5 + 3 }); @@ -425,7 +425,7 @@ describe("Worker Package - Integration Tests", () => { }); expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN - Query the current value @@ -433,7 +433,7 @@ describe("Worker Package - Integration Tests", () => { // THEN - Should return current value expect(queryResult).toBeOk(); - if (isOk(queryResult)) { + if (queryResult.isOk()) { expect(queryResult.value).toEqual({ value: 42, }); @@ -452,7 +452,7 @@ describe("Worker Package - Integration Tests", () => { }); expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN - Send update to multiply value @@ -460,7 +460,7 @@ describe("Worker Package - Integration Tests", () => { // THEN - Update should return the new value expect(updateResult).toBeOk(); - if (isOk(updateResult)) { + if (updateResult.isOk()) { expect(updateResult.value).toEqual({ newValue: 15, // 5 * 3 }); @@ -469,7 +469,7 @@ describe("Worker Package - Integration Tests", () => { // Workflow should complete with the multiplied value const result = await handle.result(); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toEqual({ finalValue: 15, }); @@ -487,7 +487,7 @@ describe("Worker Package - Integration Tests", () => { }); expect(handleResult).toBeOk(); - if (!isOk(handleResult)) throw new Error("Expected Ok result"); + if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; // WHEN @@ -495,7 +495,7 @@ describe("Worker Package - Integration Tests", () => { // THEN expect(describeResult).toBeOk(); - if (isOk(describeResult)) { + if (describeResult.isOk()) { expect(describeResult.value).toEqual( expect.objectContaining({ workflowId, @@ -524,7 +524,7 @@ describe("Worker Package - Integration Tests", () => { // ApplicationFailure in an ActivityFailure (cause is the original // ApplicationFailure with the type/message/details preserved). expect(result).toBeErr(); - if (!isErr(result)) throw new Error("Expected error result"); + if (!result.isErr()) throw new Error("Expected error result"); const error = result.error; expect(error.message).toMatch(/failableActivity failed/); // Inner cause carries the ApplicationFailure from the activity. diff --git a/packages/worker/src/cancellation.spec.ts b/packages/worker/src/cancellation.spec.ts index cae7180a..29c743b4 100644 --- a/packages/worker/src/cancellation.spec.ts +++ b/packages/worker/src/cancellation.spec.ts @@ -20,7 +20,6 @@ * Closes #183. */ import { describe, expect, it, vi } from "vitest"; -import { isOk, isErr, isDefect } from "unthrown"; import { z } from "zod"; import { defineContract, defineWorkflow } from "@temporal-contract/contract"; @@ -46,7 +45,7 @@ describe("cancellableScope", () => { it("returns Result.Ok with the resolved value on success", async () => { const result = await cancellableScope(async () => 42); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toBe(42); } }); @@ -62,7 +61,7 @@ describe("cancellableScope", () => { throw new Error(CANCEL_MARKER); }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowCancelledError); // Cause is preserved so debug tooling can see the underlying failure. expect((result.error.cause as Error).message).toBe(CANCEL_MARKER); @@ -78,7 +77,7 @@ describe("cancellableScope", () => { throw original; }); expect(result).toBeDefect(); - if (isDefect(result)) { + if (result.isDefect()) { expect(result.cause).toBe(original); } }); @@ -92,7 +91,7 @@ describe("cancellableScope", () => { throw original; }); expect(result).toBeDefect(); - if (isDefect(result)) { + if (result.isDefect()) { expect(result.cause).toBe(original); } }); @@ -102,7 +101,7 @@ describe("nonCancellableScope", () => { it("returns Result.Ok with the resolved value on success", async () => { const result = await nonCancellableScope(async () => "released"); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toBe("released"); } }); @@ -121,7 +120,7 @@ describe("nonCancellableScope", () => { throw new Error(CANCEL_MARKER); }); expect(result).toBeErr(); - if (isErr(result)) { + if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowCancelledError); } }); @@ -132,7 +131,7 @@ describe("nonCancellableScope", () => { throw original; }); expect(result).toBeDefect(); - if (isDefect(result)) { + if (result.isDefect()) { expect(result.cause).toBe(original); } }); @@ -143,7 +142,7 @@ describe("nonCancellableScope", () => { throw original; }); expect(result).toBeDefect(); - if (isDefect(result)) { + if (result.isDefect()) { expect(result.cause).toBe(original); } }); @@ -156,7 +155,7 @@ describe("scope helpers accept synchronous callbacks", () => { it("cancellableScope wraps a sync return as Result.Ok", async () => { const result = await cancellableScope(() => "sync-ok"); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toBe("sync-ok"); } }); @@ -164,7 +163,7 @@ describe("scope helpers accept synchronous callbacks", () => { it("nonCancellableScope wraps a sync return as Result.Ok", async () => { const result = await nonCancellableScope(() => 7); expect(result).toBeOk(); - if (isOk(result)) { + if (result.isOk()) { expect(result.value).toBe(7); } }); diff --git a/packages/worker/src/child-workflow.ts b/packages/worker/src/child-workflow.ts index 0d2dbe37..a20a9af5 100644 --- a/packages/worker/src/child-workflow.ts +++ b/packages/worker/src/child-workflow.ts @@ -10,7 +10,7 @@ import { executeChild, startChild, } from "@temporalio/workflow"; -import { type AsyncResult, type Result, ok, err, isErr } from "unthrown"; +import { type AsyncResult, type Result, ok, err } from "unthrown"; import { ChildWorkflowCancelledError, ChildWorkflowError, @@ -169,7 +169,7 @@ export function createStartChildWorkflow< // `getAndValidateChildWorkflow` only ever builds ok/err; assert away the // impossible defect so `.error` / `.value` narrow cleanly below. assertNoDefect(validationResult); - if (isErr(validationResult)) { + if (validationResult.isErr()) { return err(validationResult.error); } @@ -215,7 +215,7 @@ export function createExecuteChildWorkflow< ); assertNoDefect(validationResult); - if (isErr(validationResult)) { + if (validationResult.isErr()) { return err(validationResult.error); } @@ -236,7 +236,7 @@ export function createExecuteChildWorkflow< ); assertNoDefect(outputValidationResult); - if (isErr(outputValidationResult)) { + if (outputValidationResult.isErr()) { return err(outputValidationResult.error); } diff --git a/packages/worker/src/workflow.ts b/packages/worker/src/workflow.ts index 7fa722ef..77a944ec 100644 --- a/packages/worker/src/workflow.ts +++ b/packages/worker/src/workflow.ts @@ -612,14 +612,13 @@ type WorkflowContext< * * @example * ```ts - * import { isErr } from "unthrown"; * * implementation: async (context, args) => { * const result = await context.cancellableScope(async () => { * return context.activities.processStep(args); * }); * - * if (isErr(result) && result.error instanceof WorkflowCancelledError) { + * if (result.isErr() && result.error instanceof WorkflowCancelledError) { * // workflow was cancelled — perform cleanup that must not be cancelled: * await context.nonCancellableScope(async () => { * await context.activities.releaseResources(args);