Skip to content

Commit c275cb0

Browse files
committed
docs: improve last section
1 parent df8d29d commit c275cb0

1 file changed

Lines changed: 28 additions & 51 deletions

File tree

apps/web/src/content/docs/v4/tutorials/modeling-errors.mdx

Lines changed: 28 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ await Effect.runPromise(outcome) // => "Provider unreachable, try again"
607607
Two more rules TypeScript enforces:
608608

609609
1. Every key must be a `_tag` from the union. A typo does not compile.
610-
1. The result keeps what the table misses: `Effect.Effect<Authorization, InvalidTokenError>`.
610+
2. The result keeps what the table misses: `Effect.Effect<Authorization, InvalidTokenError>`.
611611

612612
When all the errors are handled, the `Error` type parameter becomes `never`:
613613

@@ -658,29 +658,22 @@ const handle = (token: PaymentToken) =>
658658

659659
## Modeling unexpected errors: defects
660660

661-
Every error so far is one a caller can respond to. These are expected errors. But `never` does not mean the code cannot fail.
661+
Every error so far is one a caller can handle. These are expected errors. But `never` does not mean the code cannot fail.
662662

663-
Some conditions must always hold, or the payment step charges a customer wrong. Each is an invariant:
663+
One condition must always hold: an authorization is captured once, never twice. This condition is an invariant. If it breaks, the payment step charges the customer twice.
664664

665-
- An authorization is captured once, never twice.
666-
- The provider returns a status its contract promises.
665+
When an invariant breaks, no handler can respond. The safe action is to stop and report.
667666

668-
When one breaks, no handler can respond, so the safe action is to stop and report.
667+
Effect calls this failure an **unexpected error**, or a **defect**: an impossible state, a broken invariant, or a bug in third-party code.
669668

670-
Effect calls a failure like this an **unexpected error**, or a **defect**: an impossible state, a broken invariant, or a bug in third-party code.
669+
A defect is not tracked in the `Error` type parameter. The runtime records it in a `Cause`.
671670

672-
Unlike an expected error, a defect is not tracked in the `Error` type parameter; the runtime records it in a `Cause`.
671+
The capture step enforces this invariant. The ledger fixture starts with the payment already captured. So this run is the retry, and the invariant breaks.
673672

674-
The capture step enforces the first invariant. The ledger fixture starts with
675-
the payment already captured, so this run is the retry, and the invariant
676-
breaks.
677-
678-
No caller can undo a double capture, so a typed error would promise a handler
679-
that cannot exist. The step dies instead: `Effect.die` sends the error to the
680-
`Cause`, and the `Error` type parameter stays empty.
673+
No caller can undo a double capture. A typed error would promise a handler that cannot exist. The step dies instead. `Effect.die` sends the error to the `Cause`, and the `Error` type parameter stays empty.
681674

682675
```ts twoslash import.meta.vitest collapse={3-12,18-45} ins={14-16}
683-
import { Data, Effect } from "effect"
676+
import { Effect, Data } from "effect"
684677

685678
interface Authorization {
686679
readonly id: string
@@ -766,21 +759,13 @@ const program = Effect.gen(function* () {
766759
await Effect.runPromise(program) // => "terminated: pauth_123 already captured"
767760
```
768761

769-
The signature promises `Effect.Effect<Authorization>` with no `Error` type
770-
parameter, yet the double capture fails the run. The failure never entered the
771-
type: it became a defect, and `Effect.catchDefect` caught it. Without that
772-
handler, `Effect.runPromise` rejects. Unknown defects stay fatal, so the handler
773-
re-raises them with `Effect.die`.
762+
The signature promises `Effect.Effect<Authorization>` with no `Error` type parameter. Yet the double capture fails the run. The failure never entered the type. It became a defect, and `Effect.catchDefect` caught it. Without that handler, `Effect.runPromise` rejects. Unknown defects stay fatal, so the handler re-raises them with `Effect.die`.
774763

775764
### Remove errors no caller can handle from the type
776765

777-
`Effect.orDie` is a modeling decision: no caller can do anything with these
778-
errors, so stop tracking them. Every typed error becomes a defect, and the
779-
`Error` type parameter disappears.
766+
`Effect.orDie` is a modeling decision. No caller can handle these errors, so stop tracking them. Every typed error becomes a defect, and the `Error` type parameter disappears.
780767

781-
Apply it at the boundary between your Effect code and its callers: inside, the
782-
code still fails with typed errors, but the signature the caller sees has none
783-
left.
768+
Apply it at the boundary between your Effect code and its callers. Inside, the code still fails with typed errors. The signature the caller sees has none left.
784769

785770
```ts twoslash import.meta.vitest
786771
import { Effect, Data } from "effect"
@@ -834,25 +819,20 @@ try {
834819
}
835820
```
836821

837-
A nightly reconciliation job is one such boundary: when the card is expired, the
838-
job crashes and alerts whoever owns it. The failure crossed the boundary as a
839-
defect, so `Effect.runPromise` rejected with the `ExpiredCardError` itself.
822+
A nightly reconciliation job is one such boundary. When the card is expired, the job crashes and alerts its owner. The failure crossed the boundary as a defect. So `Effect.runPromise` rejected with the `ExpiredCardError` itself.
840823

841824
### Handle defects at the application boundary
842825

843-
A defect is a bug. A handler cannot fix it. But the boundary decides what
844-
happens next, once, in one place: the job runner, the HTTP handler, the batch
845-
loop. For each defect, ask:
826+
A defect is a bug. A handler cannot fix it. But the boundary decides what happens next, once, in one place: the job runner, the HTTP handler, the batch loop. For each defect, choose:
846827

847-
1. Could a caller respond to this? Then it is not a bug. Model it as a typed
848-
error: a class with a `_tag`, named in the `Error` type parameter.
849-
1. Is it a bug? Report it, and let the program terminate.
850-
1. Can the program continue without it? Catch it, report it, and go on.
828+
1. Report it, and let the program stop.
829+
2. Or catch it, report it, and let the program continue.
830+
831+
If a caller can respond to the failure, it is not a defect. Model it as a typed error instead.
851832

852833
Inside the program, defects stay fatal. Only the boundary decides.
853834

854-
`Effect.catchDefect` handles defects only. The next example shows that typed
855-
errors pass through unchanged.
835+
`Effect.catchDefect` handles defects only. The next example shows that typed errors pass through unchanged.
856836

857837
```ts twoslash import.meta.vitest
858838
import { Effect, Data } from "effect"
@@ -866,7 +846,7 @@ class ExpiredCardError extends Data.TaggedError("ExpiredCardError") {}
866846
class InvalidTokenError extends Data.TaggedError("InvalidTokenError") {}
867847
class NetworkError extends Data.TaggedError("NetworkError") {}
868848
class DoubleCaptureError extends Data.TaggedError("DoubleCaptureError")<{
869-
readonly authorizationId: string
849+
readonly id: string
870850
}> {}
871851

872852
type PaymentToken =
@@ -901,7 +881,7 @@ const processPayment = (
901881
const outcome = processPayment("tok_expiredcard").pipe(
902882
Effect.catchDefect((defect) =>
903883
defect instanceof DoubleCaptureError
904-
? Effect.succeed(`reported: ${defect.authorizationId} already captured`)
884+
? Effect.succeed(`reported: ${defect.id} already captured`)
905885
: Effect.die(defect),
906886
),
907887
)
@@ -913,10 +893,7 @@ try {
913893
}
914894
```
915895

916-
The `catchDefect` handler never ran. `catch (failure)`, not `catch (defect)`:
917-
the typed error was never a defect. It reached `runPromise` untouched,
918-
exactly as its type said, with all three members still in the `Error` type
919-
parameter.
896+
The `catchDefect` handler never ran. The result is `catch (failure)`, not `catch (defect)`. The typed error was never a defect. It reached `runPromise` unchanged, exactly as its type said, with all three members still in the `Error` type parameter.
920897

921898
A rule of thumb for the whole section:
922899

@@ -927,18 +904,18 @@ A rule of thumb for the whole section:
927904

928905
<Aside type="note" title="Checkpoint">
929906
"Did I cover every case?" has two answers now. For the errors you modeled, the
930-
`Error` type parameter answers: it shrinks as you handle them. For the ones
931-
you didn't, the boundary answers: `catchDefect` sees them at the edge, where
932-
you report them and let the program terminate.
907+
`Error` type parameter answers. It shrinks as you handle them. For the errors
908+
you did not model, the boundary answers. `catchDefect` sees them at the edge,
909+
where you report them and let the program stop.
933910
</Aside>
934911

935912
## Let's review what we built
936913

937914
You can now do five things. Each one maps to an operator you used.
938915

939-
- Read an `Effect` signature as a contract. The `Success` type parameter names the result, and the `Error` type parameter names every error a caller can handle.
940-
- Make each error visible to TypeScript with `Data.TaggedError`, so TypeScript tracks it instead of losing it as `unknown` in a `catch`.
941-
- Return errors instead of throwing them. Inside `Effect.gen`, fail with `return yield* new XError()`, and the `Error` type parameter tracks each one.
916+
- Read an `Effect` signature as a contract. The `Success` type parameter names the result. The `Error` type parameter names every error a caller can handle.
917+
- Make each error visible to TypeScript with `Data.TaggedError`. TypeScript tracks it instead of losing it as `unknown` in a `catch`.
918+
- Return errors instead of throwing them. Inside `Effect.gen`, fail with `return yield* new XError()`. The `Error` type parameter tracks each one.
942919
- Handle one `_tag` with `Effect.catchTag`, and many with `Effect.catchTags`. Each handled tag leaves the `Error` union. An empty union is `never`.
943920
- Keep defects apart from typed errors. `Effect.die` records a defect in the `Cause`. `Effect.catchDefect` handles defects at the boundary. `Effect.orDie` converts tracked errors you no longer handle.
944921

0 commit comments

Comments
 (0)