diff --git a/.agents/rules/handlers.md b/.agents/rules/handlers.md index 10259b3b..653084a3 100644 --- a/.agents/rules/handlers.md +++ b/.agents/rules/handlers.md @@ -99,11 +99,13 @@ Typed-error semantics inside the workflow context: ## Worker Setup -`createWorker` returns `AsyncResult` (org rule: -`Typed*.create()` factories model creation failures on the Err channel). -Same shape on the client: `TypedClient.create({ contract, client })` returns -`AsyncResult`. Deprecated throwing aliases -(`createWorkerOrThrow`, `TypedClient.createOrThrow`) exist for migration. +`createWorker` returns `AsyncResult` — bundling / connection +failures are _technical_ infrastructure faults, so they ride the **defect** +channel (a `TechnicalError` instance as the cause), not the modeled Err +channel. Same shape on the client: `TypedClient.create({ contract, client })` +returns `AsyncResult` (a `TechnicalError`-caused defect on +setup failure). Deprecated throwing aliases (`createWorkerOrThrow`, +`TypedClient.createOrThrow`) exist for migration. ```typescript import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker"; @@ -114,8 +116,8 @@ const workerResult = await createWorker({ workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), activities, }); -if (workerResult.isErr()) { - // bundling / connection failure — modeled, not thrown +if (workerResult.isDefect()) { + // bundling / connection failure — a TechnicalError-caused defect, not thrown process.exit(1); } diff --git a/.changeset/bump-unthrown-beta-5.md b/.changeset/bump-unthrown-beta-5.md new file mode 100644 index 00000000..b4231b9f --- /dev/null +++ b/.changeset/bump-unthrown-beta-5.md @@ -0,0 +1,12 @@ +--- +"@temporal-contract/contract": patch +"@temporal-contract/client": patch +"@temporal-contract/worker": patch +--- + +Bump `unthrown` to `5.0.0-beta.5`. This tracks two beta breaking changes: +`match`'s error handler key is renamed `err` → `errCases`, and the bare error +combinators gained the `*Cases` suffix (`flatMapErr` → `flatMapErrCases`, +`tapErr` → `tapErrCases`). `unthrown` also now declares `ts-pattern` as a peer +dependency, so `ts-pattern` (`^5`) is added alongside it. The peer range is +raised to `^5.0.0-beta.5`. diff --git a/.changeset/technical-errors-to-defect.md b/.changeset/technical-errors-to-defect.md new file mode 100644 index 00000000..1016e3b9 --- /dev/null +++ b/.changeset/technical-errors-to-defect.md @@ -0,0 +1,15 @@ +--- +"@temporal-contract/client": patch +"@temporal-contract/contract": patch +"@temporal-contract/worker": patch +--- + +Route `TechnicalError` and `RuntimeClientError` to unthrown's defect channel instead of the modeled `Err` channel. + +These two errors describe technical/infrastructure failures (connection/bundling faults, an unknown schedule ID, an unrecognized Temporal rejection) that are never branched on for domain logic. Per unthrown's Thesis #1, the `E` channel is only for anticipated domain failures, so they now surface as a `Defect` whose `cause` is a `TechnicalError` / `RuntimeClientError` instance — the classes stay exported so the descriptive message, `operation`, and `cause` survive for logging. + +**Breaking.** Consumers who matched these on the error channel must move to the defect channel: + +- `TypedClient.create` and `createWorker` now return `AsyncResult<_, never>` (was `AsyncResult<_, TechnicalError>`). Inspect setup faults via `result.isDefect()` / `match`'s `defect` handler / `recoverDefect`, not `isErr()`. +- Every modeled error union drops `RuntimeClientError` (`startWorkflow`, `signalWithStart`, `executeWorkflow`, `getHandle`, handle `queries`/`signals`/`updates`/`result`/`terminate`/`cancel`/`describe`/`fetchHistory`, the schedule handle methods, `ClientCallError`). Schedule handle methods now return `AsyncResult<_, never>`. +- Drop any `.with(tag("@temporal-contract/RuntimeClientError"), …)` / `.with(tag("@temporal-contract/TechnicalError"), …)` arm from exhaustive matchers; handle these in the `defect` arm (e.g. `recoverDefect` / `tapDefect`), matching on `cause instanceof RuntimeClientError` where needed. diff --git a/docs/examples/basic-order-processing.md b/docs/examples/basic-order-processing.md index 838185be..06cc5354 100644 --- a/docs/examples/basic-order-processing.md +++ b/docs/examples/basic-order-processing.md @@ -79,7 +79,7 @@ This example demonstrates the unthrown-based pattern: - 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 `.flatMap` / `.map` / `.mapErr` +- Enables railway-oriented programming via `.flatMap` / `.map` / `.mapErrCases` ### Worker Application diff --git a/docs/guide/activity-handlers.md b/docs/guide/activity-handlers.md index 10495943..2358a535 100644 --- a/docs/guide/activity-handlers.md +++ b/docs/guide/activity-handlers.md @@ -341,9 +341,15 @@ import { } from "@temporal-contract/worker/activity"; const logging: ActivityMiddleware = ({ activityName, workflowName }, next) => - next().tapErr((error) => { - logger.warn({ activityName, workflowName, error }, "activity failed"); - }); + next().tapErrCases((matcher) => + matcher.with( + P.instanceOf(ApplicationFailure), + tag("@temporal-contract/ContractError"), + (error) => { + logger.warn({ activityName, workflowName, error }, "activity failed"); + }, + ), + ); const timing: ActivityMiddleware = async ({ activityName }, next) => { const started = Date.now(); diff --git a/docs/guide/client-usage.md b/docs/guide/client-usage.md index 1a99ea35..f69fdef7 100644 --- a/docs/guide/client-usage.md +++ b/docs/guide/client-usage.md @@ -26,15 +26,16 @@ const connection = await Connection.connect({ // Create Temporal client and typed client const temporalClient = new Client({ connection }); -// Creation returns AsyncResult — connection -// and capability failures land on the Err channel instead of throwing. +// Creation returns AsyncResult — connection and capability +// failures are technical faults that ride the defect channel (a TechnicalError +// instance as the cause), not the modeled Err channel, and are never thrown. const clientResult = await TypedClient.create({ contract: myContract, client: temporalClient, }); -if (!clientResult.isOk()) { - // Err carries the modeled TechnicalError; a defect carries the raw cause. - throw clientResult.isErr() ? clientResult.error : clientResult.cause; +if (clientResult.isDefect()) { + // The defect's cause is a TechnicalError describing the setup failure. + throw clientResult.cause; } const client = clientResult.value; ``` @@ -63,9 +64,18 @@ result.match({ ok: (output) => { console.log("Order processed:", output.status); // TypeScript knows the shape! }, - err: (error) => { - console.error("Workflow failed:", error); - }, + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ContractError"), + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (error) => { + console.error("Workflow failed:", error); + }, + ), defect: (cause) => { console.error("Unexpected failure:", cause); }, @@ -94,13 +104,26 @@ handleResult.match({ const result = await handle.result(); result.match({ ok: (output) => console.log("Completed:", output), - err: (error) => console.error("Failed:", error), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ContractError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (error) => console.error("Failed:", error), + ), defect: (cause) => console.error("Unexpected failure:", cause), }); }, - err: (error) => { - console.error("Failed to start workflow:", error); - }, + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + (error) => { + console.error("Failed to start workflow:", error); + }, + ), defect: (cause) => { console.error("Unexpected failure:", cause); }, @@ -142,7 +165,7 @@ await client.executeWorkflow("invalidWorkflow", { The client uses `unthrown` for explicit error handling: ```typescript -import { Result } from "unthrown"; +import { Result, tag } from "unthrown"; const result = await client.executeWorkflow("processOrder", { workflowId: "order-123", @@ -154,9 +177,18 @@ result.match({ ok: (value) => { console.log("Order processed:", value.transactionId); }, - err: (error) => { - console.error("Order failed:", error); - }, + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ContractError"), + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (error) => { + console.error("Order failed:", error); + }, + ), defect: (cause) => { console.error("Unexpected failure:", cause); }, @@ -200,7 +232,12 @@ handleResult.match({ const statusResult = await handle.queries.getStatus({}); statusResult.match({ ok: (status) => console.log("Status:", status), - err: (error) => console.error("Query failed:", error), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/QueryValidationError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (error) => console.error("Query failed:", error), + ), defect: (cause) => console.error("Unexpected failure:", cause), }); @@ -208,7 +245,12 @@ handleResult.match({ const signalResult = await handle.signals.cancelOrder({ reason: "Customer request" }); signalResult.match({ ok: () => console.log("Signal sent"), - err: (error) => console.error("Signal failed:", error), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/SignalValidationError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (error) => console.error("Signal failed:", error), + ), defect: (cause) => console.error("Unexpected failure:", cause), }); @@ -216,11 +258,21 @@ handleResult.match({ const result = await handle.result(); result.match({ ok: (output) => console.log("Result:", output), - err: (error) => console.error("Workflow failed:", error), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ContractError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (error) => console.error("Workflow failed:", error), + ), defect: (cause) => console.error("Unexpected failure:", cause), }); }, - err: (error) => console.error("Failed to get handle:", error), + errCases: (matcher) => + matcher.with(tag("@temporal-contract/WorkflowNotFoundError"), (error) => + console.error("Failed to get handle:", error), + ), defect: (cause) => console.error("Unexpected failure:", cause), }); ``` @@ -253,7 +305,16 @@ const result = await client.executeWorkflow("processOrder", { result.match({ ok: (value) => console.log("Success:", value), - err: (error) => console.error("Workflow returned error:", error), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ContractError"), + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (error) => console.error("Workflow returned error:", error), + ), defect: (cause) => console.error("Unexpected failure:", cause), }); ``` @@ -320,21 +381,37 @@ import type { ClientInterceptor } from "@temporal-contract/client"; // Observe every operation const logging: ClientInterceptor = (args, next) => - next().tapErr((error) => { - logger.warn({ operation: args.operation, workflowId: args.workflowId, error }); - }); - -// Retry a transient failure once -const retryOnce: ClientInterceptor = (args, next) => - next().flatMapErr((error): ReturnType => - error instanceof RuntimeClientError ? next() : Err(error).toAsync(), + next().tapErrCases((matcher) => + matcher.with( + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + tag("@temporal-contract/SignalValidationError"), + tag("@temporal-contract/QueryValidationError"), + tag("@temporal-contract/UpdateValidationError"), + tag("@temporal-contract/ContractError"), + (error) => { + logger.warn({ operation: args.operation, workflowId: args.workflowId, error }); + }, + ), ); +// Retry a transient failure once. Transient technical faults (an unrecognized +// Temporal rejection, a dropped connection) ride the defect channel now, so the +// retry re-enters via `recoverDefect` — a defect is the retry signal, and any +// genuinely-modeled `Err` still flows through untouched. +const retryOnce: ClientInterceptor = (args, next) => next().recoverDefect(() => next()); + +// `create` returns AsyncResult — its only failures are +// defects, so `get()` (not `getOrThrow()`) is the extractor; it panics +// (rethrowing the defect's TechnicalError cause) if setup failed. const client = await TypedClient.create({ contract: myContract, client: temporalClient, interceptors: [logging, retryOnce], -}).getOrThrow(); +}).get(); ``` An interceptor can also patch the invocation (`next({ input })`) or @@ -356,11 +433,11 @@ const temporalClient = new Client({ connection }); const orderClient = await TypedClient.create({ contract: orderContract, client: temporalClient, -}).getOrThrow(); +}).get(); const inventoryClient = await TypedClient.create({ contract: inventoryContract, client: temporalClient, -}).getOrThrow(); +}).get(); // Both clients share the same connection and Temporal client instance ``` @@ -399,15 +476,15 @@ const temporalClient = new Client({ connection }); const orderClient = await TypedClient.create({ contract: orderContract, client: temporalClient, -}).getOrThrow(); +}).get(); const paymentClient = await TypedClient.create({ contract: paymentContract, client: temporalClient, -}).getOrThrow(); +}).get(); const inventoryClient = await TypedClient.create({ contract: inventoryContract, client: temporalClient, -}).getOrThrow(); +}).get(); // Each client is typed to its contract await orderClient.executeWorkflow("processOrder", {/* ... */}); @@ -458,7 +535,7 @@ const temporalClient = new Client({ connection }); const client = await TypedClient.create({ contract: contract, client: temporalClient, -}).getOrThrow(); +}).get(); // ❌ Avoid - creating connections repeatedly for (const order of orders) { @@ -467,7 +544,7 @@ for (const order of orders) { const client = await TypedClient.create({ contract: contract, client: temporalClient, - }).getOrThrow(); + }).get(); await client.executeWorkflow(/* ... */); } ``` @@ -496,11 +573,20 @@ result.match({ // Handle success updateDatabase(value); }, - err: (error) => { - // Handle error - logError(error); - notifySupport(error); - }, + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ContractError"), + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (error) => { + // Handle error + logError(error); + notifySupport(error); + }, + ), defect: (cause) => { // Handle unexpected failure (bug) logError(cause); diff --git a/docs/guide/core-concepts.md b/docs/guide/core-concepts.md index abde9e89..b5b62b38 100644 --- a/docs/guide/core-concepts.md +++ b/docs/guide/core-concepts.md @@ -129,7 +129,7 @@ const temporalClient = new Client({ connection }); const client = await TypedClient.create({ contract: contract, client: temporalClient, -}).getOrThrow(); +}).get(); // TypeScript knows the exact argument types const result = await client.executeWorkflow("processOrder", { @@ -137,10 +137,19 @@ const result = await client.executeWorkflow("processOrder", { args: { orderId: "ORD-123", amount: 100 }, }); -// result is a Result — use .match({ ok, err, defect }) or isOk(result) to unwrap +// result is a Result — use .match({ ok, errCases, defect }) or isOk(result) to unwrap result.match({ ok: (output) => console.log(output.transactionId), - err: (error) => console.error("Workflow failed:", error), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ContractError"), + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (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 a1bf84fa..107ef7db 100644 --- a/docs/guide/entry-points.md +++ b/docs/guide/entry-points.md @@ -219,7 +219,7 @@ const temporalClient = new Client({ connection, namespace: "default" }); const client = await TypedClient.create({ contract: orderContract, client: temporalClient, -}).getOrThrow(); +}).get(); // Start workflow with full type safety (returns AsyncResult) const handleResult = await client.startWorkflow("processOrder", { @@ -233,11 +233,24 @@ handleResult.match({ const result = await handle.result(); result.match({ ok: (output) => console.log(output.success), // ✅ TypeScript knows the shape - err: (error) => console.error("Workflow failed:", error), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ContractError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (error) => console.error("Workflow failed:", error), + ), defect: (cause) => console.error("Unexpected failure:", cause), }); }, - err: (error) => console.error("Failed to start workflow:", error), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + (error) => console.error("Failed to start workflow:", error), + ), defect: (cause) => console.error("Unexpected failure:", cause), }); ``` diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 5ff3d29f..d19d329f 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -191,15 +191,17 @@ const connection = await NativeConnection.connect({ address: "localhost:7233", }); -// Creation returns AsyncResult — bundling and -// connection failures land on the Err channel instead of throwing. +// Creation returns AsyncResult — bundling and connection +// failures are technical faults that ride the defect channel (a TechnicalError +// cause), not the Err channel. `get()` panics (rethrowing that cause) on +// failure; there is no modeled error to `getOrThrow`. const worker = await createWorker({ contract: orderContract, // the task queue comes from the contract connection, // ESM-safe path resolution (there is no `require.resolve` in ESM) workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), activities, -}).getOrThrow(); +}).get(); await worker.run(); ``` @@ -215,10 +217,12 @@ const connection = await Connection.connect({ }); const temporalClient = new Client({ connection }); +// `create` returns AsyncResult — setup faults ride the +// defect channel (a TechnicalError cause), so `get()` is the extractor. const client = await TypedClient.create({ contract: orderContract, client: temporalClient, -}).getOrThrow(); +}).get(); // Fully typed workflow execution with Result/AsyncResult pattern const resultAsync = client.executeWorkflow("processOrder", { @@ -232,9 +236,18 @@ result.match({ ok: (output) => { console.log(output.status); // 'success' | 'failed' — fully typed! }, - err: (error) => { - console.error("Workflow failed:", error); - }, + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ContractError"), + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (error) => { + console.error("Workflow failed:", error); + }, + ), defect: (cause) => { console.error("Unexpected failure:", cause); }, diff --git a/docs/guide/migrating-to-unthrown.md b/docs/guide/migrating-to-unthrown.md index 57aa2b6a..7a72cefc 100644 --- a/docs/guide/migrating-to-unthrown.md +++ b/docs/guide/migrating-to-unthrown.md @@ -19,7 +19,8 @@ This page is an end-to-end mapping for upgrading existing code. 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. + `_tag` discriminant, enabling exhaustive `match` folds over the error channel + (the `errCases` matcher). - **Active maintenance**: a first-party, actively-maintained library (neverthrow's releases have stalled, see [supermacro/neverthrow#670](https://github.com/supermacro/neverthrow/issues/670)). @@ -51,21 +52,21 @@ the same name but is now imported from `"unthrown"`. ## 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)` / `.flatMapErr(fn)` (`orElse` was renamed in unthrown 4.1) | -| `Result.combine([...])` | `all([...])` | -| `result.match(okFn, errFn)` (positional) | `result.match({ ok, err, defect })` (object, 3 channels) | -| `result.isOk()` / `result.isErr()` to narrow | `result.isOk()` / `result.isErr()` / `result.isDefect()` (methods narrow; `isOk(result)` free functions also exist) | +| 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)` / `.mapErrCases(m)` / `.flatMapErrCases(m)` (the error combinators take an exhaustive ts-pattern matcher `m`, not a plain callback) | +| `Result.combine([...])` | `all([...])` | +| `result.match(okFn, errFn)` (positional) | `result.match({ ok, errCases, defect })` (object, 3 channels; `errCases` is an exhaustive matcher) | +| `result.isOk()` / `result.isErr()` to narrow | `result.isOk()` / `result.isErr()` / `result.isDefect()` (methods narrow; `isOk(result)` free functions also exist) | ## `okAsync` / `errAsync` are gone @@ -147,12 +148,24 @@ if (result.isOk()) { - ); + result.match({ + ok: (output) => console.log("Order:", output), -+ err: (error) => console.error("Failed:", error), ++ errCases: (matcher) => ++ matcher.with( ++ tag("@temporal-contract/ContractError"), ++ tag("@temporal-contract/WorkflowNotFoundError"), ++ tag("@temporal-contract/WorkflowValidationError"), ++ tag("@temporal-contract/WorkflowAlreadyStartedError"), ++ tag("@temporal-contract/WorkflowFailedError"), ++ tag("@temporal-contract/WorkflowExecutionNotFoundError"), ++ (error) => console.error("Failed:", error), ++ ), + defect: (cause) => console.error("Unexpected:", cause), + }); ``` -Always add the `defect` handler — it is a required, distinct channel. +Always add the `defect` handler — it is a required, distinct channel. The +`errCases` matcher is **exhaustive**: every error tag must be handled (grouped +with a single handler is fine), so a new error variant is a compile error until +you handle it. ## Error classes: `TaggedError` @@ -172,17 +185,20 @@ with a `_tag` discriminant: + }> {} ``` -Because every tagged error carries a `_tag`, unthrown's `matchTags` folds a -`Result` exhaustively by tag, with dedicated `Ok` and `Defect` channels: +Because every tagged error carries a `_tag`, `match`'s `errCases` matcher folds +a `Result` exhaustively by tag, alongside the 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)}`, +import { tag } from "unthrown"; + +const message = result.match({ + ok: (value) => `charged ${value.transactionId}`, + errCases: (matcher) => + matcher + .with(tag("PaymentDeclined"), (e) => `declined for ${e.customerId}`) + .with(tag("GatewayTimeout"), (e) => `timed out after ${e.elapsedMs}ms`), + defect: (cause) => `unexpected: ${String(cause)}`, }); ``` @@ -193,9 +209,9 @@ const message = matchTags(result, { > [!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": ... })`. +> error's `.name` stays the bare class name. If you fold library errors with +> the `errCases` matcher, the patterns carry the prefix: +> `matcher.with(tag("@temporal-contract/WorkflowExecutionNotFoundError"), …)`. ## End-to-end activity example @@ -259,7 +275,16 @@ result.match( const result = await client.executeWorkflow("processOrder", { workflowId, args }); result.match({ ok: (output) => console.log("Order:", output), - err: (error) => console.error("Failed:", error), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ContractError"), + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (error) => console.error("Failed:", error), + ), defect: (cause) => console.error("Unexpected:", cause), }); ``` diff --git a/docs/guide/result-pattern.md b/docs/guide/result-pattern.md index a273b832..203fce94 100644 --- a/docs/guide/result-pattern.md +++ b/docs/guide/result-pattern.md @@ -134,7 +134,7 @@ const temporalClient = new Client({ connection }); const client = await TypedClient.create({ contract: orderContract, client: temporalClient, -}).getOrThrow(); +}).get(); const result = await client.executeWorkflow("processOrder", { workflowId: "order-123", args: { orderId: "ORD-123", amount: 100 }, @@ -145,9 +145,18 @@ result.match({ ok: (value) => { console.log("Order processed:", value.transactionId); }, - err: (error) => { - console.error("Order failed:", error); - }, + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ContractError"), + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (error) => { + console.error("Order failed:", error); + }, + ), defect: (cause) => { console.error("Unexpected failure:", cause); }, @@ -158,7 +167,7 @@ result.match({ `AsyncResult` is a thin wrapper around a `Promise>`. You can `await` it once and then inspect synchronously, or chain with -`.map`, `.mapErr`, `.flatMap`, `.flatMapErr` before awaiting: +`.map`, `.mapErrCases`, `.flatMap`, `.flatMapErrCases` before awaiting: ```typescript import { isErr } from "unthrown"; @@ -355,12 +364,14 @@ const processOrder = ({ orderId }) => .flatMap((validId) => fetchOrder(validId)) .flatMap((order) => processPayment(order)) .flatMap((payment) => updateDatabase(payment)) - .mapErr((error) => - ApplicationFailure.create({ - type: "ORDER_FAILED", - message: "Order processing failed", - cause: error instanceof Error ? error : undefined, - }), + .mapErrCases((matcher) => + matcher.with(tag("@temporal-contract/ContractError"), (error) => + ApplicationFailure.create({ + type: "ORDER_FAILED", + message: "Order processing failed", + cause: error instanceof Error ? error : undefined, + }), + ), ); // Stops at first error ``` @@ -412,13 +423,14 @@ unthrown exposes `all([...])` to fan in a list of `Result`s into a single array, or call `.match` on the result: ```typescript -import { all } from "unthrown"; +import { all, tag } from "unthrown"; const combined = all([validateA(a), validateB(b), validateC(c)]); return combined.match({ ok: ([resA, resB, resC]) => proceed({ resA, resB, resC }), - err: (error) => fail(error), + errCases: (matcher) => + matcher.with(tag("@temporal-contract/ContractError"), (error) => fail(error)), defect: (cause) => fail(cause), }); ``` @@ -449,10 +461,16 @@ export const parentWorkflow = declareWorkflow({ success: true, transactionId: output.transactionId, }), - err: (error) => ({ - success: false, - error: error.message, - }), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ChildWorkflowError"), + tag("@temporal-contract/ChildWorkflowCancelledError"), + tag("@temporal-contract/ChildWorkflowNotFoundError"), + (error) => ({ + success: false, + error: error.message, + }), + ), defect: (cause) => ({ success: false, error: cause instanceof Error ? cause.message : "Unexpected failure", @@ -482,9 +500,15 @@ export const parentWorkflow = declareWorkflow({ // Can wait for result later if needed const result = await handle.result(); }, - err: (error) => { - console.error("Failed to start child:", error); - }, + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ChildWorkflowError"), + tag("@temporal-contract/ChildWorkflowCancelledError"), + tag("@temporal-contract/ChildWorkflowNotFoundError"), + (error) => { + console.error("Failed to start child:", error); + }, + ), defect: (cause) => { console.error("Unexpected failure starting child:", cause); }, @@ -521,10 +545,16 @@ export const orderWorkflow = declareWorkflow({ // Workflows return plain objects, not Result return notifyResult.match({ ok: () => ({ status: "completed" }), - err: (error) => ({ - status: "failed", - error: error.message, - }), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ChildWorkflowError"), + tag("@temporal-contract/ChildWorkflowCancelledError"), + tag("@temporal-contract/ChildWorkflowNotFoundError"), + (error) => ({ + status: "failed", + error: error.message, + }), + ), defect: (cause) => ({ status: "failed", error: cause instanceof Error ? cause.message : "Unexpected failure", @@ -576,7 +606,7 @@ 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` +## `TaggedError` and exhaustive matching Error classes are built with `TaggedError`, which stamps each class with a `_tag` discriminant: @@ -602,20 +632,25 @@ class GatewayTimeout extends TaggedError("GatewayTimeout")<{ > 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": ... })`. +> for readable logs. When folding library errors, the `errCases` matcher uses +> the prefixed tag: +> `matcher.with(tag("@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: +Because every tagged error carries a `_tag`, `match`'s `errCases` matcher folds +a `Result` exhaustively by tag, alongside the dedicated `ok` and `defect` +channels. The matcher is **exhaustive**: a missing tag is a compile error, so +enriching the error union forces every fold to be updated: ```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)}`, +import { tag } from "unthrown"; + +const message = result.match({ + ok: (value) => `charged ${value.transactionId}`, + errCases: (matcher) => + matcher + .with(tag("PaymentDeclined"), (e) => `declined for ${e.customerId}`) + .with(tag("GatewayTimeout"), (e) => `timed out after ${e.elapsedMs}ms`), + defect: (cause) => `unexpected: ${String(cause)}`, }); ``` diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 3f08d04e..dcbb1293 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -28,11 +28,11 @@ it("processes the order", async ({ testEnv }) => { connection: testEnv.nativeConnection, workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"), activities, - }).getOrThrow(); + }).get(); const client = await TypedClient.create({ contract: myContract, client: testEnv.client, - }).getOrThrow(); + }).get(); await worker.runUntil(async () => { const result = await client.executeWorkflow("processOrder", { @@ -127,7 +127,7 @@ const it = baseIt.extend<{ ], client: async ({ clientConnection }, use) => { const rawClient = new Client({ connection: clientConnection, namespace: "default" }); - await use(await TypedClient.create({ contract: myContract, client: rawClient }).getOrThrow()); + await use(await TypedClient.create({ contract: myContract, client: rawClient }).get()); }, }); diff --git a/docs/guide/why-temporal-contract.md b/docs/guide/why-temporal-contract.md index 8095f88c..ae2a5987 100644 --- a/docs/guide/why-temporal-contract.md +++ b/docs/guide/why-temporal-contract.md @@ -130,7 +130,7 @@ const contract = defineContract({ const client = await TypedClient.create({ contract: contract, client: temporalClient, -}).getOrThrow(); +}).get(); const future = client.executeWorkflow("processOrder", { workflowId: "order-123", @@ -169,7 +169,16 @@ const result = await client.executeWorkflow("processOrder", { result.match({ ok: (output) => console.log("Success:", output), - err: (error) => console.error("Validation failed:", error), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ContractError"), + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (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 e46f8711..356bf343 100644 --- a/docs/guide/worker-implementation.md +++ b/docs/guide/worker-implementation.md @@ -504,7 +504,13 @@ export const parentWorkflow = declareWorkflow({ result.match({ ok: (output) => console.log("Payment processed:", output), - err: (error) => console.error("Payment failed:", error), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ChildWorkflowError"), + tag("@temporal-contract/ChildWorkflowCancelledError"), + tag("@temporal-contract/ChildWorkflowNotFoundError"), + (error) => console.error("Payment failed:", error), + ), defect: (cause) => console.error("Unexpected failure:", cause), }); @@ -576,9 +582,15 @@ export const orderWorkflow = declareWorkflow({ // Can wait for result later if needed const result = await handle.result(); }, - err: (error) => { - console.error("Failed to start notification:", error); - }, + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ChildWorkflowError"), + tag("@temporal-contract/ChildWorkflowCancelledError"), + tag("@temporal-contract/ChildWorkflowNotFoundError"), + (error) => { + console.error("Failed to start notification:", error); + }, + ), defect: (cause) => { console.error("Unexpected failure starting notification:", cause); }, @@ -604,14 +616,20 @@ result.match({ // Child workflow completed successfully console.log("Transaction ID:", output.transactionId); }, - err: (error) => { - // Handle child workflow errors - if (error instanceof ChildWorkflowNotFoundError) { - console.error("Workflow not found in contract"); - } else { - console.error("Child workflow failed:", error.message); - } - }, + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ChildWorkflowError"), + tag("@temporal-contract/ChildWorkflowCancelledError"), + tag("@temporal-contract/ChildWorkflowNotFoundError"), + (error) => { + // Handle child workflow errors + if (error instanceof ChildWorkflowNotFoundError) { + console.error("Workflow not found in contract"); + } else { + console.error("Child workflow failed:", error.message); + } + }, + ), defect: (cause) => { // Unexpected failure (bug), not a modeled child-workflow error console.error("Unexpected failure:", cause); diff --git a/docs/guide/worker-usage.md b/docs/guide/worker-usage.md index 8988bbc0..93c7c0cb 100644 --- a/docs/guide/worker-usage.md +++ b/docs/guide/worker-usage.md @@ -183,10 +183,16 @@ export const parentWorkflow = declareWorkflow({ success: true, transactionId: output.transactionId, }), - err: (error) => ({ - success: false, - error: error.message, - }), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ChildWorkflowError"), + tag("@temporal-contract/ChildWorkflowCancelledError"), + tag("@temporal-contract/ChildWorkflowNotFoundError"), + (error) => ({ + success: false, + error: error.message, + }), + ), defect: (cause) => ({ success: false, error: cause instanceof Error ? cause.message : "Unexpected failure", @@ -272,7 +278,7 @@ describe("Activities", () => { ## Best Practices -### 1. Use `fromPromise` with `.map` / `.mapErr` for Activities +### 1. Use `fromPromise` with `.map` / `.mapErrCases` for Activities Activities should pass the error mapper directly to `fromPromise` and chain `.map` for the success path: diff --git a/docs/index.md b/docs/index.md index 3ce1153c..5cfa833f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -130,7 +130,7 @@ const temporalClient = new Client({ connection }); const client = await TypedClient.create({ contract: orderContract, client: temporalClient, -}).getOrThrow(); +}).get(); const future = client.executeWorkflow("processOrder", { workflowId: "order-123", @@ -141,7 +141,16 @@ const result = await future; result.match({ ok: (output) => console.log(output.status), // ✅ 'success' | 'failed' - err: (error) => console.error("Failed:", error), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ContractError"), + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + (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 d16f413a..16cf20bb 100644 --- a/examples/order-processing-client/package.json +++ b/examples/order-processing-client/package.json @@ -13,6 +13,7 @@ "@temporalio/client": "catalog:", "pino": "catalog:", "pino-pretty": "catalog:", + "ts-pattern": "catalog:", "unthrown": "catalog:", "zod": "catalog:" }, diff --git a/examples/order-processing-client/src/client.ts b/examples/order-processing-client/src/client.ts index bc75abb2..bf583583 100644 --- a/examples/order-processing-client/src/client.ts +++ b/examples/order-processing-client/src/client.ts @@ -36,7 +36,8 @@ async function run() { }); // Create type-safe client with unthrown AsyncResult pattern — creation - // failures (bad connection, missing Schedule API) land on the Err channel. + // failures (bad connection, missing Schedule API) are technical faults that + // ride the defect channel (a `TechnicalError` cause), not the Err channel. const clientResult = await TypedClient.create({ contract: orderProcessingContract, client: rawClient, @@ -124,7 +125,7 @@ async function run() { ); } }, - err: (matcher) => + errCases: (matcher) => matcher .with(tag("@temporal-contract/WorkflowNotFoundError"), (err) => logger.error({ error: err, orderId: order.orderId }, "❌ Workflow not found"), @@ -152,11 +153,10 @@ async function run() { { error: err, orderId: order.orderId }, "❌ Workflow execution not found in namespace", ), - ) - .with(tag("@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. + // A defect is an unmodeled failure (a bug) — including technical/ + // infrastructure faults like a dropped connection (a `RuntimeClientError` + // cause) — not an anticipated outcome. defect: (cause) => logger.error({ cause, orderId: order.orderId }, "❌ Unexpected failure processing order"), }); @@ -198,7 +198,7 @@ async function run() { }; logger.info({ data: summary }, `📊 Order summary: ${summary.message}`); }, - err: (matcher) => + errCases: (matcher) => matcher .with(tag("@temporal-contract/WorkflowNotFoundError"), (err) => logger.error({ error: err }, "❌ Workflow not found"), @@ -214,11 +214,10 @@ async function run() { ) .with(tag("@temporal-contract/WorkflowExecutionNotFoundError"), (err) => logger.error({ error: err }, "❌ Workflow execution not found in namespace"), - ) - .with(tag("@temporal-contract/RuntimeClientError"), (err) => - logger.error({ error: err }, "❌ Workflow execution failed"), ), - // A defect is an unmodeled failure (a bug), not an anticipated outcome. + // A defect is an unmodeled failure (a bug) — including technical/ + // infrastructure faults like a dropped connection (a `RuntimeClientError` + // cause) — not an anticipated outcome. defect: (cause) => logger.error({ cause }, "❌ Unexpected failure executing workflow"), }); diff --git a/examples/order-processing-contract/README.md b/examples/order-processing-contract/README.md index a39cc3f7..9d39c16a 100644 --- a/examples/order-processing-contract/README.md +++ b/examples/order-processing-contract/README.md @@ -57,11 +57,13 @@ import { } from "@temporal-contract/sample-order-processing-contract"; import { TypedClient } from "@temporal-contract/client"; -// Create type-safe client — creation returns AsyncResult<_, TechnicalError> +// Create type-safe client — creation returns AsyncResult<_, never>; setup +// faults ride the defect channel (a TechnicalError cause), so `get()` panics +// (rethrowing that cause) on failure. const client = await TypedClient.create({ contract: orderProcessingContract, client: new Client({ connection, namespace: "default" }), -}).getOrThrow(); +}).get(); // Start workflow with full type safety const handle = await client.startWorkflow("processOrder", { diff --git a/examples/order-processing-worker/package.json b/examples/order-processing-worker/package.json index 4ea5a5a9..3b7fb534 100644 --- a/examples/order-processing-worker/package.json +++ b/examples/order-processing-worker/package.json @@ -15,6 +15,7 @@ "@temporalio/workflow": "catalog:", "pino": "catalog:", "pino-pretty": "catalog:", + "ts-pattern": "catalog:", "unthrown": "catalog:", "zod": "catalog:" }, diff --git a/examples/order-processing-worker/src/application/worker.ts b/examples/order-processing-worker/src/application/worker.ts index d9454775..6a8dfb19 100644 --- a/examples/order-processing-worker/src/application/worker.ts +++ b/examples/order-processing-worker/src/application/worker.ts @@ -29,7 +29,8 @@ async function run() { }); // Create and run the worker using createWorker — creation failures are - // modeled on the Err channel (TechnicalError), not thrown. + // technical faults that ride the defect channel (a TechnicalError cause), + // not the Err channel and not thrown. const workerResult = await createWorker({ contract: orderProcessingContract, connection, diff --git a/packages/client/package.json b/packages/client/package.json index 623e4dd5..c0202edc 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -66,6 +66,7 @@ "@types/node": "catalog:", "@unthrown/vitest": "catalog:", "@vitest/coverage-v8": "catalog:", + "ts-pattern": "catalog:", "tsdown": "catalog:", "typedoc": "catalog:", "typedoc-plugin-markdown": "catalog:", @@ -77,7 +78,8 @@ "peerDependencies": { "@temporalio/client": "^1", "@temporalio/common": "^1", - "unthrown": "^5.0.0-beta.3" + "ts-pattern": "^5", + "unthrown": "^5.0.0-beta.5" }, "engines": { "node": ">=22.19.0" diff --git a/packages/client/src/__tests__/client.spec.ts b/packages/client/src/__tests__/client.spec.ts index 56830fc0..71965904 100644 --- a/packages/client/src/__tests__/client.spec.ts +++ b/packages/client/src/__tests__/client.spec.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { it as baseIt } from "@temporal-contract/testing/extension"; import { Client } from "@temporalio/client"; import { Worker } from "@temporalio/worker"; -import { P } from "unthrown"; +import { tag } from "unthrown"; import { describe, expect, vi, beforeEach } from "vitest"; import { TypedClient } from "../client.js"; @@ -402,10 +402,17 @@ describe("Client Package - Integration Tests", () => { matched = true; expect(value).toEqual({ result: "Processed: test" }); }, - err: (matcher) => - matcher.with(P._, () => { - throw new Error("Should not be called"); - }), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + () => { + throw new Error("Should not be called"); + }, + ), defect: () => { throw new Error("Should not be called"); }, diff --git a/packages/client/src/client.spec.ts b/packages/client/src/client.spec.ts index 1a642c92..0e5d0618 100644 --- a/packages/client/src/client.spec.ts +++ b/packages/client/src/client.spec.ts @@ -1,5 +1,5 @@ import { defineContract, defineSearchAttribute, defineWorkflow } from "@temporal-contract/contract"; -import { ContractError } from "@temporal-contract/contract/errors"; +import { ContractError, TechnicalError } from "@temporal-contract/contract/errors"; import { type Client, WorkflowExecutionAlreadyStartedError, @@ -11,7 +11,7 @@ import { TypedSearchAttributes, WorkflowNotFoundError as TemporalWorkflowNotFoundError, } from "@temporalio/common"; -import { Err, P } from "unthrown"; +import { tag } from "unthrown"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { z } from "zod"; @@ -154,17 +154,19 @@ describe("TypedClient", () => { } }); - it("surfaces a missing Schedule API as Err(TechnicalError) instead of throwing", async () => { + it("surfaces a missing Schedule API as a Defect(TechnicalError) instead of throwing", async () => { const oldClient = { workflow: mockWorkflow } as unknown as Client; const created = await TypedClient.create({ contract: testContract, client: oldClient }); - expect(created).toBeErr(); - if (created.isErr()) { - expect(created.error._tag).toBe("@temporal-contract/TechnicalError"); - expect(created.error.message).toMatch(/requires @temporalio\/client >= 1\.16/); + expect(created).toBeDefect(); + if (created.isDefect()) { + const cause = created.cause; + expect(cause).toBeInstanceOf(TechnicalError); + expect((cause as TechnicalError)._tag).toBe("@temporal-contract/TechnicalError"); + expect((cause as TechnicalError).message).toMatch(/requires @temporalio\/client >= 1\.16/); } }); - it("surfaces an eager-connection failure as Err(TechnicalError)", async () => { + it("surfaces an eager-connection failure as a Defect(TechnicalError)", async () => { const failing = new Error("connection refused"); const rawClient = { workflow: mockWorkflow, @@ -172,10 +174,12 @@ describe("TypedClient", () => { connection: { ensureConnected: vi.fn().mockRejectedValue(failing) }, } as unknown as Client; const created = await TypedClient.create({ contract: testContract, client: rawClient }); - expect(created).toBeErr(); - if (created.isErr()) { - expect(created.error._tag).toBe("@temporal-contract/TechnicalError"); - expect(created.error.cause).toBe(failing); + expect(created).toBeDefect(); + if (created.isDefect()) { + const cause = created.cause; + expect(cause).toBeInstanceOf(TechnicalError); + expect((cause as TechnicalError)._tag).toBe("@temporal-contract/TechnicalError"); + expect((cause as TechnicalError).cause).toBe(failing); } }); }); @@ -280,7 +284,7 @@ describe("TypedClient", () => { } }); - it("should return Error result when workflow execution throws", async () => { + it("surfaces a Defect(RuntimeClientError) when workflow execution throws an unrecognized error", async () => { mockWorkflow.execute.mockRejectedValue(new Error("Workflow execution failed")); const result = await typedClient.executeWorkflow("testWorkflow", { @@ -288,7 +292,11 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result).toBeErr(); + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + expect((result.cause as RuntimeClientError).operation).toBe("executeWorkflow"); + } }); }); @@ -394,7 +402,7 @@ describe("TypedClient", () => { expect(mockWorkflow.signalWithStart).not.toHaveBeenCalled(); }); - it("returns RuntimeClientError when the underlying Temporal call rejects", async () => { + it("surfaces a Defect(RuntimeClientError) when the underlying Temporal call rejects", async () => { mockWorkflow.signalWithStart.mockRejectedValue(new Error("temporal down")); const result = await typedClient.signalWithStart("testWorkflow", { @@ -404,10 +412,10 @@ describe("TypedClient", () => { signalArgs: [50], }); - expect(result).toBeErr(); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(RuntimeClientError); - expect((result.error as RuntimeClientError).operation).toBe("signalWithStart"); + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + expect((result.cause as RuntimeClientError).operation).toBe("signalWithStart"); } }); }); @@ -652,7 +660,7 @@ describe("TypedClient", () => { } }); - it("returns RuntimeClientError when the underlying query call rejects", async () => { + it("surfaces a Defect(RuntimeClientError) when the underlying query call rejects", async () => { mockHandle.query.mockRejectedValue(new Error("network down")); const handleResult = await typedClient.startWorkflow("testWorkflow", { @@ -663,10 +671,10 @@ describe("TypedClient", () => { const result = await handleResult.value.queries.getStatus([]); - expect(result).toBeErr(); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(RuntimeClientError); - expect((result.error as RuntimeClientError).operation).toBe("query"); + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + expect((result.cause as RuntimeClientError).operation).toBe("query"); } }); @@ -690,7 +698,7 @@ describe("TypedClient", () => { expect(mockHandle.signal).not.toHaveBeenCalled(); }); - it("returns RuntimeClientError when the underlying signal call rejects", async () => { + it("surfaces a Defect(RuntimeClientError) when the underlying signal call rejects", async () => { mockHandle.signal.mockRejectedValue(new Error("connection reset")); const handleResult = await typedClient.startWorkflow("testWorkflow", { @@ -701,10 +709,10 @@ describe("TypedClient", () => { const result = await handleResult.value.signals.updateProgress([50]); - expect(result).toBeErr(); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(RuntimeClientError); - expect((result.error as RuntimeClientError).operation).toBe("signal"); + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + expect((result.cause as RuntimeClientError).operation).toBe("signal"); } }); @@ -749,7 +757,7 @@ describe("TypedClient", () => { } }); - it("returns RuntimeClientError when the underlying update call rejects", async () => { + it("surfaces a Defect(RuntimeClientError) when the underlying update call rejects", async () => { mockHandle.executeUpdate.mockRejectedValue(new Error("update timeout")); const handleResult = await typedClient.startWorkflow("testWorkflow", { @@ -760,10 +768,10 @@ describe("TypedClient", () => { const result = await handleResult.value.updates.setConfig([{ value: "ok" }]); - expect(result).toBeErr(); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(RuntimeClientError); - expect((result.error as RuntimeClientError).operation).toBe("update"); + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + expect((result.cause as RuntimeClientError).operation).toBe("update"); } }); }); @@ -784,10 +792,17 @@ describe("TypedClient", () => { matched = true; expect(value).toEqual({ result: "success" }); }, - err: (matcher) => - matcher.with(P._, () => { - throw new Error("Should not be called"); - }), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/WorkflowNotFoundError"), + tag("@temporal-contract/WorkflowValidationError"), + tag("@temporal-contract/WorkflowAlreadyStartedError"), + tag("@temporal-contract/WorkflowFailedError"), + tag("@temporal-contract/WorkflowExecutionNotFoundError"), + () => { + throw new Error("Should not be called"); + }, + ), defect: () => { throw new Error("Should not be called"); }, @@ -965,12 +980,12 @@ describe("TypedClient", () => { }, }); - expect(result).toBeErr(); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(RuntimeClientError); - const op = (result.error as RuntimeClientError).operation; + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + const op = (result.cause as RuntimeClientError).operation; expect(op).toBe("searchAttributes"); - expect((result.error as RuntimeClientError).message).toContain("unknownAttr"); + expect((result.cause as RuntimeClientError).message).toContain("unknownAttr"); } // Temporal must NOT have been called with the bad attribute. expect(mockWorkflow.start).not.toHaveBeenCalled(); @@ -994,11 +1009,11 @@ describe("TypedClient", () => { }, ); - expect(result).toBeErr(); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(RuntimeClientError); - expect((result.error as RuntimeClientError).operation).toBe("searchAttributes"); - expect((result.error as RuntimeClientError).message).toContain("customerId"); + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + expect((result.cause as RuntimeClientError).operation).toBe("searchAttributes"); + expect((result.cause as RuntimeClientError).message).toContain("customerId"); } expect(mockWorkflow.start).not.toHaveBeenCalled(); }); @@ -1057,7 +1072,7 @@ describe("TypedClient", () => { } }); - it("startWorkflow falls through to RuntimeClientError for unrelated errors", async () => { + it("startWorkflow falls through to a Defect(RuntimeClientError) for unrelated errors", async () => { mockWorkflow.start.mockRejectedValue(new Error("network down")); const result = await typedClient.startWorkflow("testWorkflow", { @@ -1065,11 +1080,11 @@ describe("TypedClient", () => { args: { name: "hello", value: 42 }, }); - expect(result).toBeErr(); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(RuntimeClientError); - expect(result.error).not.toBeInstanceOf(WorkflowAlreadyStartedError); - expect((result.error as RuntimeClientError).operation).toBe("startWorkflow"); + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + expect(result.cause).not.toBeInstanceOf(WorkflowAlreadyStartedError); + expect((result.cause as RuntimeClientError).operation).toBe("startWorkflow"); } }); @@ -1512,14 +1527,11 @@ describe("TypedClient — interceptors", () => { mockWorkflow.execute .mockRejectedValueOnce(new Error("transient")) .mockResolvedValueOnce({ result: "ok" }); - const retryOnce: ClientInterceptor = (_args, next) => - next().flatMapErr((matcher) => - matcher.with( - P._, - (error): ReturnType => - error instanceof RuntimeClientError ? next() : Err(error).toAsync(), - ), - ); + // A transient Temporal failure now rides the defect channel (a + // `RuntimeClientError` cause), so the retry re-enters via `recoverDefect` + // rather than a modeled-error matcher — a defect is the retry signal, and + // any genuinely-modeled `Err` still flows through untouched. + const retryOnce: ClientInterceptor = (_args, next) => next().recoverDefect(() => next()); const result = await clientWith([retryOnce]).executeWorkflow("testWorkflow", { workflowId: "wf-4", diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 5b5e0825..81e12742 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -209,12 +209,7 @@ export type TypedWorkflowHandle = { [K in keyof ClientInferWorkflowQueries]: ClientInferWorkflowQueries[K] extends ( ...args: infer Args ) => AsyncResult - ? ( - ...args: Args - ) => AsyncResult< - R, - QueryValidationError | WorkflowExecutionNotFoundError | RuntimeClientError - > + ? (...args: Args) => AsyncResult : never; }; @@ -226,12 +221,7 @@ export type TypedWorkflowHandle = { [K in keyof ClientInferWorkflowSignals]: ClientInferWorkflowSignals[K] extends ( ...args: infer Args ) => AsyncResult - ? ( - ...args: Args - ) => AsyncResult< - void, - SignalValidationError | WorkflowExecutionNotFoundError | RuntimeClientError - > + ? (...args: Args) => AsyncResult : never; }; @@ -243,12 +233,7 @@ export type TypedWorkflowHandle = { [K in keyof ClientInferWorkflowUpdates]: ClientInferWorkflowUpdates[K] extends ( ...args: infer Args ) => AsyncResult - ? ( - ...args: Args - ) => AsyncResult< - R, - UpdateValidationError | WorkflowExecutionNotFoundError | RuntimeClientError - > + ? (...args: Args) => AsyncResult : never; }; @@ -264,27 +249,24 @@ export type TypedWorkflowHandle = { | WorkflowValidationError | WorkflowFailedError | WorkflowExecutionNotFoundError - | RuntimeClientError >; /** * Terminate workflow with Result pattern */ - terminate: ( - reason?: string, - ) => AsyncResult; + terminate: (reason?: string) => AsyncResult; /** * Cancel workflow with Result pattern */ - cancel: () => AsyncResult; + cancel: () => AsyncResult; /** * Get workflow execution description including status and metadata */ describe: () => AsyncResult< Awaited>, - WorkflowExecutionNotFoundError | RuntimeClientError + WorkflowExecutionNotFoundError >; /** @@ -292,7 +274,7 @@ export type TypedWorkflowHandle = { */ fetchHistory: () => AsyncResult< Awaited>, - WorkflowExecutionNotFoundError | RuntimeClientError + WorkflowExecutionNotFoundError >; }; @@ -338,7 +320,7 @@ async function resolveDefinitionAndValidateInput< ): Promise< Result< ResolvedWorkflow, - WorkflowNotFoundError | WorkflowValidationError | RuntimeClientError + WorkflowNotFoundError | WorkflowValidationError > > { const definition = contract.workflows[workflowName]; @@ -351,16 +333,10 @@ async function resolveDefinitionAndValidateInput< return Err(createWorkflowValidationError(workflowName, "input", inputResult.issues)); } - const searchAttributesResult = toTypedSearchAttributes( - definition, - 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; + // `toTypedSearchAttributes` throws a `RuntimeClientError` on an undeclared + // key — a technical misconfiguration routed to the defect channel by the + // enclosing `makeAsyncResult` work thunk (never a modeled Err). + const typedSearchAttributes = toTypedSearchAttributes(definition, workflowName, searchAttributes); return Ok({ definition: definition as TContract["workflows"][TWorkflowName], @@ -415,7 +391,12 @@ export class TypedClient { * * await result.match({ * ok: async (handle) => { await handle.pause("maintenance"); }, - * err: (matcher) => matcher.with(P._, (error) => console.error("schedule create failed", error)), + * errCases: (matcher) => + * matcher.with( + * tag("@temporal-contract/WorkflowNotFoundError"), + * tag("@temporal-contract/WorkflowValidationError"), + * (error) => console.error("schedule create failed", error), + * ), * defect: (cause) => console.error("unexpected failure", cause), * }); * ``` @@ -444,10 +425,10 @@ export class TypedClient { /** * Create a typed Temporal client with unthrown pattern from a contract. * - * Returns `AsyncResult` — errors-as-values - * from the very first call, matching the org-wide `Typed*.create()` - * factory shape (amqp-contract's `TypedAmqpClient.create`). Modeled - * failures on the `Err` channel: + * Returns `AsyncResult` — setup faults are *technical* + * infrastructure failures, not anticipated domain errors, so they surface on + * the `Defect` channel (a {@link TechnicalError} instance as the defect's + * cause), never the modeled `Err` channel. Technical failures routed there: * * - the underlying `Client` lacks the Schedule API * (`@temporalio/client` < 1.16); @@ -463,8 +444,8 @@ export class TypedClient { * contract: myContract, * client: temporalClient, * }); - * if (clientResult.isErr()) { - * console.error('client setup failed', clientResult.error); + * if (clientResult.isDefect()) { + * console.error('client setup failed', clientResult.cause); * return; * } * const client = clientResult.value; @@ -479,17 +460,17 @@ export class TypedClient { contract, client, interceptors, - }: CreateTypedClientOptions): AsyncResult, TechnicalError> { - const work = async (): Promise, TechnicalError>> => { + }: CreateTypedClientOptions): AsyncResult, never> { + const work = async (): Promise, never>> => { let instance: TypedClient; try { instance = new TypedClient(contract, client, interceptors ?? []); } catch (error) { - return Err( - new TechnicalError( - error instanceof Error ? error.message : "Failed to create TypedClient", - error, - ), + // Technical setup fault — throw a `TechnicalError`; `makeAsyncResult`'s + // throw→defect net routes it to the defect channel (never a modeled Err). + throw new TechnicalError( + error instanceof Error ? error.message : "Failed to create TypedClient", + error, ); } @@ -503,7 +484,8 @@ export class TypedClient { try { await connection.ensureConnected(); } catch (error) { - return Err(new TechnicalError("Failed to connect to Temporal server", error)); + // Technical connection fault — route to the defect channel too. + throw new TechnicalError("Failed to connect to Temporal server", error); } } @@ -546,7 +528,13 @@ export class TypedClient { * const result = await handle.result(); * // ... handle result * }, - * err: (matcher) => matcher.with(P._, (error) => console.error('Failed to start:', error)), + * errCases: (matcher) => + * matcher.with( + * tag('@temporal-contract/WorkflowNotFoundError'), + * tag('@temporal-contract/WorkflowValidationError'), + * tag('@temporal-contract/WorkflowAlreadyStartedError'), + * (error) => console.error('Failed to start:', error), + * ), * defect: (cause) => console.error('Unexpected failure:', cause), * }); * ``` @@ -560,17 +548,10 @@ export class TypedClient { }: TypedWorkflowStartOptions, ): AsyncResult< TypedWorkflowHandle, - | WorkflowNotFoundError - | WorkflowValidationError - | WorkflowAlreadyStartedError - | RuntimeClientError + WorkflowNotFoundError | WorkflowValidationError | WorkflowAlreadyStartedError > { type Ok = TypedWorkflowHandle; - type Err = - | WorkflowNotFoundError - | WorkflowValidationError - | WorkflowAlreadyStartedError - | RuntimeClientError; + type Err = WorkflowNotFoundError | WorkflowValidationError | WorkflowAlreadyStartedError; const runPipeline = (currentInput: unknown): AsyncResult => { const work = async (): Promise> => { const resolved = await resolveDefinitionAndValidateInput( @@ -593,7 +574,10 @@ export class TypedClient { }); return Ok(this.createTypedHandle(handle, workflowName, definition) as Ok); } catch (error) { - return Err(classifyStartError("startWorkflow", error)); + const alreadyStarted = classifyStartError(error); + if (alreadyStarted) return Err(alreadyStarted); + // Unrecognized, technical failure — route to the defect channel. + throw new RuntimeClientError("startWorkflow", error); } }; return makeAsyncResult(work); @@ -637,7 +621,14 @@ export class TypedClient { * * await result.match({ * ok: (handle) => console.log('signaled run', handle.signaledRunId), - * err: (matcher) => matcher.with(P._, (error) => console.error('signalWithStart failed', error)), + * errCases: (matcher) => + * matcher.with( + * tag('@temporal-contract/WorkflowNotFoundError'), + * tag('@temporal-contract/WorkflowValidationError'), + * tag('@temporal-contract/SignalValidationError'), + * tag('@temporal-contract/WorkflowAlreadyStartedError'), + * (error) => console.error('signalWithStart failed', error), + * ), * defect: (cause) => console.error('unexpected failure', cause), * }); * ``` @@ -660,15 +651,13 @@ export class TypedClient { | WorkflowValidationError | SignalValidationError | WorkflowAlreadyStartedError - | RuntimeClientError > { type Ok = TypedWorkflowHandleWithSignaledRunId; type Err = | WorkflowNotFoundError | WorkflowValidationError | SignalValidationError - | WorkflowAlreadyStartedError - | RuntimeClientError; + | WorkflowAlreadyStartedError; const runPipeline = ( currentInput: unknown, @@ -722,7 +711,10 @@ export class TypedClient { ) as TypedWorkflowHandle; return Ok({ ...typed, signaledRunId: handle.signaledRunId } as Ok); } catch (error) { - return Err(classifyStartError("signalWithStart", error)); + const alreadyStarted = classifyStartError(error); + if (alreadyStarted) return Err(alreadyStarted); + // Unrecognized, technical failure — route to the defect channel. + throw new RuntimeClientError("signalWithStart", error); } }; return makeAsyncResult(work); @@ -761,7 +753,16 @@ export class TypedClient { * * await result.match({ * ok: (output) => console.log('Order processed:', output.status), - * err: (matcher) => matcher.with(P._, (error) => console.error('Processing failed:', error)), + * errCases: (matcher) => + * matcher.with( + * tag('@temporal-contract/ContractError'), + * tag('@temporal-contract/WorkflowNotFoundError'), + * tag('@temporal-contract/WorkflowValidationError'), + * tag('@temporal-contract/WorkflowAlreadyStartedError'), + * tag('@temporal-contract/WorkflowFailedError'), + * tag('@temporal-contract/WorkflowExecutionNotFoundError'), + * (error) => console.error('Processing failed:', error), + * ), * defect: (cause) => console.error('Unexpected failure:', cause), * }); * ``` @@ -781,7 +782,6 @@ export class TypedClient { | WorkflowAlreadyStartedError | WorkflowFailedError | WorkflowExecutionNotFoundError - | RuntimeClientError > { type Ok = ClientInferOutput; type Err = @@ -790,8 +790,7 @@ export class TypedClient { | WorkflowValidationError | WorkflowAlreadyStartedError | WorkflowFailedError - | WorkflowExecutionNotFoundError - | RuntimeClientError; + | WorkflowExecutionNotFoundError; const runPipeline = (currentInput: unknown): AsyncResult => { const work = async (): Promise> => { const resolved = await resolveDefinitionAndValidateInput( @@ -864,7 +863,8 @@ export class TypedClient { ), ); } - return Err(createRuntimeClientError("executeWorkflow", error)); + // Unrecognized, technical failure — route to the defect channel. + throw new RuntimeClientError("executeWorkflow", error); } }; return makeAsyncResult(work); @@ -894,7 +894,11 @@ export class TypedClient { * const result = await handle.result(); * // ... handle result * }, - * err: (matcher) => matcher.with(P._, (error) => console.error('Failed to get handle:', error)), + * errCases: (matcher) => + * matcher.with( + * tag('@temporal-contract/WorkflowNotFoundError'), + * (error) => console.error('Failed to get handle:', error), + * ), * defect: (cause) => console.error('Unexpected failure:', cause), * }); * ``` @@ -904,10 +908,10 @@ export class TypedClient { workflowId: string, ): AsyncResult< TypedWorkflowHandle, - WorkflowNotFoundError | RuntimeClientError + WorkflowNotFoundError > { type Ok = TypedWorkflowHandle; - type Err = WorkflowNotFoundError | RuntimeClientError; + type Err = WorkflowNotFoundError; const work = async (): Promise> => { const definition = this.contract.workflows[workflowName]; if (!definition) { @@ -918,7 +922,8 @@ export class TypedClient { const handle = this.client.workflow.getHandle(workflowId); return Ok(this.createTypedHandle(handle, workflowName, definition) as Ok); } catch (error) { - return Err(createRuntimeClientError("getHandle", error)); + // Unrecognized, technical failure — route to the defect channel. + throw new RuntimeClientError("getHandle", error); } }; return makeAsyncResult(work); @@ -978,15 +983,13 @@ export class TypedClient { | WorkflowValidationError | WorkflowFailedError | WorkflowExecutionNotFoundError - | RuntimeClientError > => { type Ok = ClientInferOutput; type Err = | WorkflowContractErrorsOf | WorkflowValidationError | WorkflowFailedError - | WorkflowExecutionNotFoundError - | RuntimeClientError; + | WorkflowExecutionNotFoundError; const work = async (): Promise> => { try { const result = await workflowHandle.result(); @@ -1011,43 +1014,52 @@ export class TypedClient { return Err(rehydrated as Err); } } - return Err(classifyResultError("result", error, workflowHandle.workflowId)); + const classified = classifyResultError(error, workflowHandle.workflowId); + if (classified) return Err(classified); + // Unrecognized, technical failure — route to the defect channel. + throw new RuntimeClientError("result", error); } }; return makeAsyncResult(work); }, - terminate: ( - reason?: string, - ): AsyncResult => - fromPromise(workflowHandle.terminate(reason), (error) => - classifyHandleError("terminate", error, workflowHandle.workflowId), + terminate: (reason?: string): AsyncResult => + fromPromise( + workflowHandle.terminate(reason), + (error, defect) => + classifyHandleError(error, workflowHandle.workflowId) ?? + defect(new RuntimeClientError("terminate", error)), ).map(() => undefined), - cancel: (): AsyncResult => - fromPromise(workflowHandle.cancel(), (error) => - classifyHandleError("cancel", error, workflowHandle.workflowId), + cancel: (): AsyncResult => + fromPromise( + workflowHandle.cancel(), + (error, defect) => + classifyHandleError(error, workflowHandle.workflowId) ?? + defect(new RuntimeClientError("cancel", error)), ).map(() => undefined), describe: (): AsyncResult< Awaited>, - WorkflowExecutionNotFoundError | RuntimeClientError + WorkflowExecutionNotFoundError > => - fromPromise(workflowHandle.describe(), (error) => - classifyHandleError("describe", error, workflowHandle.workflowId), + fromPromise( + workflowHandle.describe(), + (error, defect) => + classifyHandleError(error, workflowHandle.workflowId) ?? + defect(new RuntimeClientError("describe", error)), ), fetchHistory: (): AsyncResult< Awaited>, - WorkflowExecutionNotFoundError | RuntimeClientError + WorkflowExecutionNotFoundError > => - fromPromise(workflowHandle.fetchHistory(), (error) => - classifyHandleError("fetchHistory", error, workflowHandle.workflowId), + fromPromise( + workflowHandle.fetchHistory(), + (error, defect) => + classifyHandleError(error, workflowHandle.workflowId) ?? + defect(new RuntimeClientError("fetchHistory", error)), ), }; } } -function createRuntimeClientError(operation: string, error: unknown): RuntimeClientError { - return new RuntimeClientError(operation, error); -} - function createWorkflowNotFoundError( workflowName: string | number | symbol, contract: ContractDefinition, @@ -1110,11 +1122,9 @@ function buildValidatedProxy): Record< string, - ( - args: unknown, - ) => AsyncResult + (args: unknown) => AsyncResult > { - type ProxyError = TValidationError | WorkflowExecutionNotFoundError | RuntimeClientError; + type ProxyError = TValidationError | WorkflowExecutionNotFoundError; const proxy: Record AsyncResult> = {}; if (!defs) return proxy; @@ -1138,7 +1148,10 @@ function buildValidatedProxy - * next().flatMapErr((matcher) => - * matcher.with(P._, (error) => - * error instanceof RuntimeClientError ? next() : Err(error).toAsync(), - * ), - * ); + * next().recoverDefect((cause) => { + * if (cause instanceof RuntimeClientError) return next(); + * throw cause; // not a transient fault we own — keep it a defect + * }); * ``` + * + * Modeled domain errors (`WorkflowNotFoundError`, a `ContractError`, …) stay on + * the `Err` channel — branch on those with `flatMapErrCases` / `match` as usual; + * `recoverDefect` / `tapDefect` are for the technical faults on the defect channel. */ export type ClientInterceptor = ( args: ClientInterceptorArgs, diff --git a/packages/client/src/internal.ts b/packages/client/src/internal.ts index ff12c957..bac6ee6a 100644 --- a/packages/client/src/internal.ts +++ b/packages/client/src/internal.ts @@ -20,7 +20,7 @@ import { TypedSearchAttributes, WorkflowNotFoundError as TemporalWorkflowNotFoundError, } from "@temporalio/common"; -import { Ok, Err, type AsyncResult, type Result } from "unthrown"; +import { 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 @@ -40,21 +40,23 @@ import { * Temporal client honours indexing when starting the workflow. * * Workflows without a `searchAttributes` block (or callers passing no - * values) resolve to `Ok(undefined)`, matching the Temporal SDK's + * values) resolve to `undefined`, matching the Temporal SDK's * "absent ≠ empty" semantics. * - * Returns `Err(RuntimeClientError)` on unknown keys. The TypeScript - * surface already gates the happy path; the runtime check catches typed - * escape hatches (`as never`, `as any`, raw-call interop) where a typo - * would otherwise silently drop the attribute, leaving the workflow - * unindexed without any signal to the caller. + * **Throws** a {@link RuntimeClientError} on unknown keys — a *technical* + * misconfiguration, not a modeled domain error, so it rides the defect + * channel (this helper always runs inside a `makeAsyncResult` work thunk, + * whose throw→defect net captures it). The TypeScript surface already gates + * the happy path; the runtime check catches typed escape hatches (`as never`, + * `as any`, raw-call interop) where a typo would otherwise silently drop the + * attribute, leaving the workflow unindexed without any signal to the caller. */ export function toTypedSearchAttributes( workflowDef: AnyWorkflowDefinition, workflowName: string, values: Record | undefined, -): Result { - if (!values) return Ok(undefined); +): TypedSearchAttributes | undefined { + if (!values) return undefined; // Workflows that omit the `searchAttributes` block declare none. Treat // that as an empty declared map so a caller passing values still hits // the per-key "undeclared" check below — silently dropping them would @@ -68,20 +70,18 @@ export function toTypedSearchAttributes( if (value === undefined) continue; const def = declared[name]; if (!def) { - return Err( - new RuntimeClientError( - "searchAttributes", - new Error( - `Search attribute "${name}" is not declared on workflow "${workflowName}". ` + - `Declared attributes: ${Object.keys(declared).join(", ") || "none"}.`, - ), + throw new RuntimeClientError( + "searchAttributes", + new Error( + `Search attribute "${name}" is not declared on workflow "${workflowName}". ` + + `Declared attributes: ${Object.keys(declared).join(", ") || "none"}.`, ), ); } const key = defineSearchAttributeKey(name, def.kind); pairs.push({ key, value } as SearchAttributePair); } - return Ok(pairs.length > 0 ? new TypedSearchAttributes(pairs) : undefined); + return pairs.length > 0 ? new TypedSearchAttributes(pairs) : undefined; } /** @@ -120,37 +120,35 @@ export async function rehydrateWorkflowContractError( } /** - * Map a thrown error from `client.workflow.start` / `signalWithStart` into - * the discriminated union surfaced by the typed client. Specifically - * recognizes Temporal's `WorkflowExecutionAlreadyStartedError`; everything - * else falls through to {@link RuntimeClientError}. + * Recognize a thrown error from `client.workflow.start` / `signalWithStart` + * as the modeled {@link WorkflowAlreadyStartedError} (Temporal's + * `WorkflowExecutionAlreadyStartedError`). Returns `undefined` for anything + * else — an unrecognized, *technical* failure the caller routes to the defect + * channel with a {@link RuntimeClientError} cause. */ -export function classifyStartError( - operation: string, - error: unknown, -): WorkflowAlreadyStartedError | RuntimeClientError { +export function classifyStartError(error: unknown): WorkflowAlreadyStartedError | undefined { if (error instanceof WorkflowExecutionAlreadyStartedError) { return new WorkflowAlreadyStartedError(error.workflowType, error.workflowId, error); } - return new RuntimeClientError(operation, error); + return undefined; } /** - * Map a thrown error from a workflow handle method (signal, query, - * executeUpdate, terminate, cancel, describe, fetchHistory) into the - * discriminated union surfaced by the typed client. Recognizes Temporal's - * `WorkflowNotFoundError`; everything else falls through to - * {@link RuntimeClientError}. + * Recognize a thrown error from a workflow handle method (signal, query, + * executeUpdate, terminate, cancel, describe, fetchHistory) as the modeled + * {@link WorkflowExecutionNotFoundError} (Temporal's `WorkflowNotFoundError`). + * Returns `undefined` for anything else — an unrecognized, *technical* failure + * the caller routes to the defect channel with a {@link RuntimeClientError} + * cause. * * `fallbackWorkflowId` is used when Temporal's error carries an empty * `workflowId` (it normalizes missing IDs to the empty string), so the * surfaced error always identifies the targeted execution. */ export function classifyHandleError( - operation: string, error: unknown, fallbackWorkflowId: string, -): WorkflowExecutionNotFoundError | RuntimeClientError { +): WorkflowExecutionNotFoundError | undefined { if (error instanceof TemporalWorkflowNotFoundError) { return new WorkflowExecutionNotFoundError( error.workflowId || fallbackWorkflowId, @@ -158,14 +156,16 @@ export function classifyHandleError( error, ); } - return new RuntimeClientError(operation, error); + return undefined; } /** - * Map a thrown error from `handle.result()` / `client.workflow.execute()` - * (the latter when waiting on the result phase). Recognizes Temporal's - * `WorkflowFailedError` and `WorkflowNotFoundError`; everything else falls - * through to {@link RuntimeClientError}. + * Recognize a thrown error from `handle.result()` / `client.workflow.execute()` + * (the latter when waiting on the result phase) as one of the modeled + * {@link WorkflowFailedError} / {@link WorkflowExecutionNotFoundError} + * (Temporal's `WorkflowFailedError` / `WorkflowNotFoundError`). Returns + * `undefined` for anything else — an unrecognized, *technical* failure the + * caller routes to the defect channel with a {@link RuntimeClientError} cause. * * Temporal's `WorkflowFailedError` is itself a wrapper — the actionable * failure (ApplicationFailure, CancelledFailure, TerminatedFailure, etc.) @@ -175,10 +175,9 @@ export function classifyHandleError( * `cause` is too — same shape as before.) */ export function classifyResultError( - operation: string, error: unknown, workflowId: string, -): WorkflowFailedError | WorkflowExecutionNotFoundError | RuntimeClientError { +): WorkflowFailedError | WorkflowExecutionNotFoundError | undefined { if (error instanceof TemporalWorkflowFailedError) { // Temporal types `cause` as `Error | undefined`, but the SDK only ever // populates it with a `TemporalFailure` subclass when surfacing a @@ -189,5 +188,5 @@ export function classifyResultError( if (error instanceof TemporalWorkflowNotFoundError) { return new WorkflowExecutionNotFoundError(error.workflowId || workflowId, error.runId, error); } - return new RuntimeClientError(operation, error); + return undefined; } diff --git a/packages/client/src/schedule.spec.ts b/packages/client/src/schedule.spec.ts index 2dd59bc3..0d5cf3db 100644 --- a/packages/client/src/schedule.spec.ts +++ b/packages/client/src/schedule.spec.ts @@ -136,7 +136,7 @@ describe("TypedClient.schedule", () => { expect(mockSchedule.create).not.toHaveBeenCalled(); }); - it("returns RuntimeClientError when Temporal's create rejects", async () => { + it("surfaces a Defect(RuntimeClientError) when Temporal's create rejects", async () => { mockSchedule.create.mockRejectedValue(new Error("temporal down")); const result = await client.schedule.create("processOrder", { @@ -145,10 +145,10 @@ describe("TypedClient.schedule", () => { args: { orderId: "sweep" }, }); - expect(result).toBeErr(); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(RuntimeClientError); - expect((result.error as RuntimeClientError).operation).toBe("schedule.create"); + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + expect((result.cause as RuntimeClientError).operation).toBe("schedule.create"); } }); @@ -280,7 +280,7 @@ describe("TypedClient.schedule", () => { expect(Object.hasOwn(passed.action, "typedSearchAttributes")).toBe(false); }); - it("rejects undeclared attribute keys with a RuntimeClientError", async () => { + it("rejects undeclared attribute keys with a Defect(RuntimeClientError)", async () => { const result = await searchClient.schedule.create("processOrder", { scheduleId: "search-sweep", spec: { cronExpressions: ["0 2 * * *"] }, @@ -292,11 +292,11 @@ describe("TypedClient.schedule", () => { }, }); - expect(result).toBeErr(); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(RuntimeClientError); - expect((result.error as RuntimeClientError).operation).toBe("searchAttributes"); - expect((result.error as RuntimeClientError).message).toContain("unknownAttr"); + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + expect((result.cause as RuntimeClientError).operation).toBe("searchAttributes"); + expect((result.cause as RuntimeClientError).message).toContain("unknownAttr"); } expect(mockSchedule.create).not.toHaveBeenCalled(); }); @@ -323,17 +323,17 @@ describe("TypedClient.schedule", () => { expect(tempHandle.delete).toHaveBeenCalled(); }); - it("wraps Temporal errors as RuntimeClientError tagged by the failing operation", async () => { + it("wraps Temporal errors as a Defect(RuntimeClientError) tagged by the failing operation", async () => { const tempHandle = createMockHandle(); tempHandle.pause.mockRejectedValue(new Error("not found")); mockSchedule.getHandle.mockReturnValue(tempHandle); const handle = client.schedule.getHandle("missing"); const result = await handle.pause(); - expect(result).toBeErr(); - if (result.isErr()) { - expect(result.error).toBeInstanceOf(RuntimeClientError); - expect((result.error as RuntimeClientError).operation).toBe("schedule.pause"); + expect(result).toBeDefect(); + if (result.isDefect()) { + expect(result.cause).toBeInstanceOf(RuntimeClientError); + expect((result.cause as RuntimeClientError).operation).toBe("schedule.pause"); } }); diff --git a/packages/client/src/schedule.ts b/packages/client/src/schedule.ts index 90cf83b2..5d947811 100644 --- a/packages/client/src/schedule.ts +++ b/packages/client/src/schedule.ts @@ -12,7 +12,7 @@ import { type AsyncResult, type Result, Ok, Err, fromPromise } from "unthrown"; import type { TypedSearchAttributeMap } from "./client.js"; import { RuntimeClientError, WorkflowNotFoundError, WorkflowValidationError } from "./errors.js"; -import { assertNoDefect, makeAsyncResult, toTypedSearchAttributes } from "./internal.js"; +import { makeAsyncResult, toTypedSearchAttributes } from "./internal.js"; import type { ClientInferInput } from "./types.js"; /** @@ -90,16 +90,23 @@ export type TypedScheduleCreateOptions< export type TypedScheduleHandle = { /** This schedule's identifier. */ readonly scheduleId: string; - /** Pause the schedule. Optional note becomes part of the audit trail. */ - pause: (note?: string) => AsyncResult; + /** + * Pause the schedule. Optional note becomes part of the audit trail. + * + * Returns `AsyncResult` — a failed schedule operation is a + * *technical* fault (an unknown schedule ID, a transport error, …), routed + * to the `Defect` channel with a {@link RuntimeClientError} cause rather + * than surfaced as a modeled `Err`. + */ + pause: (note?: string) => AsyncResult; /** Resume a paused schedule. */ - unpause: (note?: string) => AsyncResult; + unpause: (note?: string) => AsyncResult; /** Fire the schedule's action immediately. */ - trigger: (overlap?: ScheduleOverlapPolicy) => AsyncResult; + trigger: (overlap?: ScheduleOverlapPolicy) => AsyncResult; /** Delete the schedule. */ - delete: () => AsyncResult; + delete: () => AsyncResult; /** Fetch the schedule's current description from the server. */ - describe: () => AsyncResult; + describe: () => AsyncResult; }; /** @@ -125,12 +132,9 @@ export class TypedScheduleClient { create( workflowName: TWorkflowName, options: TypedScheduleCreateOptions, - ): AsyncResult< - TypedScheduleHandle, - WorkflowNotFoundError | WorkflowValidationError | RuntimeClientError - > { + ): AsyncResult { type Ok = TypedScheduleHandle; - type Err = WorkflowNotFoundError | WorkflowValidationError | RuntimeClientError; + type Err = WorkflowNotFoundError | WorkflowValidationError; const work = async (): Promise> => { const definition = this.contract.workflows[workflowName]; if (!definition) { @@ -147,16 +151,14 @@ export class TypedScheduleClient { // indexing), not on the schedule itself. Mirrors what // `client.startWorkflow` does for direct starts so schedule-spawned // runs share visibility with their direct-start counterparts. - const searchAttributesResult = toTypedSearchAttributes( + // `toTypedSearchAttributes` throws a `RuntimeClientError` on an + // undeclared key — a technical misconfiguration routed to the defect + // channel by the enclosing `makeAsyncResult` work thunk. + const typedSearchAttributes = toTypedSearchAttributes( definition, 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; try { const overrides = options.action ?? {}; @@ -196,7 +198,8 @@ export class TypedScheduleClient { }); return Ok(wrapScheduleHandle(handle)); } catch (error) { - return Err(new RuntimeClientError("schedule.create", error)); + // Technical failure creating the schedule — route to the defect channel. + throw new RuntimeClientError("schedule.create", error); } }; return makeAsyncResult(work); @@ -216,25 +219,24 @@ function wrapScheduleHandle(handle: ScheduleHandle): TypedScheduleHandle { return { scheduleId: handle.scheduleId, pause: (note) => - fromPromise( - handle.pause(note), - (error) => new RuntimeClientError("schedule.pause", error), + fromPromise(handle.pause(note), (error, defect) => + defect(new RuntimeClientError("schedule.pause", error)), ).map(() => undefined), unpause: (note) => - fromPromise( - handle.unpause(note), - (error) => new RuntimeClientError("schedule.unpause", error), + fromPromise(handle.unpause(note), (error, defect) => + defect(new RuntimeClientError("schedule.unpause", error)), ).map(() => undefined), trigger: (overlap) => - fromPromise( - handle.trigger(overlap), - (error) => new RuntimeClientError("schedule.trigger", error), + fromPromise(handle.trigger(overlap), (error, defect) => + defect(new RuntimeClientError("schedule.trigger", error)), ).map(() => undefined), delete: () => - fromPromise(handle.delete(), (error) => new RuntimeClientError("schedule.delete", error)).map( - () => undefined, - ), + fromPromise(handle.delete(), (error, defect) => + defect(new RuntimeClientError("schedule.delete", error)), + ).map(() => undefined), describe: () => - fromPromise(handle.describe(), (error) => new RuntimeClientError("schedule.describe", error)), + fromPromise(handle.describe(), (error, defect) => + defect(new RuntimeClientError("schedule.describe", error)), + ), }; } diff --git a/packages/contract/package.json b/packages/contract/package.json index 2fe0b3f6..cac05144 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -78,6 +78,7 @@ "@unthrown/vitest": "catalog:", "@vitest/coverage-v8": "catalog:", "arktype": "catalog:", + "ts-pattern": "catalog:", "tsdown": "catalog:", "typedoc": "catalog:", "typedoc-plugin-markdown": "catalog:", @@ -87,9 +88,13 @@ "vitest": "catalog:" }, "peerDependencies": { - "unthrown": "^5.0.0-beta.3" + "ts-pattern": "^5", + "unthrown": "^5.0.0-beta.5" }, "peerDependenciesMeta": { + "ts-pattern": { + "optional": true + }, "unthrown": { "optional": true } diff --git a/packages/contract/src/errors.ts b/packages/contract/src/errors.ts index a8e11b5c..244cf885 100644 --- a/packages/contract/src/errors.ts +++ b/packages/contract/src/errors.ts @@ -25,12 +25,15 @@ import type { AnySchema, ErrorDefinition, InferErrorData, InferErrorDataInput } /** * Error for technical/runtime failures that cannot be prevented by * TypeScript — connection failures, missing runtime capabilities, worker - * bundling errors. Surfaced on the `Err` channel of the creation factories - * (`TypedClient.create`, `createWorker`), never thrown: it is a *modeled* - * error (it lives in the `E` channel of a `Result`), not a `Defect`. + * bundling errors. These are *unmodeled* infrastructure faults, never + * anticipated domain failures, so they ride the `Defect` channel: the + * creation factories (`TypedClient.create`, `createWorker`) surface them as a + * `Defect` whose `cause` is a `TechnicalError` instance (inspect via `match`'s + * `defect` handler, `recoverDefect`, or `tapDefect`) — this class never + * appears in a `Result`'s modeled `E` channel. * - * Mirrors amqp-contract's `TechnicalError` — the org-wide shape for - * `Typed*.create()` factories returning `AsyncResult<_, TechnicalError>`. + * The class is retained (and still exported) so the descriptive message and + * `cause` survive for logging; it is only ever used as a defect's `cause`. */ export class TechnicalError extends TaggedError("@temporal-contract/TechnicalError", { name: "TechnicalError", @@ -61,7 +64,7 @@ export class TechnicalError extends TaggedError("@temporal-contract/TechnicalErr * * The unthrown `_tag` ("@temporal-contract/ContractError") discriminates a * `ContractError` from the other tagged errors in a Result's error channel - * (e.g. via `result.match({ err: (m) => m.with(tag("@temporal-contract/ContractError"), …) })`); + * (e.g. via `result.match({ errCases: (m) => m.with(tag("@temporal-contract/ContractError"), …) })`); * `errorName` then narrows to the concrete declared error. */ export class ContractError extends TaggedError( diff --git a/packages/worker/README.md b/packages/worker/README.md index 3c2833ce..51438972 100644 --- a/packages/worker/README.md +++ b/packages/worker/README.md @@ -82,10 +82,17 @@ export const parentWorkflow = declareWorkflow({ args: { amount: input.totalAmount }, }); - childResult.match( - (output) => console.log("Payment processed:", output), - (error) => console.error("Payment failed:", error), - ); + childResult.match({ + ok: (output) => console.log("Payment processed:", output), + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ChildWorkflowError"), + tag("@temporal-contract/ChildWorkflowCancelledError"), + tag("@temporal-contract/ChildWorkflowNotFoundError"), + (error) => console.error("Payment failed:", error), + ), + defect: (cause) => console.error("Unexpected failure:", cause), + }); // Execute child workflow from another contract (another worker) const notificationResult = await context.executeChildWorkflow( @@ -103,14 +110,21 @@ export const parentWorkflow = declareWorkflow({ args: { to: "user@example.com", body: "Order received" }, }); - handleResult.match( - async (handle) => { + handleResult.match({ + ok: async (handle) => { // Can wait for result later const result = await handle.result(); // ... }, - (error) => console.error("Failed to start:", error), - ); + errCases: (matcher) => + matcher.with( + tag("@temporal-contract/ChildWorkflowError"), + tag("@temporal-contract/ChildWorkflowCancelledError"), + tag("@temporal-contract/ChildWorkflowNotFoundError"), + (error) => console.error("Failed to start:", error), + ), + defect: (cause) => console.error("Unexpected failure:", cause), + }); return { success: true }; }, diff --git a/packages/worker/package.json b/packages/worker/package.json index 81bd1fc3..29a8259c 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -84,6 +84,7 @@ "@types/node": "catalog:", "@unthrown/vitest": "catalog:", "@vitest/coverage-v8": "catalog:", + "ts-pattern": "catalog:", "tsdown": "catalog:", "typedoc": "catalog:", "typedoc-plugin-markdown": "catalog:", @@ -96,7 +97,8 @@ "@temporalio/common": "^1", "@temporalio/worker": "^1", "@temporalio/workflow": "^1", - "unthrown": "^5.0.0-beta.3" + "ts-pattern": "^5", + "unthrown": "^5.0.0-beta.5" }, "engines": { "node": ">=22.19.0" diff --git a/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts b/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts index 1999eb07..66ae8d05 100644 --- a/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts +++ b/packages/worker/src/__tests__/time-skipping.inprocess.spec.ts @@ -25,7 +25,7 @@ import { declareActivitiesHandler, defineActivityMiddleware, } from "../activity.js"; -import { createWorker } from "../worker.js"; +import { createWorker, TechnicalError } from "../worker.js"; import { inprocessContract } from "./inprocess.contract.js"; const okAsync = (value: T): AsyncResult => Ok(value).toAsync(); @@ -138,17 +138,19 @@ describe("time-skipping TestWorkflowEnvironment", () => { ]); }); - it("surfaces worker bundling failures on the Err channel", async ({ testEnv }) => { + it("surfaces worker bundling failures on the defect channel", async ({ testEnv }) => { const workerResult = await createWorker({ contract: inprocessContract, connection: testEnv.nativeConnection, workflowsPath: workflowPath("does-not-exist"), activities, }); - expect(workerResult.isErr()).toBe(true); - if (workerResult.isErr()) { - expect(workerResult.error._tag).toBe("@temporal-contract/TechnicalError"); - expect(workerResult.error.message).toContain("inprocess-tests"); + expect(workerResult.isDefect()).toBe(true); + if (workerResult.isDefect()) { + const cause = workerResult.cause; + expect(cause).toBeInstanceOf(TechnicalError); + expect((cause as TechnicalError)._tag).toBe("@temporal-contract/TechnicalError"); + expect((cause as TechnicalError).message).toContain("inprocess-tests"); } }); }); diff --git a/packages/worker/src/activity-contract-errors.spec.ts b/packages/worker/src/activity-contract-errors.spec.ts index b77de491..557578f0 100644 --- a/packages/worker/src/activity-contract-errors.spec.ts +++ b/packages/worker/src/activity-contract-errors.spec.ts @@ -1,6 +1,6 @@ import { defineContract } from "@temporal-contract/contract"; import { ContractError } from "@temporal-contract/contract/errors"; -import { Ok, Err, P, type AsyncResult } from "unthrown"; +import { Ok, Err, P, tag, type AsyncResult } from "unthrown"; /** * Runtime coverage for `declareActivitiesHandler`'s contract-declared typed * errors, middleware chain, and dependency context — the boundary where an @@ -298,10 +298,14 @@ describe("declareActivitiesHandler — middleware", () => { it("observes typed errors on the err channel", async () => { const observed: unknown[] = []; const observing: ActivityMiddleware = (_invocation, next) => - next().tapErr((matcher) => - matcher.with(P._, (error) => { - observed.push(error); - }), + next().tapErrCases((matcher) => + matcher.with( + P.instanceOf(ApplicationFailure), + tag("@temporal-contract/ContractError"), + (error) => { + observed.push(error); + }, + ), ); const activities = declareActivitiesHandler({ diff --git a/packages/worker/src/activity.ts b/packages/worker/src/activity.ts index 845689cc..7bb6c9f2 100644 --- a/packages/worker/src/activity.ts +++ b/packages/worker/src/activity.ts @@ -29,7 +29,7 @@ import { type ContractErrorInputUnion, } from "@temporal-contract/contract/errors"; import { ApplicationFailure } from "@temporalio/common"; -import { P, type AsyncResult } from "unthrown"; +import { P, tag, type AsyncResult } from "unthrown"; import { contractErrorToApplicationFailure } from "./contract-errors.js"; import { @@ -294,10 +294,14 @@ export type ActivityMiddlewareNext< * @example Log every activity invocation and its outcome (read-only) * ```ts * const logging: ActivityMiddleware = ({ activityName, workflowName }, next) => - * next().tapErr((matcher) => - * matcher.with(P._, (error) => { - * logger.warn({ activityName, workflowName, error }, "activity failed"); - * }), + * next().tapErrCases((matcher) => + * matcher.with( + * P.instanceOf(ApplicationFailure), + * tag("@temporal-contract/ContractError"), + * (error) => { + * logger.warn({ activityName, workflowName, error }, "activity failed"); + * }, + * ), * ); * ``` * @@ -741,17 +745,21 @@ export function declareActivitiesHandler< // retry policy (honoring `nonRetryable: true`). Contract errors are // validated against their declared data schema and serialized as // ApplicationFailure(type = error name, details = [data]). - err: (matcher) => - matcher.with(P._, async (error) => { - if (error instanceof ContractError) { - throw await contractErrorToApplicationFailure( - error, - activityDef.errors, - `activity "${label}"`, - ); - } - throw error; - }), + errCases: (matcher) => + matcher.with( + P.instanceOf(ApplicationFailure), + tag("@temporal-contract/ContractError"), + async (error) => { + if (error instanceof ContractError) { + throw await contractErrorToApplicationFailure( + error, + activityDef.errors, + `activity "${label}"`, + ); + } + 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 diff --git a/packages/worker/src/cancellation.ts b/packages/worker/src/cancellation.ts index e2a8fdb0..18411e06 100644 --- a/packages/worker/src/cancellation.ts +++ b/packages/worker/src/cancellation.ts @@ -37,8 +37,8 @@ import { makeAsyncResult } from "./internal.js"; * * result.match({ * ok: (output) => { ... }, - * err: (matcher) => - * matcher.with(P._, (error) => { + * errCases: (matcher) => + * matcher.with(tag("@temporal-contract/WorkflowCancelledError"), (error) => { * // error instanceof WorkflowCancelledError — graceful exit * }), * defect: (cause) => { diff --git a/packages/worker/src/errors.ts b/packages/worker/src/errors.ts index 8df19f36..525dd776 100644 --- a/packages/worker/src/errors.ts +++ b/packages/worker/src/errors.ts @@ -346,7 +346,7 @@ export class ChildWorkflowError extends TaggedError("@temporal-contract/ChildWor * 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. A - * `result.match` with the ts-pattern `err` matcher folds the + * `result.match` with the ts-pattern `errCases` matcher folds the * `ChildWorkflowError | ChildWorkflowCancelledError` union exhaustively. */ export class ChildWorkflowCancelledError extends TaggedError( diff --git a/packages/worker/src/worker.spec.ts b/packages/worker/src/worker.spec.ts index c9c05cb2..d63db3ec 100644 --- a/packages/worker/src/worker.spec.ts +++ b/packages/worker/src/worker.spec.ts @@ -3,7 +3,12 @@ import { type NativeConnection, Worker } from "@temporalio/worker"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { z } from "zod"; -import { createWorker, createWorkerOrThrow, workflowsPathFromURL } from "./worker.js"; +import { + createWorker, + createWorkerOrThrow, + TechnicalError, + workflowsPathFromURL, +} from "./worker.js"; // Mock @temporalio/worker vi.mock("@temporalio/worker", () => ({ @@ -56,7 +61,7 @@ describe("Worker Entry Point", () => { expect(workerResult).toBeOkWith(mockWorker); }); - it("should surface Worker.create rejections as Err(TechnicalError)", async () => { + it("should surface Worker.create rejections as a Defect with a TechnicalError cause", async () => { // GIVEN const contract = { taskQueue: "test-queue", @@ -78,12 +83,15 @@ describe("Worker Entry Point", () => { activities: {}, }); - // THEN — modeled on the Err channel, not thrown - expect(workerResult).toBeErr(); - if (workerResult.isErr()) { - expect(workerResult.error._tag).toBe("@temporal-contract/TechnicalError"); - expect(workerResult.error.message).toContain('task queue "test-queue"'); - expect(workerResult.error.cause).toBe(bundleError); + // THEN — a technical fault rides the defect channel (a TechnicalError + // instance as the cause), not the modeled Err channel + expect(workerResult).toBeDefect(); + if (workerResult.isDefect()) { + const cause = workerResult.cause; + expect(cause).toBeInstanceOf(TechnicalError); + expect((cause as TechnicalError)._tag).toBe("@temporal-contract/TechnicalError"); + expect((cause as TechnicalError).message).toContain('task queue "test-queue"'); + expect((cause as TechnicalError).cause).toBe(bundleError); } }); diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts index ffdb8862..7566800a 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/worker.ts @@ -4,12 +4,13 @@ import { fileURLToPath } from "node:url"; import { type ContractDefinition } from "@temporal-contract/contract"; import { TechnicalError } from "@temporal-contract/contract/errors"; import { Worker, type WorkerOptions } from "@temporalio/worker"; -import { fromPromise, P, type AsyncResult } from "unthrown"; +import { fromPromise, type AsyncResult } from "unthrown"; import type { ActivitiesHandler } from "./activity.js"; -// Modeled creation failure — `createWorker` surfaces it on the Err channel -// instead of throwing. +// Technical creation failure — worker bundling / connection errors are +// unmodeled infrastructure defects, surfaced on the `Defect` channel with a +// {@link TechnicalError} instance as their cause (never in the `Err` channel). export { TechnicalError } from "@temporal-contract/contract/errors"; /** @@ -37,10 +38,11 @@ export type CreateWorkerOptions = Omit< * - Using the contract's task queue automatically * - Providing type-safe configuration * - * Returns `AsyncResult` — worker bundling and - * connection failures are modeled on the `Err` channel instead of thrown, - * matching the org-wide `Typed*.create()` factory shape (amqp-contract's - * `TypedAmqpWorker.create`). + * Returns `AsyncResult` — worker bundling and connection + * failures are *technical* infrastructure faults, not anticipated domain + * errors, so they surface on the `Defect` channel (a {@link TechnicalError} + * instance as the defect's cause) rather than the modeled `Err` channel. + * Inspect them via `match`'s `defect` handler or `recoverDefect` / `tapDefect`. * * @example * ```ts @@ -59,8 +61,8 @@ export type CreateWorkerOptions = Omit< * workflowsPath: workflowsPathFromURL(import.meta.url, './workflows.js'), * activities, * }); - * if (workerResult.isErr()) { - * console.error('worker setup failed', workerResult.error); + * if (workerResult.isDefect()) { + * console.error('worker setup failed', workerResult.cause); * process.exit(1); * } * @@ -69,22 +71,25 @@ export type CreateWorkerOptions = Omit< */ export function createWorker( options: CreateWorkerOptions, -): AsyncResult { +): AsyncResult { const { contract, activities, ...workerOptions } = options; // Create the worker with contract's task queue. `Worker.create` rejects on // workflow-bundle compilation errors, bad connections, and invalid - // options — all technical failures, modeled rather than thrown. + // options — all *technical* faults, routed to the defect channel with a + // `TechnicalError` cause (never a modeled `Err`). return fromPromise( Worker.create({ ...workerOptions, activities, taskQueue: contract.taskQueue, }), - (cause) => - new TechnicalError( - `Failed to create Temporal worker for task queue "${contract.taskQueue}"`, - cause, + (cause, defect) => + defect( + new TechnicalError( + `Failed to create Temporal worker for task queue "${contract.taskQueue}"`, + cause, + ), ), ); } @@ -94,23 +99,21 @@ export function createWorker( * pre-AsyncResult behavior. * * @deprecated Use {@link createWorker}, which returns - * `AsyncResult`. This throwing alias exists to ease - * migration and will be removed in a future major. + * `AsyncResult` (technical failures ride the defect channel). + * This throwing alias exists to ease migration and will be removed in a + * future major. */ export async function createWorkerOrThrow( options: CreateWorkerOptions, ): Promise { const result = await createWorker(options); - return result.match({ - ok: (worker) => worker, - err: (matcher) => - matcher.with(P._, (error) => { - throw error.cause ?? error; - }), - defect: (cause) => { - throw cause; - }, - }); + // A technical failure now rides the defect channel with a `TechnicalError` + // cause; unwrap it so this throwing alias keeps rethrowing the *original* + // cause (its pre-defect behavior), not the wrapper. + if (result.isDefect() && result.cause instanceof TechnicalError) { + throw result.cause.cause ?? result.cause; + } + return result.get(); } /** diff --git a/packages/worker/src/workflow.ts b/packages/worker/src/workflow.ts index 9ffee567..7d15ad24 100644 --- a/packages/worker/src/workflow.ts +++ b/packages/worker/src/workflow.ts @@ -627,7 +627,13 @@ type WorkflowContext< * const result = await handle.result(); * // ... handle result * }, - * err: (matcher) => matcher.with(P._, (error) => console.error('Failed to start:', error)), + * errCases: (matcher) => + * matcher.with( + * tag('@temporal-contract/ChildWorkflowError'), + * tag('@temporal-contract/ChildWorkflowCancelledError'), + * tag('@temporal-contract/ChildWorkflowNotFoundError'), + * (error) => console.error('Failed to start:', error), + * ), * defect: (cause) => console.error('Unexpected failure:', cause), * }); * ``` @@ -667,7 +673,13 @@ type WorkflowContext< * * await result.match({ * ok: (output) => console.log('Payment processed:', output), - * err: (matcher) => matcher.with(P._, (error) => console.error('Processing failed:', error)), + * errCases: (matcher) => + * matcher.with( + * tag('@temporal-contract/ChildWorkflowError'), + * tag('@temporal-contract/ChildWorkflowCancelledError'), + * tag('@temporal-contract/ChildWorkflowNotFoundError'), + * (error) => console.error('Processing failed:', error), + * ), * defect: (cause) => console.error('Unexpected failure:', cause), * }); * ``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22ad452f..fcf0af6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,8 +52,8 @@ catalogs: specifier: 26.1.1 version: 26.1.1 '@unthrown/vitest': - specifier: 5.0.0-beta.3 - version: 5.0.0-beta.3 + specifier: 5.0.0-beta.5 + version: 5.0.0-beta.5 '@vitest/coverage-v8': specifier: 4.1.10 version: 4.1.10 @@ -81,6 +81,9 @@ catalogs: testcontainers: specifier: 12.0.4 version: 12.0.4 + ts-pattern: + specifier: 5.9.0 + version: 5.9.0 tsdown: specifier: 0.22.7 version: 0.22.7 @@ -100,8 +103,8 @@ catalogs: specifier: 6.0.3 version: 6.0.3 unthrown: - specifier: 5.0.0-beta.3 - version: 5.0.0-beta.3 + specifier: 5.0.0-beta.5 + version: 5.0.0-beta.5 valibot: specifier: 1.4.2 version: 1.4.2 @@ -209,9 +212,12 @@ importers: pino-pretty: specifier: 'catalog:' version: 13.1.3 + ts-pattern: + specifier: 'catalog:' + version: 5.9.0 unthrown: specifier: 'catalog:' - version: 5.0.0-beta.3 + version: 5.0.0-beta.5(ts-pattern@5.9.0) zod: specifier: 'catalog:' version: 4.4.3 @@ -271,9 +277,12 @@ importers: pino-pretty: specifier: 'catalog:' version: 13.1.3 + ts-pattern: + specifier: 'catalog:' + version: 5.9.0 unthrown: specifier: 'catalog:' - version: 5.0.0-beta.3 + version: 5.0.0-beta.5(ts-pattern@5.9.0) zod: specifier: 'catalog:' version: 4.4.3 @@ -295,7 +304,7 @@ importers: version: 26.1.1 '@unthrown/vitest': specifier: 'catalog:' - version: 5.0.0-beta.3(unthrown@5.0.0-beta.3)(vitest@4.1.10) + version: 5.0.0-beta.5(unthrown@5.0.0-beta.5(ts-pattern@5.9.0))(vitest@4.1.10) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -344,10 +353,13 @@ importers: version: 26.1.1 '@unthrown/vitest': specifier: 'catalog:' - version: 5.0.0-beta.3(unthrown@5.0.0-beta.3)(vitest@4.1.10) + version: 5.0.0-beta.5(unthrown@5.0.0-beta.5(ts-pattern@5.9.0))(vitest@4.1.10) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) + ts-pattern: + specifier: 'catalog:' + version: 5.9.0 tsdown: specifier: 'catalog:' version: 0.22.7(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) @@ -362,7 +374,7 @@ importers: version: 6.0.3 unthrown: specifier: 'catalog:' - version: 5.0.0-beta.3 + version: 5.0.0-beta.5(ts-pattern@5.9.0) vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0) @@ -390,13 +402,16 @@ importers: version: 26.1.1 '@unthrown/vitest': specifier: 'catalog:' - version: 5.0.0-beta.3(unthrown@5.0.0-beta.3)(vitest@4.1.10) + version: 5.0.0-beta.5(unthrown@5.0.0-beta.5(ts-pattern@5.9.0))(vitest@4.1.10) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) arktype: specifier: 'catalog:' version: 2.2.3 + ts-pattern: + specifier: 'catalog:' + version: 5.9.0 tsdown: specifier: 'catalog:' version: 0.22.7(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) @@ -411,7 +426,7 @@ importers: version: 6.0.3 unthrown: specifier: 'catalog:' - version: 5.0.0-beta.3 + version: 5.0.0-beta.5(ts-pattern@5.9.0) valibot: specifier: 'catalog:' version: 1.4.2(typescript@6.0.3) @@ -500,10 +515,13 @@ importers: version: 26.1.1 '@unthrown/vitest': specifier: 'catalog:' - version: 5.0.0-beta.3(unthrown@5.0.0-beta.3)(vitest@4.1.10) + version: 5.0.0-beta.5(unthrown@5.0.0-beta.5(ts-pattern@5.9.0))(vitest@4.1.10) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) + ts-pattern: + specifier: 'catalog:' + version: 5.9.0 tsdown: specifier: 'catalog:' version: 0.22.7(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) @@ -518,7 +536,7 @@ importers: version: 6.0.3 unthrown: specifier: 'catalog:' - version: 5.0.0-beta.3 + version: 5.0.0-beta.5(ts-pattern@5.9.0) vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0) @@ -2514,11 +2532,11 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} - '@unthrown/vitest@5.0.0-beta.3': - resolution: {integrity: sha512-N1nWqXzAm/tmgmR+iwldx/IHS8BqJsWYpW2p9lefD4/dOOBxjWw2VJ5oZ9uyfFvQjVjdbCcE1GcpAcmK0CcA+g==} + '@unthrown/vitest@5.0.0-beta.5': + resolution: {integrity: sha512-MN9kChh+cAPcRIBVv+BaDA3olisvkHPCOHD4vnZMJyPJI4kHB+prWyJDifXZlSvWJwTPnoverF/LnvMvFVdvhg==} engines: {node: '>=20'} peerDependencies: - unthrown: ^5.0.0-beta.3 + unthrown: ^5.0.0-beta.5 vitest: ^4 '@upsetjs/venn.js@2.0.0': @@ -4792,9 +4810,11 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} - unthrown@5.0.0-beta.3: - resolution: {integrity: sha512-z6tOSiq9ymstsL5o6X6lhRfF+TXRn/CC3/MjaVDGVxdQ7aB5k8JX0G8QUNU4cRjwk7Drw5raUtKqHWFBoyiNVw==} + unthrown@5.0.0-beta.5: + resolution: {integrity: sha512-2s9/QdXkJTCQr6+Zb3RZihjPklmuY6ueORZBMBD9S9M2f8ZRuyexLWYVJbgI4sIMK6cnQ2u8uCQikrvHVaupCw==} engines: {node: '>=20'} + peerDependencies: + ts-pattern: ^5 update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} @@ -6821,9 +6841,9 @@ snapshots: '@ungap/structured-clone@1.3.1': {} - '@unthrown/vitest@5.0.0-beta.3(unthrown@5.0.0-beta.3)(vitest@4.1.10)': + '@unthrown/vitest@5.0.0-beta.5(unthrown@5.0.0-beta.5(ts-pattern@5.9.0))(vitest@4.1.10)': dependencies: - unthrown: 5.0.0-beta.3 + unthrown: 5.0.0-beta.5(ts-pattern@5.9.0) vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0) '@upsetjs/venn.js@2.0.0': @@ -9237,7 +9257,7 @@ snapshots: universalify@0.1.2: {} - unthrown@5.0.0-beta.3: + unthrown@5.0.0-beta.5(ts-pattern@5.9.0): dependencies: ts-pattern: 5.9.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b4a2a570..5bb7e0f9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -25,7 +25,7 @@ catalog: "@temporalio/worker": 1.20.2 "@temporalio/workflow": 1.20.2 "@types/node": 26.1.1 - "@unthrown/vitest": 5.0.0-beta.3 + "@unthrown/vitest": 5.0.0-beta.5 "@vitest/coverage-v8": 4.1.10 arktype: 2.2.3 knip: 6.26.0 @@ -35,13 +35,14 @@ catalog: pino: 10.3.1 pino-pretty: 13.1.3 testcontainers: 12.0.4 + ts-pattern: 5.9.0 tsdown: 0.22.7 tsx: 4.23.1 turbo: 2.10.5 typedoc: 0.28.20 typedoc-plugin-markdown: 4.12.0 typescript: 6.0.3 - unthrown: 5.0.0-beta.3 + unthrown: 5.0.0-beta.5 valibot: 1.4.2 vitest: 4.1.10 zod: 4.4.3