Skip to content

Commit aedacde

Browse files
committed
docs(examples)!: adopt v8 idioms — tag constants, object-pattern narrowing, match folds
- client matchers use exported tag constants and tagPatterns bundles - ContractError discrimination via the { errorName } object pattern - workflow error handling via match({ ok, errCases, defect }) with rethrow-on-defect - fix README link to the examples overview
1 parent 0eb42fa commit aedacde

3 files changed

Lines changed: 126 additions & 87 deletions

File tree

examples/order-processing-client/src/client.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import {
2+
SCHEDULE_NOT_FOUND_ERROR_TAG,
3+
SCHEDULE_ALREADY_EXISTS_ERROR_TAG,
4+
SIGNAL_VALIDATION_ERROR_TAG,
25
tagPatterns,
36
TypedClient,
7+
WORKFLOW_ALREADY_STARTED_ERROR_TAG,
8+
WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG,
9+
WORKFLOW_FAILED_ERROR_TAG,
10+
WORKFLOW_NOT_IN_CONTRACT_ERROR_TAG,
411
WORKFLOW_OUTCOME_ERROR_TAGS,
512
WORKFLOW_RESULT_ERROR_TAGS,
613
WORKFLOW_START_ERROR_TAGS,
14+
WORKFLOW_VALIDATION_ERROR_TAG,
715
} from "@temporal-contract/client";
816
import {
917
orderProcessingContract,
@@ -104,10 +112,10 @@ async function run() {
104112
ok: () => logger.info("✍️ Approval signal sent"),
105113
errCases: (matcher) =>
106114
matcher
107-
.with(P.tag("@temporal-contract/SignalValidationError"), (err) =>
115+
.with(P.tag(SIGNAL_VALIDATION_ERROR_TAG), (err) =>
108116
logger.error({ error: err }, "❌ Signal payload rejected by the contract"),
109117
)
110-
.with(P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), (err) =>
118+
.with(P.tag(WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG), (err) =>
111119
logger.error({ error: err }, "❌ Workflow execution not found"),
112120
),
113121
defect: (cause) => logger.error({ cause }, "❌ Unexpected failure sending signal"),
@@ -124,9 +132,9 @@ async function run() {
124132
errCases: (matcher) =>
125133
matcher
126134
// Typed domain error declared in the workflow's `errors:` block. The
127-
// only declared error is PaymentDeclined, so `err.errorName` narrows
128-
// to "PaymentDeclined" and `err.data` to `{ reason: string }`.
129-
.with(P.tag("@temporal-contract/ContractError"), (err) =>
135+
// object pattern narrows the shared-`_tag` `ContractError` union by
136+
// `errorName`, so `err.data` is typed `{ reason: string }`.
137+
.with({ errorName: "PaymentDeclined" }, (err) =>
130138
logger.error(
131139
{ errorName: err.errorName, reason: err.data.reason },
132140
`❌ Payment declined: ${err.data.reason}`,
@@ -192,7 +200,7 @@ async function run() {
192200
),
193201
errCases: (matcher) =>
194202
matcher
195-
.with(P.tag("@temporal-contract/ContractError"), (err) =>
203+
.with({ errorName: "PaymentDeclined" }, (err) =>
196204
logger.error({ errorName: err.errorName }, "❌ Payment declined"),
197205
)
198206
// The first-class outcome errors get their own arm here: a
@@ -201,13 +209,13 @@ async function run() {
201209
.with(...tagPatterns(WORKFLOW_OUTCOME_ERROR_TAGS), (err) =>
202210
logger.warn({ error: err }, `🛑 Workflow ${err.name}: execution was stopped`),
203211
)
204-
.with(P.tag("@temporal-contract/WorkflowValidationError"), (err) =>
212+
.with(P.tag(WORKFLOW_VALIDATION_ERROR_TAG), (err) =>
205213
logger.error({ error: err }, "❌ Workflow output validation failed"),
206214
)
207-
.with(P.tag("@temporal-contract/WorkflowFailedError"), (err) =>
215+
.with(P.tag(WORKFLOW_FAILED_ERROR_TAG), (err) =>
208216
logger.error({ error: err, cause: err.cause }, "❌ Workflow completed with failure"),
209217
)
210-
.with(P.tag("@temporal-contract/WorkflowExecutionNotFoundError"), (err) =>
218+
.with(P.tag(WORKFLOW_EXECUTION_NOT_FOUND_ERROR_TAG), (err) =>
211219
logger.error({ error: err }, "❌ Workflow execution not found in namespace"),
212220
),
213221
defect: (cause) => logger.error({ cause }, "❌ Unexpected failure awaiting result"),
@@ -248,8 +256,9 @@ async function run() {
248256
errCases: (matcher) =>
249257
matcher
250258
// The typed PaymentDeclined contract error, rehydrated from the
251-
// workflow's ApplicationFailure wire shape.
252-
.with(P.tag("@temporal-contract/ContractError"), (err) =>
259+
// workflow's ApplicationFailure wire shape and narrowed by the
260+
// `errorName` object pattern.
261+
.with({ errorName: "PaymentDeclined" }, (err) =>
253262
logger.error(
254263
{ errorName: err.errorName, reason: err.data.reason },
255264
`❌ Payment declined: ${err.data.reason}`,
@@ -259,7 +268,7 @@ async function run() {
259268
// (or in retention). Production callers can re-fetch the existing
260269
// handle; here we just log and move on. (Handled before the grouped
261270
// bundles so it keeps its dedicated branch.)
262-
.with(P.tag("@temporal-contract/WorkflowAlreadyStartedError"), (err) =>
271+
.with(P.tag(WORKFLOW_ALREADY_STARTED_ERROR_TAG), (err) =>
263272
logger.warn({ error: err }, "⏭️ Workflow already started — skipping"),
264273
)
265274
// Everything else executeWorkflow can err with — the start-phase and
@@ -297,15 +306,15 @@ async function run() {
297306
matcher
298307
// Create-if-absent: a colliding running schedule is a modeled error,
299308
// so idempotent callers just reuse the existing one.
300-
.with(P.tag("@temporal-contract/ScheduleAlreadyExistsError"), (err) => {
309+
.with(P.tag(SCHEDULE_ALREADY_EXISTS_ERROR_TAG), (err) => {
301310
logger.info({ scheduleId: err.scheduleId }, "⏭️ Schedule already exists — reusing it");
302311
return orders.schedule.getHandle(err.scheduleId);
303312
})
304-
.with(P.tag("@temporal-contract/WorkflowNotInContractError"), (err) => {
313+
.with(P.tag(WORKFLOW_NOT_IN_CONTRACT_ERROR_TAG), (err) => {
305314
logger.error({ error: err }, "❌ Workflow not declared in the contract");
306315
return undefined;
307316
})
308-
.with(P.tag("@temporal-contract/WorkflowValidationError"), (err) => {
317+
.with(P.tag(WORKFLOW_VALIDATION_ERROR_TAG), (err) => {
309318
logger.error({ error: err }, "❌ Schedule args rejected by the contract");
310319
return undefined;
311320
}),
@@ -321,7 +330,7 @@ async function run() {
321330
triggerResult.match({
322331
ok: () => logger.info("🧹 Cleanup run triggered immediately"),
323332
errCases: (matcher) =>
324-
matcher.with(P.tag("@temporal-contract/ScheduleNotFoundError"), (err) =>
333+
matcher.with(P.tag(SCHEDULE_NOT_FOUND_ERROR_TAG), (err) =>
325334
logger.error({ error: err }, "❌ Schedule vanished before it could be triggered"),
326335
),
327336
defect: (cause) => logger.error({ cause }, "❌ Unexpected failure triggering schedule"),
@@ -333,7 +342,7 @@ async function run() {
333342
logger.info("💡 What this client demonstrated:");
334343
logger.info(" - TypedClient.create({ client }) + .for(contract) split");
335344
logger.info(" - Typed signals (with and without payload) and queries");
336-
logger.info(" - A typed contract error (PaymentDeclined) matched with P.tag");
345+
logger.info(" - A typed contract error matched with the { errorName } object pattern");
337346
logger.info(" - schedule.create with the ScheduleAlreadyExistsError branch");
338347
logger.info(" - Exhaustive error matching — every tag or it doesn't compile");
339348

examples/order-processing-worker/README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,8 @@ pnpm dev
3535

3636
📖 **[Read the full documentation →](https://btravstack.github.io/temporal-contract)**
3737

38-
- [Example Overview](https://btravstack.github.io/temporal-contract/examples/basic-order-processing)
38+
- [Examples overview](https://btravstack.github.io/temporal-contract/examples/)
3939
- [Your first workflow](https://btravstack.github.io/temporal-contract/tutorial/your-first-workflow)
40-
- [All Examples](https://btravstack.github.io/temporal-contract/examples/)
4140

4241
## License
4342

examples/order-processing-worker/src/application/workflows.ts

Lines changed: 99 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,15 @@ import {
22
orderProcessingContract,
33
type OrderStatusSchema,
44
} from "@temporal-contract/sample-order-processing-contract";
5-
import { declareWorkflow } from "@temporal-contract/worker/workflow";
6-
import { condition, isCancellation, log } from "@temporalio/workflow";
5+
import {
6+
ACTIVITY_CANCELLED_ERROR_TAG,
7+
ACTIVITY_ERROR_TAG,
8+
declareWorkflow,
9+
rethrowCancellation,
10+
WORKFLOW_CANCELLED_ERROR_TAG,
11+
} from "@temporal-contract/worker/workflow";
12+
import { condition, log } from "@temporalio/workflow";
13+
import { P } from "unthrown";
714
import type { z } from "zod";
815

916
type OrderStatus = z.infer<typeof OrderStatusSchema>;
@@ -29,9 +36,13 @@ const APPROVAL_TIMEOUT = "5 minutes";
2936
* (domain + infrastructure). `processPayment` declares a contract error,
3037
* so its workflow-side call returns an `AsyncResult` whose error channel
3138
* carries the typed `PaymentDeclined` (plus the generic activity errors).
32-
* - A declined payment is rethrown as this workflow's own declared contract
33-
* error (`context.errors.PaymentDeclined`), so the typed client rehydrates
34-
* it — the one failure path that is an *error*, not a "failed" result.
39+
* - That error channel is folded once, at the call site, with
40+
* `match({ ok, errCases, defect })`: a declined payment is rethrown as
41+
* this workflow's own declared contract error
42+
* (`context.errors.PaymentDeclined`) so the typed client rehydrates it,
43+
* cancellation is re-raised with `rethrowCancellation` so the execution
44+
* ends `Cancelled`, an undeclared activity failure becomes a "failed"
45+
* order result, and a defect fails the Workflow Task.
3546
*
3647
* Determinism note: everything here is replay-safe — `condition` and `log`
3748
* come from `@temporalio/workflow`, signal/query state is plain local data,
@@ -138,57 +149,67 @@ export const processOrder = declareWorkflow({
138149

139150
// `processPayment` declares `errors` in the contract, so the call returns
140151
// `AsyncResult<PaymentResult, PaymentDeclined | ActivityError |
141-
// ActivityCancelledError>` instead of a throwing Promise.
142-
const paymentResult = await activities.processPayment({
143-
customerId: order.customerId,
144-
amount: order.totalAmount,
145-
});
146-
147-
if (!paymentResult.isOk()) {
148-
status = "failed";
149-
150-
if (paymentResult.isDefect()) {
152+
// ActivityCancelledError>` instead of a throwing Promise. Fold all three
153+
// channels once, at the call site — every arm either produces a value or
154+
// deliberately ends the workflow.
155+
const paymentOutcome = await activities
156+
.processPayment({ customerId: order.customerId, amount: order.totalAmount })
157+
.match({
158+
ok: (payment) => payment,
159+
errCases: (matcher) =>
160+
matcher
161+
// The only declared error on `processPayment` — the object
162+
// pattern narrows the shared-`_tag` `ContractError` union by
163+
// `errorName`, typing `failure.data` as `{ reason: string }`.
164+
.with({ errorName: "PaymentDeclined" }, async (failure) => {
165+
status = "failed";
166+
log.error(`Payment declined for order ${order.orderId}: ${failure.data.reason}`);
167+
168+
await activities.sendNotification({
169+
customerId: order.customerId,
170+
subject: "Order Failed",
171+
message: `We're sorry, but your order ${order.orderId} could not be processed. Your payment was declined (${failure.data.reason}).`,
172+
});
173+
174+
// Rethrow as this workflow's own declared contract error: the
175+
// execution fails with `ApplicationFailure(type: "PaymentDeclined")`
176+
// and the typed client rehydrates it into a `ContractError`.
177+
// oxlint-disable-next-line unthrown/no-throw -- sanctioned ApplicationFailure model: `throw context.errors.X(...)` is how a workflow fails terminally with a typed contract error (CLAUDE.md rule 2 exception)
178+
throw context.errors.PaymentDeclined(
179+
{ reason: failure.data.reason },
180+
{ cause: failure },
181+
);
182+
})
183+
// Cancellation rides the modeled Err channel — mapping it to a
184+
// "failed" order would complete the workflow instead of honoring
185+
// the cancel. Re-raise it so the execution ends `Cancelled`.
186+
.with(P.tag(ACTIVITY_CANCELLED_ERROR_TAG), (failure) => rethrowCancellation(failure))
187+
// Undeclared activity failure (retries exhausted, timeout):
188+
// surface a failed order result.
189+
.with(P.tag(ACTIVITY_ERROR_TAG), (failure) => {
190+
status = "failed";
191+
log.error(`Payment activity failed for order ${order.orderId}: ${failure.message}`);
192+
return {
193+
orderId: order.orderId,
194+
status: "failed" as const,
195+
failureReason: "Payment could not be processed",
196+
errorCode: "PAYMENT_UNAVAILABLE",
197+
};
198+
}),
151199
// Unmodeled failure (a bug, not an anticipated outcome) — rethrow at
152200
// the edge so Temporal surfaces the Workflow Task failure.
153-
// oxlint-disable-next-line unthrown/no-throw -- defect-cause rethrow at the edge: an unmodeled failure must surface as a Workflow Task failure
154-
throw paymentResult.cause;
155-
}
156-
const failure = paymentResult.error;
157-
158-
switch (failure._tag) {
159-
case "@temporal-contract/ContractError": {
160-
// The only declared error on `processPayment` is PaymentDeclined,
161-
// so `failure.data` is typed `{ reason: string }`.
162-
log.error(`Payment declined for order ${order.orderId}: ${failure.data.reason}`);
163-
164-
await activities.sendNotification({
165-
customerId: order.customerId,
166-
subject: "Order Failed",
167-
message: `We're sorry, but your order ${order.orderId} could not be processed. Your payment was declined (${failure.data.reason}).`,
168-
});
169-
170-
// Rethrow as this workflow's own declared contract error: the
171-
// execution fails with `ApplicationFailure(type: "PaymentDeclined")`
172-
// and the typed client rehydrates it into a `ContractError`.
173-
// oxlint-disable-next-line unthrown/no-throw -- sanctioned ApplicationFailure model: `throw context.errors.X(...)` is how a workflow fails terminally with a typed contract error (CLAUDE.md rule 2 exception)
174-
throw context.errors.PaymentDeclined({ reason: failure.data.reason }, { cause: failure });
175-
}
176-
case "@temporal-contract/ActivityError":
177-
case "@temporal-contract/ActivityCancelledError": {
178-
// Unclassified activity failure (retries exhausted, timeout,
179-
// cancellation): surface a failed order result.
180-
log.error(`Payment activity failed for order ${order.orderId}: ${failure.message}`);
181-
return {
182-
orderId: order.orderId,
183-
status: "failed" as const,
184-
failureReason: "Payment could not be processed",
185-
errorCode: "PAYMENT_UNAVAILABLE",
186-
};
187-
}
188-
}
201+
defect: (cause) => {
202+
// oxlint-disable-next-line unthrown/no-throw -- defect-cause rethrow at the edge: an unmodeled failure must surface as a Workflow Task failure
203+
throw cause;
204+
},
205+
});
206+
207+
if ("status" in paymentOutcome) {
208+
// The fold produced the workflow's failed output — return it as-is.
209+
return paymentOutcome;
189210
}
190211

191-
const payment = paymentResult.value;
212+
const payment = paymentOutcome;
192213
log.info(`Payment successful: ${payment.transactionId}`);
193214

194215
// ------------------------------------------------------------------
@@ -235,23 +256,33 @@ export const processOrder = declareWorkflow({
235256

236257
log.info(`Shipment created: ${shippingResult.trackingNumber}`);
237258

238-
// Step 5: Send success notification (non-critical)
239-
try {
240-
await activities.sendNotification({
241-
customerId: order.customerId,
242-
subject: "Order Confirmed",
243-
message: `Your order ${order.orderId} has been confirmed and will be shipped. Tracking: ${shippingResult.trackingNumber}`,
259+
// Step 5: Send success notification (non-critical). `sendNotification`
260+
// declares no errors, so it is a throwing Promise — `cancellableScope`
261+
// folds it into the Result discipline instead of a `try/catch`:
262+
// cancellation surfaces as `Err(WorkflowCancelledError)`, anything else
263+
// it throws is a defect.
264+
await context
265+
.cancellableScope(() =>
266+
activities.sendNotification({
267+
customerId: order.customerId,
268+
subject: "Order Confirmed",
269+
message: `Your order ${order.orderId} has been confirmed and will be shipped. Tracking: ${shippingResult.trackingNumber}`,
270+
}),
271+
)
272+
.match({
273+
ok: () => undefined,
274+
// Cancellation must propagate — absorbing it here would complete the
275+
// workflow after a cancel request instead of ending it `Cancelled`.
276+
errCases: (matcher) =>
277+
matcher.with(P.tag(WORKFLOW_CANCELLED_ERROR_TAG), (cancelled) =>
278+
rethrowCancellation(cancelled),
279+
),
280+
// Non-critical: the order is already shipped, so even an unmodeled
281+
// notification failure is only worth a warning.
282+
defect: (cause) => {
283+
log.warn(`Failed to send confirmation notification: ${cause}`);
284+
},
244285
});
245-
} catch (error) {
246-
// Cancellation must propagate — swallowing it here would leave the
247-
// workflow running after a cancel request.
248-
if (isCancellation(error)) {
249-
// oxlint-disable-next-line unthrown/no-throw -- cancellation rethrow: swallowing a CancelledFailure would leave the workflow running after a cancel request
250-
throw error;
251-
}
252-
// Non-critical: log but continue
253-
log.warn(`Failed to send confirmation notification: ${error}`);
254-
}
255286

256287
// Success!
257288
status = "completed";

0 commit comments

Comments
 (0)