Skip to content

Commit 73ae867

Browse files
Benoit Traversclaude
authored andcommitted
docs: unwrap discarded AsyncResults and fix non-exhaustive matchers
Addresses PR review. `AsyncResult` is a success-only thenable — its internal promise never rejects (core.ts:616) — so `await` collapses it to a Result and a bare `await handle.signals.x(...)` or `await schedule.pause(...)` discards the failure entirely. Copilot flagged four sites; the same bug was present at ~15. All are now unwrapped with `.get()` / `.getOrThrow()` or an explicit branch, and the trap is documented in the-result-model.md ("`await` is not an extractor") plus inline warnings on the schedule, signals, and upgrade pages. Also fixes the interceptor short-circuit, which returned `Err(new RuntimeClientError(...))`. That is both a type error — `ClientCallError` excludes it since 8.0 — and the wrong channel. It now defects via `fromSafePromise`, and the note points at the `defect` helper passed as the second argument to every `*ErrCases` callback. Auditing that turned up a further class of broken example: all the `*ErrCases` combinators require an `ExhaustiveMatch`, so the single-arm `.with(tag(...))` calls in the interceptor and middleware pages would not compile against their wide unions. Those now close with `P._`, and the abbreviated before/after snippets in the migration guides are labelled as such. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 30d86d1 commit 73ae867

11 files changed

Lines changed: 201 additions & 80 deletions

docs/explanation/the-result-model.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,34 @@ Extractors, and how each treats a defect:
193193

194194
Every one rethrows a defect. There is no extractor that quietly swallows a bug.
195195

196+
### `await` is not an extractor
197+
198+
The one trap worth internalizing. `AsyncResult` is a **success-only thenable**:
199+
awaiting it collapses it to a `Result`, and the underlying promise never
200+
rejects. So `await` never throws, whatever the outcome:
201+
202+
```typescript
203+
// ❌ The Result is discarded. A failed signal — or an outright bug — vanishes.
204+
await handle.signals.approve({ approvedBy: "ops" });
205+
206+
// ✅ Unwrap it.
207+
(await handle.signals.approve({ approvedBy: "ops" })).getOrThrow();
208+
209+
// ✅ Or chain the extractor before awaiting.
210+
await handle.signals.approve({ approvedBy: "ops" }).getOrThrow();
211+
212+
// ✅ Or branch.
213+
const sent = await handle.signals.approve({ approvedBy: "ops" });
214+
if (sent.isErr()) {
215+
/* ... */
216+
}
217+
```
218+
219+
This bites hardest on operations returning `AsyncResult<void, never>` — the
220+
schedule handle's `pause` / `unpause` / `trigger` / `delete`. An empty error
221+
channel reads like "cannot fail", but every failure there is a _defect_, and a
222+
bare `await` drops it silently. Chain `.get()`.
223+
196224
## Next
197225

198226
- [Errors reference](/reference/errors) — every class and its channel

docs/explanation/workflow-determinism.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ Temporal's `CancellationScope` and surface cancellation as
104104
const result = await context.cancellableScope(() => context.activities.processStep(args));
105105

106106
if (result.isErr()) {
107-
await context.nonCancellableScope(() => context.activities.releaseResources(args));
107+
await context.nonCancellableScope(() => context.activities.releaseResources(args)).getOrThrow();
108108
return { status: "cancelled" };
109109
}
110110
```

docs/how-to/add-activity-middleware.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,17 @@ Because `next` can be called more than once, a retry is just a branch on the
174174
error channel:
175175

176176
```typescript
177+
import { Err, P } from "unthrown";
178+
177179
const retryOnce: ActivityMiddleware = (invocation, next) =>
178180
next().flatMapErrCases((matcher) =>
179-
matcher.with(P.instanceOf(ApplicationFailure), (error) =>
180-
error.type === "GATEWAY_TIMEOUT" ? next() : Err(error).toAsync(),
181-
),
181+
matcher
182+
.with(P.instanceOf(ApplicationFailure), (error) =>
183+
error.type === "GATEWAY_TIMEOUT" ? next() : Err(error).toAsync(),
184+
)
185+
// The middleware error union is `ApplicationFailure | AnyContractError`,
186+
// and the matcher must cover all of it — pass declared errors through.
187+
.with(P._, (error) => Err(error).toAsync()),
182188
);
183189
```
184190

docs/how-to/handle-cancellation.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ if (bound.isErr()) {
1313
}
1414

1515
// Cooperative: the workflow observes the request and exits on its own terms.
16-
await bound.value.cancel();
16+
// `.getOrThrow()` surfaces a WorkflowExecutionNotFoundError — a bare `await`
17+
// would collapse the AsyncResult to an ignored Result instead.
18+
await bound.value.cancel().getOrThrow();
1719

1820
// Forceful: Temporal stops the execution immediately. No cleanup runs.
19-
await bound.value.terminate("fraud detected");
21+
await bound.value.terminate("fraud detected").getOrThrow();
2022
```
2123

2224
Prefer `cancel`. Reach for `terminate` only when the workflow is stuck or
@@ -76,7 +78,11 @@ implementation: async (context, order) => {
7678
// Cancelled after the charge but before shipping — refund.
7779
// Without the non-cancellable scope this refund would itself be cancelled.
7880
if (transactionId !== undefined) {
79-
await context.nonCancellableScope(() => context.activities.refundPayment({ transactionId }));
81+
// Unwrap: a refund that silently failed is worse than a loud failure,
82+
// and a bare `await` would discard both the Err and any defect.
83+
await context
84+
.nonCancellableScope(() => context.activities.refundPayment({ transactionId }))
85+
.getOrThrow();
8086
}
8187
return { status: "cancelled" as const };
8288
}

docs/how-to/index-workflows-with-search-attributes.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -152,15 +152,17 @@ Attributes set on a schedule apply to each run it spawns, so scheduled and
152152
directly started executions are indexed identically:
153153

154154
```typescript
155-
await client.schedule.create("processOrder", {
156-
scheduleId: "nightly-reconcile",
157-
spec: { cronExpressions: ["0 2 * * *"] },
158-
args: { mode: "reconcile" },
159-
searchAttributes: {
160-
priority: 5,
161-
tags: ["scheduled"],
162-
},
163-
});
155+
await client.schedule
156+
.create("processOrder", {
157+
scheduleId: "nightly-reconcile",
158+
spec: { cronExpressions: ["0 2 * * *"] },
159+
args: { mode: "reconcile" },
160+
searchAttributes: {
161+
priority: 5,
162+
tags: ["scheduled"],
163+
},
164+
})
165+
.getOrThrow();
164166
```
165167

166168
## Keep the cardinality sane

docs/how-to/intercept-client-calls.md

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,32 +95,71 @@ Modeled domain errors (`WorkflowNotFoundError`, a `ContractError`, a validation
9595
failure) stay on the `err` channel. Branch on those with `flatMapErrCases`:
9696

9797
```typescript
98+
import { Err, Ok, P, tag } from "unthrown";
99+
98100
const fallback: ClientInterceptor = (args, next) =>
99101
next().flatMapErrCases((matcher) =>
100-
matcher.with(tag("@temporal-contract/WorkflowAlreadyStartedError"), () =>
102+
matcher
101103
// Idempotent start: treat "already running" as success.
102-
Ok(undefined).toAsync(),
103-
),
104+
.with(tag("@temporal-contract/WorkflowAlreadyStartedError"), () => Ok(undefined).toAsync())
105+
// The matcher must cover the whole union — `P._` passes the rest through.
106+
.with(P._, (error) => Err(error).toAsync()),
104107
);
105108
```
106109

110+
::: warning The matcher is exhaustive
111+
`flatMapErrCases`, `mapErrCases`, `tapErrCases`, and `recoverErrCases` all
112+
require a match that covers every member of the error union — here the nine
113+
members of `ClientCallError`. A single `.with(tag(...))` arm will not compile.
114+
Handle the cases you care about, then close with `P._`.
115+
:::
116+
107117
Calling `next()` twice re-runs the rest of the chain, so retries compose.
108118

109119
## Short-circuit
110120

111121
Return your own result without calling `next`:
112122

113123
```typescript
124+
import { fromSafePromise } from "unthrown";
125+
114126
const readOnlyGuard: ClientInterceptor = (args, next) => {
115127
if (maintenanceMode && args.operation !== "query") {
116-
return Err(
117-
new RuntimeClientError(args.operation, new Error("maintenance mode: writes disabled")),
118-
).toAsync();
128+
// Maintenance mode is a technical fault, not a domain outcome, so it
129+
// belongs on the defect channel. `fromSafePromise` turns any throw from
130+
// the thunk into a defect, giving an AsyncResult<never, never>.
131+
return fromSafePromise(async () => {
132+
throw new RuntimeClientError(args.operation, new Error("maintenance mode: writes disabled"));
133+
});
119134
}
120135
return next();
121136
};
122137
```
123138

139+
::: warning Do not put `RuntimeClientError` on the `err` channel
140+
`ClientCallError` does not include it — since 8.0 it rides the defect channel.
141+
`Err(new RuntimeClientError(...))` is both a type error and the wrong channel.
142+
143+
There is no exported bare `Defect` constructor. Produce one by throwing inside
144+
`fromSafePromise` / `fromSafeThrowable`, or via the `defect` helper passed as
145+
the **second argument** to every `*ErrCases` callback and to a `fromPromise`
146+
qualifier:
147+
148+
```typescript
149+
next().mapErrCases(
150+
(matcher, defect) => matcher.with(P._, (error) => defect(error)), // demote every modeled error
151+
);
152+
```
153+
154+
:::
155+
156+
To short-circuit with a _modeled_ outcome instead, return `Ok`:
157+
158+
```typescript
159+
const skipInReadOnly: ClientInterceptor = (args, next) =>
160+
maintenanceMode && args.operation === "signal" ? Ok(undefined).toAsync() : next();
161+
```
162+
124163
## Measure latency
125164

126165
```typescript

docs/how-to/migrate-from-neverthrow.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ exhaustive matcher instead of the bare error:
103103
// before
104104
result.mapErr((error) => new WrappedError(error));
105105

106-
// after
106+
// after — one arm per tag in the union (abbreviated here; see the note below)
107107
import { tag } from "unthrown";
108108

109109
result.mapErrCases((matcher) =>

docs/how-to/schedule-workflows.md

Lines changed: 69 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -61,19 +61,21 @@ DST shifts will surprise you otherwise.
6161
## Control overlap and catch-up
6262

6363
```typescript
64-
await client.schedule.create("reconcileLedger", {
65-
scheduleId: "nightly-reconcile",
66-
spec: { cronExpressions: ["0 2 * * *"] },
67-
args: { mode: "full" },
68-
policies: {
69-
// What to do if the previous run is still going.
70-
overlap: "SKIP", // BUFFER_ONE | BUFFER_ALL | CANCEL_OTHER | TERMINATE_OTHER | ALLOW_ALL
71-
// How far back to catch up after an outage.
72-
catchupWindow: "1 hour",
73-
// Pause the whole schedule if a run fails.
74-
pauseOnFailure: true,
75-
},
76-
});
64+
await client.schedule
65+
.create("reconcileLedger", {
66+
scheduleId: "nightly-reconcile",
67+
spec: { cronExpressions: ["0 2 * * *"] },
68+
args: { mode: "full" },
69+
policies: {
70+
// What to do if the previous run is still going.
71+
overlap: "SKIP", // BUFFER_ONE | BUFFER_ALL | CANCEL_OTHER | TERMINATE_OTHER | ALLOW_ALL
72+
// How far back to catch up after an outage.
73+
catchupWindow: "1 hour",
74+
// Pause the whole schedule if a run fails.
75+
pauseOnFailure: true,
76+
},
77+
})
78+
.getOrThrow();
7779
```
7880

7981
`overlap: "SKIP"` is the safe default for anything non-idempotent. `ALLOW_ALL`
@@ -82,35 +84,39 @@ will happily run twenty copies at once after an outage.
8284
## Start paused
8385

8486
```typescript
85-
await client.schedule.create("reconcileLedger", {
86-
scheduleId: "nightly-reconcile",
87-
spec: { cronExpressions: ["0 2 * * *"] },
88-
args: { mode: "full" },
89-
state: {
90-
paused: true,
91-
note: "awaiting sign-off",
92-
// Fire a fixed number of times then stop.
93-
remainingActions: 10,
94-
},
95-
});
87+
await client.schedule
88+
.create("reconcileLedger", {
89+
scheduleId: "nightly-reconcile",
90+
spec: { cronExpressions: ["0 2 * * *"] },
91+
args: { mode: "full" },
92+
state: {
93+
paused: true,
94+
note: "awaiting sign-off",
95+
// Fire a fixed number of times then stop.
96+
remainingActions: 10,
97+
},
98+
})
99+
.getOrThrow();
96100
```
97101

98102
## Override the spawned workflow
99103

100104
`action` carries workflow-level overrides for each run:
101105

102106
```typescript
103-
await client.schedule.create("reconcileLedger", {
104-
scheduleId: "nightly-reconcile",
105-
spec: { cronExpressions: ["0 2 * * *"] },
106-
args: { mode: "full" },
107-
memo: { owner: "platform" }, // metadata on the schedule itself
108-
action: {
109-
workflowExecutionTimeout: "2 hours",
110-
retry: { maximumAttempts: 2 },
111-
memo: { kind: "scheduled-run" }, // metadata on each spawned workflow
112-
},
113-
});
107+
await client.schedule
108+
.create("reconcileLedger", {
109+
scheduleId: "nightly-reconcile",
110+
spec: { cronExpressions: ["0 2 * * *"] },
111+
args: { mode: "full" },
112+
memo: { owner: "platform" }, // metadata on the schedule itself
113+
action: {
114+
workflowExecutionTimeout: "2 hours",
115+
retry: { maximumAttempts: 2 },
116+
memo: { kind: "scheduled-run" }, // metadata on each spawned workflow
117+
},
118+
})
119+
.getOrThrow();
114120
```
115121

116122
::: tip Two different memos
@@ -124,15 +130,17 @@ are nested separately.
124130
## Index the spawned runs
125131

126132
```typescript
127-
await client.schedule.create("reconcileLedger", {
128-
scheduleId: "nightly-reconcile",
129-
spec: { cronExpressions: ["0 2 * * *"] },
130-
args: { mode: "full" },
131-
searchAttributes: {
132-
priority: 5,
133-
tags: ["scheduled"],
134-
},
135-
});
133+
await client.schedule
134+
.create("reconcileLedger", {
135+
scheduleId: "nightly-reconcile",
136+
spec: { cronExpressions: ["0 2 * * *"] },
137+
args: { mode: "full" },
138+
searchAttributes: {
139+
priority: 5,
140+
tags: ["scheduled"],
141+
},
142+
})
143+
.getOrThrow();
136144
```
137145

138146
Keys and value types are constrained to what the workflow declares. See
@@ -149,24 +157,35 @@ if (created.isErr()) throw created.error;
149157

150158
const schedule = created.value;
151159

152-
await schedule.pause("incident #4821");
153-
await schedule.unpause("incident resolved");
160+
// `.get()` rethrows a defect's original cause. Without it, `await` merely
161+
// collapses the AsyncResult to a Result and the failure is discarded.
162+
await schedule.pause("incident #4821").get();
163+
await schedule.unpause("incident resolved").get();
154164

155165
// Run it right now, without waiting for the next tick.
156-
await schedule.trigger();
166+
await schedule.trigger().get();
157167

158168
// Inspect current state.
159169
const described = await schedule.describe();
160-
if (described.isOk()) {
170+
if (described.isDefect()) {
171+
console.error("describe failed:", described.cause);
172+
} else {
161173
console.log(described.value.state.paused, described.value.info.nextActionTimes);
162174
}
163175

164-
await schedule.delete();
176+
await schedule.delete().get();
165177
```
166178

167179
Every method returns `AsyncResult<T, never>` — there is no modeled error. An
168180
unknown schedule id or a transport failure is a technical fault on the defect
169-
channel, so `.get()` is the extractor and a genuine problem surfaces loudly.
181+
channel.
182+
183+
::: warning `await` alone does not surface the failure
184+
`AsyncResult` is a success-only thenable: awaiting it yields a `Result`, and the
185+
underlying promise never rejects. `await schedule.pause(...)` therefore discards
186+
a defect silently. Chain `.get()` (which rethrows the original cause) or branch
187+
on `isDefect()` — the same applies to every `AsyncResult` in this library.
188+
:::
170189

171190
## Reach an existing schedule
172191

0 commit comments

Comments
 (0)