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..9f178008 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 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 ## 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..e6a94b49 --- /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)`. +- **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. + +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..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 `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 `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/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..12543930 --- /dev/null +++ b/docs/guide/migrating-to-unthrown.md @@ -0,0 +1,290 @@ +# 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: methods or free functions + +Both narrow. The `result.isOk()` / `result.isErr()` / `result.isDefect()` +**methods** are type guards (as in neverthrow), and unthrown also exports the +matching **free functions** `isOk` / `isErr` / `isDefect`. This codebase uses +the methods, but either reaches `.value` / `.error` / `.cause`: + +```ts +if (result.isErr()) { + console.error(result.error); + return; +} +// result is narrowed to `Ok | Defect` here — a `Defect` still needs handling +// (see below) before `.value` is 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: + +- **`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`. + +> [!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):** + +```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..dc50d4a0 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. + +> [!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 methods; either works. ## 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,111 @@ 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`. + +> [!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: + +```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 +632,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..bbffb1b0 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 an `AsyncResult`. -**Solution:** Always return a `ResultAsync` from activities: +**Solution:** Always return an `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..7dc10168 100644 --- a/examples/order-processing-client/package.json +++ b/examples/order-processing-client/package.json @@ -13,7 +13,7 @@ "@temporalio/client": "catalog:", "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..8c1bbf4e 100644 --- a/examples/order-processing-client/src/client.ts +++ b/examples/order-processing-client/src/client.ts @@ -1,28 +1,20 @@ 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 { matchTags } 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 +22,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 +34,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,104 +70,79 @@ 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}`); - // Start workflow and get handle - const handleResult = await contractClient.startWorkflow("processOrder", { - workflowId: order.orderId, - args: order, - }); - - // Handle workflow start errors - if (handleResult.isErr()) { - 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; - } - - 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 (result.isErr()) { - 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; - } - - 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 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`, @@ -196,50 +163,44 @@ async function run() { args: exampleOrder, }); - // Handle result with pattern matching - if (result.isOk()) { - 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 { - // Handle 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(); - } + // 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. + Defect: (cause) => logger.error({ 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"); + 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 7a3fd01d..0841f525 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": { @@ -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/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..ea7514b9 100644 --- a/examples/order-processing-worker/src/integration.spec.ts +++ b/examples/order-processing-worker/src/integration.spec.ts @@ -94,7 +94,7 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ orderId: order.orderId, @@ -127,13 +127,13 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); 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.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ orderId: order.orderId, @@ -168,13 +168,13 @@ describe("Order Processing Workflow - Integration Tests", () => { // THEN const handleResult = await client.getHandle("processOrder", order.orderId); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); 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.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ orderId: order.orderId, @@ -207,12 +207,12 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; const describeResult = await handle.describe(); - expect(describeResult.isOk()).toBe(true); + expect(describeResult).toBeOk(); if (describeResult.isOk()) { expect(describeResult.value).toEqual( expect.objectContaining({ workflowId: order.orderId, type: "processOrder" }), @@ -244,7 +244,7 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - expect(execution.isErr()).toBe(true); + expect(execution).toBeErr(); if (execution.isErr()) { expect(execution.error).toBeInstanceOf(WorkflowValidationError); const validationError = execution.error as WorkflowValidationError; @@ -289,7 +289,7 @@ describe("Order Processing Workflow - Integration Tests", () => { }); // THEN - Should return failed status - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { 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 4d242eef..bd7d33fc 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": { @@ -63,19 +63,20 @@ "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", "@types/node": "catalog:", + "@unthrown/vitest": "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.2" }, "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..53210f03 100644 --- a/packages/client/src/__tests__/client.spec.ts +++ b/packages/client/src/__tests__/client.spec.ts @@ -93,7 +93,7 @@ describe("Client Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: test-data" }); } @@ -112,14 +112,14 @@ describe("Client Package - Integration Tests", () => { }); // THEN - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); 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.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: async-test" }); } @@ -139,12 +139,12 @@ describe("Client Package - Integration Tests", () => { const handleResult = await client.getHandle("simpleWorkflow", workflowId); // THEN - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; const result = await handle.result(); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: get-handle-test" }); } @@ -163,7 +163,7 @@ describe("Client Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ result: "HELLO WORLD" }); } @@ -184,7 +184,7 @@ describe("Client Package - Integration Tests", () => { args: invalidInput as { value: string }, }); - expect(execution.isErr()).toBe(true); + expect(execution).toBeErr(); if (execution.isErr()) { expect(execution.error).toBeInstanceOf(WorkflowValidationError); } @@ -201,7 +201,7 @@ describe("Client Package - Integration Tests", () => { }); // THEN - Should succeed with proper validation - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); }); }); @@ -214,7 +214,7 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 10 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -224,7 +224,7 @@ describe("Client Package - Integration Tests", () => { // THEN - Workflow should complete with updated value const result = await handle.result(); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ finalValue: 18 }); // 10 + 5 + 3 } @@ -238,7 +238,7 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -246,7 +246,7 @@ describe("Client Package - Integration Tests", () => { const queryResult = await handle.queries.getCurrentValue({}); // THEN - Should return current value - expect(queryResult.isOk()).toBe(true); + expect(queryResult).toBeOk(); if (queryResult.isOk()) { expect(queryResult.value).toEqual({ value: 42 }); } @@ -263,7 +263,7 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 5 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -271,14 +271,14 @@ describe("Client Package - Integration Tests", () => { const updateResult = await handle.updates.multiply({ factor: 3 }); // THEN - Update should return the new value - expect(updateResult.isOk()).toBe(true); + expect(updateResult).toBeOk(); 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.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ finalValue: 15 }); } @@ -294,7 +294,7 @@ describe("Client Package - Integration Tests", () => { args: { value: "describe-me" }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -302,7 +302,7 @@ describe("Client Package - Integration Tests", () => { const describeResult = await handle.describe(); // THEN - expect(describeResult.isOk()).toBe(true); + expect(describeResult).toBeOk(); if (describeResult.isOk()) { expect(describeResult.value).toEqual( expect.objectContaining({ workflowId, type: "simpleWorkflow" }), @@ -321,7 +321,7 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 10 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -329,11 +329,11 @@ describe("Client Package - Integration Tests", () => { const cancelResult = await handle.cancel(); // THEN - expect(cancelResult.isOk()).toBe(true); + expect(cancelResult).toBeOk(); // Result should throw or return error const result = await handle.result(); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); }); it("should terminate a running workflow", async ({ client }) => { @@ -344,7 +344,7 @@ describe("Client Package - Integration Tests", () => { args: { initialValue: 10 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -352,11 +352,11 @@ describe("Client Package - Integration Tests", () => { const terminateResult = await handle.terminate("Test termination"); // THEN - expect(terminateResult.isOk()).toBe(true); + expect(terminateResult).toBeOk(); // Result should throw or return error const result = await handle.result(); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); }); }); @@ -372,7 +372,7 @@ describe("Client Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: test" }); } @@ -390,15 +390,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,7 +417,7 @@ describe("Client Package - Integration Tests", () => { // THEN const mapped = result.map((value) => value.result.toUpperCase()); - expect(mapped.isOk()).toBe(true); + expect(mapped).toBeOk(); 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 33f8605f..f80045bf 100644 --- a/packages/client/src/client.spec.ts +++ b/packages/client/src/client.spec.ts @@ -166,7 +166,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual( expect.objectContaining({ @@ -188,7 +188,7 @@ describe("TypedClient", () => { args: { name: "hello", value: "not-a-number" as unknown as number }, }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } @@ -203,7 +203,7 @@ describe("TypedClient", () => { }, ); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } @@ -219,7 +219,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ result: "success" }); } @@ -239,7 +239,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } @@ -253,7 +253,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); }); }); @@ -293,7 +293,7 @@ describe("TypedClient", () => { signalArgs: [50], }); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value.workflowId).toBe("test-123"); expect(result.value.signaledRunId).toBe("run-abc"); @@ -320,7 +320,7 @@ describe("TypedClient", () => { }, ); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } @@ -336,7 +336,7 @@ describe("TypedClient", () => { signalArgs: [50], }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } @@ -352,7 +352,7 @@ describe("TypedClient", () => { signalArgs: ["not a number"], }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(SignalValidationError); } @@ -369,7 +369,7 @@ describe("TypedClient", () => { signalArgs: [50], }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("signalWithStart"); @@ -395,7 +395,7 @@ describe("TypedClient", () => { const result = await typedClient.getHandle("testWorkflow", "test-123"); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual(expect.objectContaining({ workflowId: "test-123" })); } @@ -407,7 +407,7 @@ describe("TypedClient", () => { "test-123", ); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } @@ -455,12 +455,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (handleResult.isOk()) { const result = await handleResult.value.result(); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ result: "success" }); } @@ -475,12 +475,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (handleResult.isOk()) { const result = await handleResult.value.queries.getStatus([]); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual("running"); } @@ -493,12 +493,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (handleResult.isOk()) { const result = await handleResult.value.signals.updateProgress([50]); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); expect(mockHandle.signal).toHaveBeenCalledWith("updateProgress", [50]); } }); @@ -511,12 +511,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (handleResult.isOk()) { const result = await handleResult.value.updates.setConfig([{ value: "new-config" }]); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual(true); } @@ -529,12 +529,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (handleResult.isOk()) { const result = await handleResult.value.terminate("test reason"); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); expect(mockHandle.terminate).toHaveBeenCalledWith("test reason"); } }); @@ -545,12 +545,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (handleResult.isOk()) { const result = await handleResult.value.cancel(); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); expect(mockHandle.cancel).toHaveBeenCalled(); } }); @@ -561,12 +561,12 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (handleResult.isOk()) { const result = await handleResult.value.describe(); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual(expect.objectContaining({ workflowId: "test-123" })); } @@ -590,7 +590,7 @@ describe("TypedClient", () => { [123], ); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(QueryValidationError); expect((result.error as QueryValidationError).direction).toBe("input"); @@ -610,7 +610,7 @@ describe("TypedClient", () => { const result = await handleResult.value.queries.getStatus([]); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(QueryValidationError); expect((result.error as QueryValidationError).direction).toBe("output"); @@ -628,7 +628,7 @@ describe("TypedClient", () => { const result = await handleResult.value.queries.getStatus([]); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("query"); @@ -648,7 +648,7 @@ describe("TypedClient", () => { ["not a number"], ); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(SignalValidationError); } @@ -666,7 +666,7 @@ describe("TypedClient", () => { const result = await handleResult.value.signals.updateProgress([50]); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("signal"); @@ -687,7 +687,7 @@ describe("TypedClient", () => { [{ value: 99 }], ); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(UpdateValidationError); expect((result.error as UpdateValidationError).direction).toBe("input"); @@ -707,7 +707,7 @@ describe("TypedClient", () => { const result = await handleResult.value.updates.setConfig([{ value: "ok" }]); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(UpdateValidationError); expect((result.error as UpdateValidationError).direction).toBe("output"); @@ -725,7 +725,7 @@ describe("TypedClient", () => { const result = await handleResult.value.updates.setConfig([{ value: "ok" }]); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("update"); @@ -744,15 +744,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,7 +770,7 @@ describe("TypedClient", () => { const mapped = result.map((value) => value.result.toUpperCase()); - expect(mapped.isOk()).toBe(true); + expect(mapped).toBeOk(); if (mapped.isOk()) { expect(mapped.value).toEqual("SUCCESS"); } @@ -836,7 +839,7 @@ describe("TypedClient", () => { }, }); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); const call = mockWorkflow.start.mock.calls[0]; expect(call?.[0]).toBe("processOrder"); const passed = call?.[1] as { typedSearchAttributes?: TypedSearchAttributes }; @@ -926,7 +929,7 @@ describe("TypedClient", () => { }, }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); const op = (result.error as RuntimeClientError).operation; @@ -955,7 +958,7 @@ describe("TypedClient", () => { }, ); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("searchAttributes"); @@ -1008,7 +1011,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowAlreadyStartedError); const err = result.error as WorkflowAlreadyStartedError; @@ -1026,7 +1029,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect(result.error).not.toBeInstanceOf(WorkflowAlreadyStartedError); @@ -1046,7 +1049,7 @@ describe("TypedClient", () => { signalArgs: [50], }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowAlreadyStartedError); } @@ -1062,7 +1065,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowAlreadyStartedError); } @@ -1084,7 +1087,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowFailedError); const err = result.error as WorkflowFailedError; @@ -1105,7 +1108,7 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); const err = result.error as WorkflowExecutionNotFoundError; @@ -1137,7 +1140,7 @@ describe("TypedClient", () => { if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.result(); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowFailedError); const err = result.error as WorkflowFailedError; @@ -1174,7 +1177,7 @@ describe("TypedClient", () => { if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.cancel(); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); const err = result.error as WorkflowExecutionNotFoundError; @@ -1204,7 +1207,7 @@ describe("TypedClient", () => { if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.terminate("done"); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); } @@ -1230,7 +1233,7 @@ describe("TypedClient", () => { if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.signals.updateProgress([50]); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowExecutionNotFoundError); } @@ -1256,7 +1259,7 @@ describe("TypedClient", () => { if (!handleResult.isOk()) throw new Error("getHandle should succeed"); const result = await handleResult.value.describe(); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { 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 cfd82359..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 { ResultAsync, type Result, ok, err } from "neverthrow"; +import { type AsyncResult, type Result, ok, err, fromPromise } from "unthrown"; import { type TemporalFailure, WorkflowAlreadyStartedError, @@ -32,10 +32,11 @@ import { } from "./errors.js"; import { TypedScheduleClient } from "./schedule.js"; import { + assertNoDefect, classifyHandleError, classifyResultError, classifyStartError, - makeResultAsync, + makeAsyncResult, toTypedSearchAttributes, } from "./internal.js"; import { WorkflowExecutionAlreadyStartedError } from "@temporalio/client"; @@ -170,22 +171,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 +195,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 +212,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 +230,7 @@ export type TypedWorkflowHandle = { /** * Get workflow result with Result pattern */ - result: () => ResultAsync< + result: () => AsyncResult< ClientInferOutput, | WorkflowValidationError | WorkflowFailedError @@ -242,17 +243,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 +261,7 @@ export type TypedWorkflowHandle = { /** * Fetch the workflow execution history */ - fetchHistory: () => ResultAsync< + fetchHistory: () => AsyncResult< Awaited>, WorkflowExecutionNotFoundError | RuntimeClientError >; @@ -326,6 +327,9 @@ async function resolveDefinitionAndValidateInput< workflowName, searchAttributes, ); + // `toTypedSearchAttributes` only ever builds ok/err; assert away the + // impossible defect so `.error` / `.value` narrow cleanly. + assertNoDefect(searchAttributesResult); if (searchAttributesResult.isErr()) return err(searchAttributesResult.error); const typedSearchAttributes = searchAttributesResult.value; @@ -337,7 +341,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 +367,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 +395,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 +408,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 +423,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 +434,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 +451,7 @@ export class TypedClient { searchAttributes, ...temporalOptions }: TypedWorkflowStartOptions, - ): ResultAsync< + ): AsyncResult< TypedWorkflowHandle, | WorkflowNotFoundError | WorkflowValidationError @@ -464,6 +471,8 @@ export class TypedClient { args, searchAttributes as Record | undefined, ); + // The resolver only ever builds ok/err; assert away the impossible defect. + assertNoDefect(resolved); if (resolved.isErr()) return err(resolved.error); const { definition, validatedInput, typedSearchAttributes } = resolved.value; @@ -479,7 +488,7 @@ export class TypedClient { return err(classifyStartError("startWorkflow", error)); } }; - return makeResultAsync(work); + return makeAsyncResult(work); } /** @@ -502,10 +511,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 +530,7 @@ export class TypedClient { searchAttributes, ...temporalOptions }: TypedSignalWithStartOptions, - ): ResultAsync< + ): AsyncResult< TypedWorkflowHandleWithSignaledRunId, | WorkflowNotFoundError | WorkflowValidationError @@ -543,6 +553,8 @@ export class TypedClient { args, searchAttributes as Record | undefined, ); + // The resolver only ever builds ok/err; assert away the impossible defect. + assertNoDefect(resolved); if (resolved.isErr()) return err(resolved.error); const { definition, validatedInput, typedSearchAttributes } = resolved.value; @@ -583,11 +595,11 @@ export class TypedClient { return err(classifyStartError("signalWithStart", error)); } }; - return makeResultAsync(work); + return makeAsyncResult(work); } /** - * 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 +610,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 +624,7 @@ export class TypedClient { searchAttributes, ...temporalOptions }: TypedWorkflowStartOptions, - ): ResultAsync< + ): AsyncResult< ClientInferOutput, | WorkflowNotFoundError | WorkflowValidationError @@ -635,6 +648,8 @@ export class TypedClient { args, searchAttributes as Record | undefined, ); + // The resolver only ever builds ok/err; assert away the impossible defect. + assertNoDefect(resolved); if (resolved.isErr()) return err(resolved.error); const { definition, validatedInput, typedSearchAttributes } = resolved.value; @@ -691,11 +706,11 @@ export class TypedClient { return err(createRuntimeClientError("executeWorkflow", error)); } }; - return makeResultAsync(work); + return makeAsyncResult(work); } /** - * Get a handle to an existing workflow with ResultAsync pattern + * Get a handle to an existing workflow with AsyncResult pattern * * @example * ```ts @@ -712,7 +727,7 @@ export class TypedClient { getHandle( workflowName: TWorkflowName, workflowId: string, - ): ResultAsync< + ): AsyncResult< TypedWorkflowHandle, WorkflowNotFoundError | RuntimeClientError > { @@ -731,7 +746,7 @@ export class TypedClient { return err(createRuntimeClientError("getHandle", error)); } }; - return makeResultAsync(work); + return makeAsyncResult(work); } private createTypedHandle( @@ -775,7 +790,7 @@ export class TypedClient { queries, signals, updates, - result: (): ResultAsync< + result: (): AsyncResult< ClientInferOutput, | WorkflowValidationError | WorkflowFailedError @@ -806,30 +821,30 @@ export class TypedClient { return err(classifyResultError("result", error, workflowHandle.workflowId)); } }; - return makeResultAsync(work); + return makeAsyncResult(work); }, 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 +896,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 +913,13 @@ function buildValidatedProxy ResultAsync + ) => AsyncResult > { const proxy: Record< string, ( args: unknown, - ) => ResultAsync< + ) => AsyncResult< unknown, TValidationError | WorkflowExecutionNotFoundError | RuntimeClientError > @@ -936,7 +951,7 @@ function buildValidatedProxy { + constructor(operation: string, cause?: unknown) { + super({ + operation, + cause, + message: `Operation "${operation}" failed: ${ cause instanceof Error ? cause.message : String(cause ?? "unknown error") }`, - ); + }); + // only on `_tag`. } } /** * 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("@temporal-contract/WorkflowNotFoundError", { + name: "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 +82,22 @@ 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( + "@temporal-contract/WorkflowAlreadyStartedError", + { name: "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 +113,22 @@ 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( + "@temporal-contract/WorkflowExecutionNotFoundError", + { name: "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 +151,21 @@ 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("@temporal-contract/WorkflowFailedError", { + name: "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 +177,94 @@ export class WorkflowFailedError extends TypedClientError { /** * Thrown when workflow input or output validation fails */ -export class WorkflowValidationError extends TypedClientError { +export class WorkflowValidationError extends TaggedError( + "@temporal-contract/WorkflowValidationError", + { name: "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("@temporal-contract/QueryValidationError", { + name: "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("@temporal-contract/SignalValidationError", { + name: "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("@temporal-contract/UpdateValidationError", { + name: "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..e7cc6a24 100644 --- a/packages/client/src/internal.ts +++ b/packages/client/src/internal.ts @@ -14,8 +14,13 @@ 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"; + +// `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, @@ -75,26 +80,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 makeAsyncResult(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..ff7731b0 100644 --- a/packages/client/src/schedule.spec.ts +++ b/packages/client/src/schedule.spec.ts @@ -83,7 +83,7 @@ describe("TypedClient.schedule", () => { args: { orderId: "sweep" }, }); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value.scheduleId).toBe("daily-sweep"); } @@ -113,7 +113,7 @@ describe("TypedClient.schedule", () => { }, ); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowNotFoundError); } @@ -128,7 +128,7 @@ describe("TypedClient.schedule", () => { args: { orderId: 123 }, }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowValidationError); } @@ -144,7 +144,7 @@ describe("TypedClient.schedule", () => { args: { orderId: "sweep" }, }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("schedule.create"); @@ -291,7 +291,7 @@ describe("TypedClient.schedule", () => { }, }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("searchAttributes"); @@ -309,16 +309,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(); }); @@ -329,7 +329,7 @@ describe("TypedClient.schedule", () => { const handle = client.schedule.getHandle("missing"); const result = await handle.pause(); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(RuntimeClientError); expect((result.error as RuntimeClientError).operation).toBe("schedule.pause"); @@ -344,7 +344,7 @@ describe("TypedClient.schedule", () => { const handle = client.schedule.getHandle("daily-sweep"); const result = await handle.describe(); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); 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 8b2e6151..1d616fba 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 { ResultAsync, type Result, ok, err } from "neverthrow"; +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"; -import { makeResultAsync, toTypedSearchAttributes } from "./internal.js"; +import { assertNoDefect, makeAsyncResult, toTypedSearchAttributes } from "./internal.js"; /** * Workflow-action–level overrides forwarded to Temporal's @@ -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,6 +151,9 @@ 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 (searchAttributesResult.isErr()) return err(searchAttributesResult.error); const typedSearchAttributes = searchAttributesResult.value; @@ -195,7 +198,7 @@ export class TypedScheduleClient { return err(new RuntimeClientError("schedule.create", error)); } }; - return makeResultAsync(work); + return makeAsyncResult(work); } /** @@ -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/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 05507d56..b58de22b 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -63,21 +63,22 @@ "devDependencies": { "@temporal-contract/tsconfig": "workspace:*", "@temporal-contract/typedoc": "workspace:*", + "@unthrown/vitest": "catalog:", "@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.2" }, "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..f7f713a9 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 } from "unthrown"; +import { _internal_makeAsyncResult } from "./result-async.js"; class TestError extends Error { constructor(public readonly tag: string) { @@ -19,88 +20,63 @@ 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()) { - expect(result.value).toBe(42); - } + const result = await _internal_makeAsyncResult(async () => ok(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_makeResultAsync( - async () => err(domainError), - (e) => new TestError(`unexpected:${String(e)}`), - ); - expect(result.isErr()).toBe(true); + const result = await _internal_makeAsyncResult(async () => err(domainError)); + expect(result).toBeErr(); if (result.isErr()) { // 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(result).toBeDefect(); + if (result.isDefect()) { + 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(result).toBeDefect(); + if (result.isDefect()) { + 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(result).toBeDefect(); + 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 9585b1a1..8ad861c0 100644 --- a/packages/contract/src/result-async.ts +++ b/packages/contract/src/result-async.ts @@ -1,44 +1,66 @@ /** * 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 ErrView, + type OkView, + 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))); +): 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 (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 + * (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 (result.isDefect()) { + throw result.cause; } - return new ResultAsync(promise.catch((e: unknown) => err(mapRejection(e)))); } 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/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..bd18cc08 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", @@ -81,12 +81,13 @@ "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", "@types/node": "catalog:", + "@unthrown/vitest": "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 +95,7 @@ "@temporalio/common": "^1", "@temporalio/worker": "^1", "@temporalio/workflow": "^1", - "neverthrow": "^8" + "unthrown": "^0.2" }, "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..b891b360 100644 --- a/packages/worker/src/__tests__/test.workflows.ts +++ b/packages/worker/src/__tests__/test.workflows.ts @@ -116,8 +116,10 @@ export const parentWorkflow = declareWorkflow({ if (childResult.isOk()) { results.push(childResult.value.message); - } else { + } 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 31a17f34..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 { okAsync, errAsync } from "neverthrow"; +import { ok, err, 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,7 +135,7 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: test-data", @@ -152,14 +156,14 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); 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.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: async-test", @@ -181,12 +185,12 @@ describe("Worker Package - Integration Tests", () => { const handleResult = await client.getHandle("simpleWorkflow", workflowId); // THEN - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; const result = await handle.result(); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ result: "Processed: get-handle-test", @@ -210,7 +214,7 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ orderId: "ORD-123", @@ -239,7 +243,7 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ orderId: "INVALID-123", @@ -263,7 +267,7 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ orderId: "ORD-456", @@ -288,7 +292,7 @@ describe("Worker Package - Integration Tests", () => { args: invalidInput, }); - expect(execution.isErr()).toBe(true); + expect(execution).toBeErr(); if (execution.isErr()) { 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(result).toBeOk(); }); }); @@ -367,7 +371,7 @@ describe("Worker Package - Integration Tests", () => { }); // THEN - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ results: ["Child 0 completed", "Child 1 completed", "Child 2 completed"], @@ -394,7 +398,7 @@ describe("Worker Package - Integration Tests", () => { args: { initialValue: 10 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -404,7 +408,7 @@ describe("Worker Package - Integration Tests", () => { // THEN - Workflow should complete with updated value const result = await handle.result(); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ finalValue: 18, // 10 + 5 + 3 @@ -420,7 +424,7 @@ describe("Worker Package - Integration Tests", () => { args: { initialValue: 42 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -428,7 +432,7 @@ describe("Worker Package - Integration Tests", () => { const queryResult = await handle.queries.getCurrentValue({}); // THEN - Should return current value - expect(queryResult.isOk()).toBe(true); + expect(queryResult).toBeOk(); if (queryResult.isOk()) { expect(queryResult.value).toEqual({ value: 42, @@ -447,7 +451,7 @@ describe("Worker Package - Integration Tests", () => { args: { initialValue: 5 }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -455,7 +459,7 @@ describe("Worker Package - Integration Tests", () => { const updateResult = await handle.updates.multiply({ factor: 3 }); // THEN - Update should return the new value - expect(updateResult.isOk()).toBe(true); + expect(updateResult).toBeOk(); if (updateResult.isOk()) { expect(updateResult.value).toEqual({ newValue: 15, // 5 * 3 @@ -464,7 +468,7 @@ describe("Worker Package - Integration Tests", () => { // Workflow should complete with the multiplied value const result = await handle.result(); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toEqual({ finalValue: 15, @@ -482,7 +486,7 @@ describe("Worker Package - Integration Tests", () => { args: { value: "describe-me" }, }); - expect(handleResult.isOk()).toBe(true); + expect(handleResult).toBeOk(); if (!handleResult.isOk()) throw new Error("Expected Ok result"); const handle = handleResult.value; @@ -490,7 +494,7 @@ describe("Worker Package - Integration Tests", () => { const describeResult = await handle.describe(); // THEN - expect(describeResult.isOk()).toBe(true); + expect(describeResult).toBeOk(); if (describeResult.isOk()) { expect(describeResult.value).toEqual( expect.objectContaining({ @@ -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(result).toBeErr(); + 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/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..f17b439c 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,41 @@ 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; + }, + // 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; + }, + }); }; } @@ -257,7 +276,7 @@ export function declareActivitiesHandler( (wrappedActivities as Record)[activityName] = makeWrapped( activityName, activityDef, - impl as (args: unknown) => ResultAsync, + impl as (args: unknown) => AsyncResult, ); } } @@ -288,7 +307,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..29c743b4 100644 --- a/packages/worker/src/cancellation.spec.ts +++ b/packages/worker/src/cancellation.spec.ts @@ -4,16 +4,16 @@ * * 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. * @@ -39,12 +39,12 @@ 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); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toBe(42); } @@ -60,7 +60,7 @@ describe("cancellableScope", () => { const result = await cancellableScope(async () => { throw new Error(CANCEL_MARKER); }); - expect(result.isErr()).toBe(true); + expect(result).toBeErr(); if (result.isErr()) { expect(result.error).toBeInstanceOf(WorkflowCancelledError); // Cause is preserved so debug tooling can see the underlying failure. @@ -68,38 +68,31 @@ describe("cancellableScope", () => { } }); - 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 `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 () => { 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(result).toBeDefect(); + if (result.isDefect()) { + 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, `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; }); - 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(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBe(original); } }); }); @@ -107,7 +100,7 @@ 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); + expect(result).toBeOk(); if (result.isOk()) { 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); + expect(result).toBeErr(); if (result.isErr()) { 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(result).toBeDefect(); + if (result.isDefect()) { + 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(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBe(original); } }); }); @@ -164,7 +154,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(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toBe("sync-ok"); } @@ -172,7 +162,7 @@ describe("scope helpers accept synchronous callbacks", () => { it("nonCancellableScope wraps a sync return as Result.Ok", async () => { const result = await nonCancellableScope(() => 7); - expect(result.isOk()).toBe(true); + expect(result).toBeOk(); if (result.isOk()) { expect(result.value).toBe(7); } diff --git a/packages/worker/src/cancellation.ts b/packages/worker/src/cancellation.ts index 546ddb7e..8d8d7f22 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 { makeResultAsync } from "./internal.js"; +import { type AsyncResult, type Result, ok, err } from "unthrown"; +import { WorkflowCancelledError } from "./errors.js"; +import { makeAsyncResult } 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 `makeAsyncResult`'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 makeAsyncResult(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 makeAsyncResult(work); } diff --git a/packages/worker/src/child-workflow.ts b/packages/worker/src/child-workflow.ts index 5ef4f06b..a20a9af5 100644 --- a/packages/worker/src/child-workflow.ts +++ b/packages/worker/src/child-workflow.ts @@ -10,16 +10,17 @@ import { executeChild, startChild, } from "@temporalio/workflow"; -import { ResultAsync, type Result, ok, err } from "neverthrow"; +import { type AsyncResult, type Result, ok, err } from "unthrown"; import { ChildWorkflowCancelledError, ChildWorkflowError, ChildWorkflowNotFoundError, } from "./errors.js"; import { + assertNoDefect, classifyChildWorkflowError, formatChildWorkflowValidationMessage, - makeResultAsync, + makeAsyncResult, } from "./internal.js"; import type { ClientInferInput, ClientInferOutput, WorkerInferInput } from "./types.js"; @@ -36,13 +37,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 +84,7 @@ async function getAndValidateChildWorkflow< validatedInput: WorkerInferInput; taskQueue: string; }, - ChildWorkflowError + ChildWorkflowError | ChildWorkflowNotFoundError > > { const childDefinition = childContract.workflows[childWorkflowName]; @@ -125,7 +126,7 @@ function createTypedChildHandle( ): TypedChildWorkflowHandle { return { workflowId: handle.workflowId, - result: (): ResultAsync< + result: (): AsyncResult< ClientInferOutput, ChildWorkflowError | ChildWorkflowCancelledError > => { @@ -139,14 +140,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 makeAsyncResult(work); }, }; } @@ -158,7 +152,7 @@ export function createStartChildWorkflow< childContract: TChildContract, childWorkflowName: TChildWorkflowName, options: TypedChildWorkflowOptions, -): ResultAsync< +): AsyncResult< TypedChildWorkflowHandle, ChildWorkflowError | ChildWorkflowCancelledError | ChildWorkflowNotFoundError > { @@ -172,6 +166,9 @@ 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 (validationResult.isErr()) { return err(validationResult.error); } @@ -193,14 +190,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 makeAsyncResult(work); } export function createExecuteChildWorkflow< @@ -210,7 +200,7 @@ export function createExecuteChildWorkflow< childContract: TChildContract, childWorkflowName: TChildWorkflowName, options: TypedChildWorkflowOptions, -): ResultAsync< +): AsyncResult< ClientInferOutput, ChildWorkflowError | ChildWorkflowCancelledError | ChildWorkflowNotFoundError > { @@ -224,6 +214,7 @@ export function createExecuteChildWorkflow< options.args, ); + assertNoDefect(validationResult); if (validationResult.isErr()) { return err(validationResult.error); } @@ -244,6 +235,7 @@ export function createExecuteChildWorkflow< childWorkflowName, ); + assertNoDefect(outputValidationResult); if (outputValidationResult.isErr()) { return err(outputValidationResult.error); } @@ -253,12 +245,5 @@ export function createExecuteChildWorkflow< 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 makeAsyncResult(work); } diff --git a/packages/worker/src/errors.ts b/packages/worker/src/errors.ts index ac67881e..48f9a3c6 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,21 @@ 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( + "@temporal-contract/ActivityDefinitionNotFoundError", + { name: "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 +235,21 @@ 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( + "@temporal-contract/ChildWorkflowNotFoundError", + { name: "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 +262,65 @@ 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("@temporal-contract/ChildWorkflowError", { + name: "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( + "@temporal-contract/ChildWorkflowCancelledError", + { name: "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( + "@temporal-contract/WorkflowCancelledError", + { name: "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..0c82f870 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,18 @@ 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. +// `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. @@ -294,7 +298,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/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/src/workflow.ts b/packages/worker/src/workflow.ts index 1a217f10..77a944ec 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,49 @@ 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 + * * 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 (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); + * }); + * 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/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 84fc89ba..70b2ade0 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.2.0 + version: 0.2.0 '@vitest/coverage-v8': specifier: 4.1.8 version: 4.1.8 @@ -45,9 +48,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 @@ -63,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 @@ -84,6 +81,9 @@ catalogs: typescript: specifier: 6.0.3 version: 6.0.3 + unthrown: + specifier: 0.2.0 + version: 0.2.0 valibot: specifier: 1.4.1 version: 1.4.1 @@ -179,9 +179,9 @@ importers: pino-pretty: specifier: 'catalog:' version: 13.1.3 - ts-pattern: + unthrown: specifier: 'catalog:' - version: 5.9.0 + version: 0.2.0 zod: specifier: 'catalog:' version: 4.4.3 @@ -235,15 +235,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.2.0 zod: specifier: 'catalog:' version: 4.4.3 @@ -263,6 +263,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.13.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 0.2.0(vitest@4.1.8) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.8(vitest@4.1.8) @@ -309,12 +312,12 @@ importers: '@types/node': specifier: 'catalog:' version: 24.13.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 0.2.0(vitest@4.1.8) '@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 +330,9 @@ importers: typescript: specifier: 'catalog:' version: 6.0.3 + unthrown: + specifier: 'catalog:' + 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) @@ -349,15 +355,15 @@ importers: '@temporal-contract/typedoc': specifier: workspace:* version: link:../../tools/typedoc + '@unthrown/vitest': + specifier: 'catalog:' + version: 0.2.0(vitest@4.1.8) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.8(vitest@4.1.8) 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 +376,9 @@ importers: typescript: specifier: 'catalog:' version: 6.0.3 + unthrown: + specifier: 'catalog:' + version: 0.2.0 valibot: specifier: 'catalog:' version: 1.4.1(typescript@6.0.3) @@ -450,12 +459,12 @@ importers: '@types/node': specifier: 'catalog:' version: 24.13.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 0.2.0(vitest@4.1.8) '@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 +477,9 @@ importers: typescript: specifier: 'catalog:' version: 6.0.3 + unthrown: + specifier: 'catalog:' + 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) @@ -2541,6 +2553,12 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@unthrown/vitest@0.2.0': + resolution: {integrity: sha512-0IavcCbccDw5l6NI9Y8EkF+7tDZkz7mYfYalzsYdPMW5z7f/mTRiaUJkZ6qgd4bVPuD1TiQmKTyK3CFZXGi76Q==} + engines: {node: '>=22.19'} + peerDependencies: + vitest: ^4 + '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -3521,6 +3539,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 +4067,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'} @@ -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} @@ -4814,6 +4826,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + unthrown@0.2.0: + resolution: {integrity: sha512-scackbGqniNj2OhQvsr+3x1HaVqcG7NWDrEViTZgtXcalayTl40DDB/0Ck3xkZay/Z8+oGWnFhqZD3RmpqHLUQ==} + engines: {node: '>=22.19'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -5952,6 +5968,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 +6114,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 +6342,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': @@ -6847,6 +6870,11 @@ snapshots: '@ungap/structured-clone@1.3.1': {} + '@unthrown/vitest@0.2.0(vitest@4.1.8)': + dependencies: + 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': optionalDependencies: d3-selection: 3.0.0 @@ -8400,10 +8428,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: {} @@ -9172,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 @@ -9278,6 +9300,8 @@ snapshots: universalify@0.1.2: {} + unthrown@0.2.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..dedfc9aa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,23 +54,23 @@ catalog: "@temporalio/worker": 1.18.1 "@temporalio/workflow": 1.18.1 "@types/node": 24.13.2 + "@unthrown/vitest": 0.2.0 "@vitest/coverage-v8": 4.1.8 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 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 typedoc: 0.28.19 typedoc-plugin-markdown: 4.12.0 typescript: 6.0.3 + unthrown: 0.2.0 valibot: 1.4.1 vitest: 4.1.8 zod: 4.4.3 @@ -84,3 +84,8 @@ allowBuilds: ssh2: true minimumReleaseAgeStrict: true +# 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"