opencode-effect-enforcer 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +278 -0
- package/guidance/effect-first-development.md +1247 -0
- package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
- package/guidance/post__parse-dont-validate.md +109 -0
- package/guidance/progressive-disclosure-guidance.md +38 -0
- package/package.json +63 -0
- package/patterns/avoid-any.md +37 -0
- package/patterns/avoid-data-tagged-error.md +34 -0
- package/patterns/avoid-direct-json.md +51 -0
- package/patterns/avoid-direct-tag-checks.md +54 -0
- package/patterns/avoid-expect-in-if.md +52 -0
- package/patterns/avoid-mutable-state.md +70 -0
- package/patterns/avoid-native-fetch.md +61 -0
- package/patterns/avoid-node-imports.md +86 -0
- package/patterns/avoid-non-null-assertion.md +44 -0
- package/patterns/avoid-object-type.md +46 -0
- package/patterns/avoid-option-getorthrow.md +39 -0
- package/patterns/avoid-platform-coupling.md +43 -0
- package/patterns/avoid-process-env.md +43 -0
- package/patterns/avoid-react-hooks.md +73 -0
- package/patterns/avoid-schema-suffix.md +45 -0
- package/patterns/avoid-sync-fs.md +68 -0
- package/patterns/avoid-try-catch.md +47 -0
- package/patterns/avoid-ts-ignore.md +38 -0
- package/patterns/avoid-untagged-errors.md +67 -0
- package/patterns/avoid-yield-ref.md +46 -0
- package/patterns/casting-awareness.md +46 -0
- package/patterns/context-tag-extends.md +84 -0
- package/patterns/effect-catchall-default.md +61 -0
- package/patterns/effect-promise-vs-trypromise.md +47 -0
- package/patterns/effect-run-in-body.md +58 -0
- package/patterns/imperative-loops.md +76 -0
- package/patterns/prefer-arr-sort.md +52 -0
- package/patterns/prefer-duration-values.md +56 -0
- package/patterns/prefer-effect-fn.md +161 -0
- package/patterns/prefer-match-over-switch.md +48 -0
- package/patterns/prefer-option-over-null.md +56 -0
- package/patterns/prefer-redacted-config.md +70 -0
- package/patterns/prefer-schema-class.md +54 -0
- package/patterns/require-effect-concurrency.md +83 -0
- package/patterns/stream-large-files.md +63 -0
- package/patterns/throw-in-effect-gen.md +62 -0
- package/patterns/use-clock-service.md +45 -0
- package/patterns/use-command-executor-service.md +54 -0
- package/patterns/use-console-service.md +54 -0
- package/patterns/use-filesystem-service.md +59 -0
- package/patterns/use-http-client-service.md +77 -0
- package/patterns/use-path-service.md +53 -0
- package/patterns/use-random-service.md +45 -0
- package/patterns/use-temp-file-scoped.md +66 -0
- package/patterns/vm-in-wrong-file.md +51 -0
- package/patterns/yield-in-for-loop.md +61 -0
- package/skills/effect-ai-chat/SKILL.md +472 -0
- package/skills/effect-ai-language-model/SKILL.md +652 -0
- package/skills/effect-ai-prompt/SKILL.md +752 -0
- package/skills/effect-ai-provider/SKILL.md +668 -0
- package/skills/effect-ai-streaming/SKILL.md +418 -0
- package/skills/effect-ai-tool/SKILL.md +1132 -0
- package/skills/effect-atom-rpc/SKILL.md +488 -0
- package/skills/effect-atom-state/SKILL.md +640 -0
- package/skills/effect-batching/SKILL.md +614 -0
- package/skills/effect-cache/SKILL.md +570 -0
- package/skills/effect-cli/SKILL.md +523 -0
- package/skills/effect-command-executor/SKILL.md +675 -0
- package/skills/effect-concurrency-testing/SKILL.md +612 -0
- package/skills/effect-config/SKILL.md +580 -0
- package/skills/effect-context-witness/SKILL.md +274 -0
- package/skills/effect-domain-modeling/SKILL.md +1212 -0
- package/skills/effect-domain-predicates/SKILL.md +867 -0
- package/skills/effect-error-handling/SKILL.md +1581 -0
- package/skills/effect-fiber/SKILL.md +731 -0
- package/skills/effect-filesystem/SKILL.md +624 -0
- package/skills/effect-graph/SKILL.md +571 -0
- package/skills/effect-http-api/SKILL.md +1760 -0
- package/skills/effect-http-client/SKILL.md +989 -0
- package/skills/effect-http-server/SKILL.md +920 -0
- package/skills/effect-incremental-migration/SKILL.md +362 -0
- package/skills/effect-layer-design/SKILL.md +642 -0
- package/skills/effect-managed-runtime/SKILL.md +395 -0
- package/skills/effect-mcp-server/SKILL.md +608 -0
- package/skills/effect-observability/SKILL.md +719 -0
- package/skills/effect-optics/SKILL.md +554 -0
- package/skills/effect-parallelization/SKILL.md +668 -0
- package/skills/effect-path/SKILL.md +296 -0
- package/skills/effect-pattern-matching/SKILL.md +914 -0
- package/skills/effect-platform-abstraction/SKILL.md +1175 -0
- package/skills/effect-platform-layers/SKILL.md +514 -0
- package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
- package/skills/effect-react-composition/SKILL.md +986 -0
- package/skills/effect-react-vm/SKILL.md +675 -0
- package/skills/effect-rpc-api/SKILL.md +624 -0
- package/skills/effect-rpc-client/SKILL.md +666 -0
- package/skills/effect-rpc-cluster/SKILL.md +1623 -0
- package/skills/effect-rpc-server/SKILL.md +767 -0
- package/skills/effect-scheduling/SKILL.md +124 -0
- package/skills/effect-schema-composition/SKILL.md +975 -0
- package/skills/effect-schema-v4/SKILL.md +691 -0
- package/skills/effect-scope/SKILL.md +682 -0
- package/skills/effect-service-implementation/SKILL.md +656 -0
- package/skills/effect-socket/SKILL.md +703 -0
- package/skills/effect-sql/SKILL.md +781 -0
- package/skills/effect-stream/SKILL.md +765 -0
- package/skills/effect-testing/SKILL.md +1331 -0
- package/skills/effect-typeclass-design/SKILL.md +161 -0
- package/skills/effect-wide-events/Article.md +66 -0
- package/skills/effect-wide-events/SKILL.md +95 -0
- package/skills/effect-workflow/SKILL.md +810 -0
- package/src/agent-policy.ts +22 -0
- package/src/enforcer.ts +104 -0
- package/src/frontmatter.ts +34 -0
- package/src/guidance.ts +66 -0
- package/src/index.ts +38 -0
- package/src/pattern-catalog.ts +115 -0
- package/src/pattern-matcher.ts +178 -0
- package/src/pattern.ts +97 -0
- package/src/skills.ts +29 -0
- package/src/write-projection.ts +66 -0
|
@@ -0,0 +1,1581 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: effect-error-handling
|
|
3
|
+
description: Implement typed error handling in Effect v4 using Schema.TaggedError, catchTag/catchTags, catchReason/catchReasons, Cause, ErrorReporter, and recovery patterns. Use this skill when working with Effect error channels, handling expected failures, or designing error recovery strategies.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are an Effect TypeScript expert specializing in typed error handling, recovery patterns, and error channel management in **Effect v4**.
|
|
7
|
+
|
|
8
|
+
## Effect Source Reference
|
|
9
|
+
|
|
10
|
+
The Effect v4 source is available at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`.
|
|
11
|
+
Browse and read files there directly to look up APIs, types, and implementations.
|
|
12
|
+
|
|
13
|
+
Reference this for:
|
|
14
|
+
|
|
15
|
+
- Schema.TaggedError and error class creation
|
|
16
|
+
- Error handling combinators (catchTag, catchTags, catch, catchReason, catchReasons)
|
|
17
|
+
- Error transformation and recovery patterns
|
|
18
|
+
- Cause structure and inspection
|
|
19
|
+
- ErrorReporter module
|
|
20
|
+
- Defects vs error channel distinction
|
|
21
|
+
|
|
22
|
+
## v3 to v4 Error API Changes
|
|
23
|
+
|
|
24
|
+
**This table is authoritative. Never use the v3 names.**
|
|
25
|
+
|
|
26
|
+
### Effect Catch Combinators
|
|
27
|
+
|
|
28
|
+
| v3 (DO NOT USE) | v4 (USE THIS) | Notes |
|
|
29
|
+
| --------------------------- | --------------------------- | -------------------------------------------------- |
|
|
30
|
+
| `Effect.catchAll` | `Effect.catch` | Renamed |
|
|
31
|
+
| `Effect.catchAllCause` | `Effect.catchCause` | Renamed |
|
|
32
|
+
| `Effect.catchAllDefect` | `Effect.catchDefect` | Renamed |
|
|
33
|
+
| `Effect.catchSome` | `Effect.catchFilter` | Uses `Filter` module instead of `Option` |
|
|
34
|
+
| `Effect.catchSomeCause` | `Effect.catchCauseFilter` | Uses `Filter` module instead of `Option` |
|
|
35
|
+
| `Effect.catchSomeDefect` | Removed | No replacement |
|
|
36
|
+
| `Effect.optionFromOptional` | `Effect.catchNoSuchElement` | Renamed |
|
|
37
|
+
| `Effect.catchTag` | `Effect.catchTag` | Enhanced: accepts array of tags, optional `orElse` |
|
|
38
|
+
| `Effect.catchTags` | `Effect.catchTags` | Enhanced: optional `orElse` fallback |
|
|
39
|
+
| `Effect.catchIf` | `Effect.catchIf` | Enhanced: optional `orElse` fallback |
|
|
40
|
+
| (none) | `Effect.catchReason` | NEW: catch nested reason within tagged error |
|
|
41
|
+
| (none) | `Effect.catchReasons` | NEW: catch multiple nested reasons |
|
|
42
|
+
| (none) | `Effect.unwrapReason` | NEW: promote nested reasons to error channel |
|
|
43
|
+
| (none) | `Effect.catchEager` | NEW: synchronous recovery optimization |
|
|
44
|
+
| (none) | `Effect.withErrorReporting` | NEW: report errors to registered ErrorReporters |
|
|
45
|
+
|
|
46
|
+
### Cause Structure
|
|
47
|
+
|
|
48
|
+
| v3 (DO NOT USE) | v4 (USE THIS) | Notes |
|
|
49
|
+
| -------------------------------- | ------------------------------------------ | ----------------------------- |
|
|
50
|
+
| 6-variant recursive tree | `{ reasons: ReadonlyArray<Reason<E>> }` | Flattened |
|
|
51
|
+
| `Cause.sequential(l, r)` | `Cause.combine(l, r)` | Concatenates reasons arrays |
|
|
52
|
+
| `Cause.parallel(l, r)` | `Cause.combine(l, r)` | Same as sequential |
|
|
53
|
+
| `Cause.isFailType(cause)` | `Cause.isFailReason(reason)` | Operates on Reason, not Cause |
|
|
54
|
+
| `Cause.isDieType(cause)` | `Cause.isDieReason(reason)` | Operates on Reason, not Cause |
|
|
55
|
+
| `Cause.isInterruptType(cause)` | `Cause.isInterruptReason(reason)` | Operates on Reason, not Cause |
|
|
56
|
+
| `Cause.isFailure(cause)` | `Cause.hasFails(cause)` | Renamed |
|
|
57
|
+
| `Cause.isDie(cause)` | `Cause.hasDies(cause)` | Renamed |
|
|
58
|
+
| `Cause.isInterrupted(cause)` | `Cause.hasInterrupts(cause)` | Renamed |
|
|
59
|
+
| `Cause.isInterruptedOnly(cause)` | `Cause.hasInterruptsOnly(cause)` | Renamed |
|
|
60
|
+
| `Cause.failureOption(cause)` | `Cause.findErrorOption(cause)` | Renamed |
|
|
61
|
+
| `Cause.failureOrCause(cause)` | `Cause.findError(cause)` | Returns `Result.Result` now |
|
|
62
|
+
| `Cause.dieOption(cause)` | `Cause.findDefect(cause)` | Returns `Result.Result` now |
|
|
63
|
+
| `Cause.interruptOption(cause)` | `Cause.findInterrupt(cause)` | Returns `Result.Result` now |
|
|
64
|
+
| `Cause.failures(cause)` | `cause.reasons.filter(Cause.isFailReason)` | Use array filter |
|
|
65
|
+
| `Cause.defects(cause)` | `cause.reasons.filter(Cause.isDieReason)` | Use array filter |
|
|
66
|
+
|
|
67
|
+
### Error Class Renames (`*Exception` to `*Error`)
|
|
68
|
+
|
|
69
|
+
| v3 (DO NOT USE) | v4 (USE THIS) |
|
|
70
|
+
| -------------------------------------- | ----------------------------- |
|
|
71
|
+
| `Cause.NoSuchElementException` | `Cause.NoSuchElementError` |
|
|
72
|
+
| `Cause.TimeoutException` | `Cause.TimeoutError` |
|
|
73
|
+
| `Cause.IllegalArgumentException` | `Cause.IllegalArgumentError` |
|
|
74
|
+
| `Cause.ExceededCapacityException` | `Cause.ExceededCapacityError` |
|
|
75
|
+
| `Cause.UnknownException` | `Cause.UnknownError` |
|
|
76
|
+
| `Cause.RuntimeException` | Removed |
|
|
77
|
+
| `Cause.InterruptedException` | Removed |
|
|
78
|
+
| `Cause.InvalidPubSubCapacityException` | Removed |
|
|
79
|
+
|
|
80
|
+
### Schema Error Renames
|
|
81
|
+
|
|
82
|
+
| Old API (DO NOT USE) | Effect v4 API (USE THIS) |
|
|
83
|
+
| --------------------------------- | ------------------------------ |
|
|
84
|
+
| `Schema.TaggedErrorClass` | `Schema.TaggedError` |
|
|
85
|
+
| `Schema.ErrorClass` | `Schema.Error` |
|
|
86
|
+
| `Schema.Error` (instance schema) | `Schema.ErrorInstance` |
|
|
87
|
+
| `Schema.ErrorReviver` | `Schema.ErrorInstanceReviver` |
|
|
88
|
+
| `ParseError` | `Schema.SchemaError` |
|
|
89
|
+
|
|
90
|
+
## Core Error Handling Philosophy
|
|
91
|
+
|
|
92
|
+
Effect distinguishes between two types of failures:
|
|
93
|
+
|
|
94
|
+
1. **Expected Errors (Error Channel)** - Business logic failures that should be handled
|
|
95
|
+
- Type-safe and tracked in the effect signature: `Effect<A, E, R>`
|
|
96
|
+
- Represented by the `E` type parameter
|
|
97
|
+
- Handle with catchTag, catchTags, catch, catchReason, catchReasons
|
|
98
|
+
|
|
99
|
+
2. **Unexpected Errors (Defects)** - Programming errors that indicate bugs
|
|
100
|
+
- Not tracked in the type system
|
|
101
|
+
- Result from programming mistakes (null refs, unhandled cases, assertions)
|
|
102
|
+
- Usually should NOT be caught; use catchDefect only at boundaries
|
|
103
|
+
|
|
104
|
+
### Runtime Adapter Boundaries and Invariants
|
|
105
|
+
|
|
106
|
+
Do not force every impossible or adapter-internal failure into a tagged error just to satisfy a blanket rule.
|
|
107
|
+
|
|
108
|
+
Use typed errors for:
|
|
109
|
+
|
|
110
|
+
- caller-actionable failures
|
|
111
|
+
- business or protocol failures that the next layer can recover from
|
|
112
|
+
- public service contracts
|
|
113
|
+
|
|
114
|
+
Use defects or `Effect.orDie` for:
|
|
115
|
+
|
|
116
|
+
- impossible branches and invariant violations
|
|
117
|
+
- runtime-adapter internals where no caller can recover meaningfully
|
|
118
|
+
- collapsing noisy upstream error surfaces at a boundary that should not leak them further
|
|
119
|
+
|
|
120
|
+
`new Error(...)` is acceptable inside `Effect.die(...)`, invariant branches, or adapter-only defect paths. It is not acceptable as the public error model for recoverable domain behavior.
|
|
121
|
+
|
|
122
|
+
### When to Use Error Channel vs Defects
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
import * as Effect from 'effect/Effect';
|
|
126
|
+
import * as Schema from 'effect/Schema';
|
|
127
|
+
|
|
128
|
+
declare const findUser: (userId: string) => Effect.Effect<User, UserNotFound>;
|
|
129
|
+
declare const validatePassword: (
|
|
130
|
+
user: User,
|
|
131
|
+
password: string
|
|
132
|
+
) => Effect.Effect<boolean, InvalidCredentials>;
|
|
133
|
+
declare const database: {
|
|
134
|
+
query: (
|
|
135
|
+
sql: string,
|
|
136
|
+
...params: ReadonlyArray<unknown>
|
|
137
|
+
) => Effect.Effect<unknown>;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
interface User {
|
|
141
|
+
readonly id: string;
|
|
142
|
+
readonly name: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// CORRECT - Expected business failures in error channel
|
|
146
|
+
class UserNotFound extends Schema.TaggedError<UserNotFound>()(
|
|
147
|
+
'UserNotFound',
|
|
148
|
+
{
|
|
149
|
+
userId: Schema.String,
|
|
150
|
+
message: Schema.String
|
|
151
|
+
}
|
|
152
|
+
) {}
|
|
153
|
+
|
|
154
|
+
class InvalidCredentials extends Schema.TaggedError<InvalidCredentials>()(
|
|
155
|
+
'InvalidCredentials',
|
|
156
|
+
{ reason: Schema.String, message: Schema.String }
|
|
157
|
+
) {}
|
|
158
|
+
|
|
159
|
+
const authenticateUser = (
|
|
160
|
+
userId: string,
|
|
161
|
+
password: string
|
|
162
|
+
): Effect.Effect<User, UserNotFound | InvalidCredentials> =>
|
|
163
|
+
Effect.gen(function* () {
|
|
164
|
+
const user = yield* findUser(userId); // Can fail with UserNotFound
|
|
165
|
+
const valid = yield* validatePassword(user, password); // Can fail with InvalidCredentials
|
|
166
|
+
return user;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// CORRECT - Programmer errors as defects (use Effect.die)
|
|
170
|
+
const assertPositive = (n: number): Effect.Effect<number> =>
|
|
171
|
+
n > 0
|
|
172
|
+
? Effect.succeed(n)
|
|
173
|
+
: Effect.die(new Error(`Expected positive number, got ${n}`));
|
|
174
|
+
|
|
175
|
+
// WRONG - Business failure as defect
|
|
176
|
+
const findUserWrong = (userId: string): Effect.Effect<User> =>
|
|
177
|
+
Effect.gen(function* () {
|
|
178
|
+
const user = yield* database.query(
|
|
179
|
+
'SELECT * FROM users WHERE id = ?',
|
|
180
|
+
userId
|
|
181
|
+
);
|
|
182
|
+
if (!user) {
|
|
183
|
+
yield* Effect.die(new Error('User not found')); // Should be in error channel!
|
|
184
|
+
}
|
|
185
|
+
return user as User;
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Error Class Decision Tree
|
|
190
|
+
|
|
191
|
+
Effect v4 provides three ways to define error classes. Choose based on context:
|
|
192
|
+
|
|
193
|
+
### `Schema.TaggedError` — Primary choice for domain errors
|
|
194
|
+
|
|
195
|
+
Schema-validated, automatically tagged with `_tag`, catchable via `catchTag`. Use for all cross-module and public API errors.
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
import * as Schema from 'effect/Schema';
|
|
199
|
+
|
|
200
|
+
class NotFound extends Schema.TaggedError<NotFound>()(
|
|
201
|
+
'NotFound',
|
|
202
|
+
{ id: Schema.String, message: Schema.String },
|
|
203
|
+
{ description: 'Entity was not found.' }
|
|
204
|
+
) {}
|
|
205
|
+
|
|
206
|
+
// Constructed with schema validation
|
|
207
|
+
const error = new NotFound({ id: '123', message: 'User not found' });
|
|
208
|
+
error._tag; // "NotFound"
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### `Schema.Error` — For manual tag control
|
|
212
|
+
|
|
213
|
+
Schema-validated but no automatic `_tag`. Use when you need a custom discriminator field (e.g., HttpApiError types use `_tag: Schema.tag("NotFound")` manually).
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
import * as Schema from 'effect/Schema';
|
|
217
|
+
|
|
218
|
+
class NotFound extends Schema.Error<NotFound>('NotFound')({
|
|
219
|
+
_tag: Schema.tag('NotFound'),
|
|
220
|
+
message: Schema.String
|
|
221
|
+
}) {}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### `Data.TaggedError` — Lightweight, no schema validation
|
|
225
|
+
|
|
226
|
+
No schema validation overhead. Use for module-internal errors or hot paths where schema decoding cost is unwanted.
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
import * as Data from 'effect/Data';
|
|
230
|
+
|
|
231
|
+
class InternalError extends Data.TaggedError('InternalError')<{
|
|
232
|
+
readonly message: string;
|
|
233
|
+
}> {}
|
|
234
|
+
|
|
235
|
+
// Still catchable via catchTag
|
|
236
|
+
const program = Effect.fail(new InternalError({ message: 'oops' })).pipe(
|
|
237
|
+
Effect.catchTag('InternalError', (e) => Effect.succeed(e.message))
|
|
238
|
+
);
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Decision summary
|
|
242
|
+
|
|
243
|
+
| Scenario | Use |
|
|
244
|
+
| ------------------------------------------- | ------------------------- |
|
|
245
|
+
| Cross-module / public API errors | `Schema.TaggedError` |
|
|
246
|
+
| Errors that need `httpApiStatus` annotation | `Schema.TaggedError` |
|
|
247
|
+
| Errors with a `reason` union field | `Schema.TaggedError` |
|
|
248
|
+
| Custom discriminator field (not `_tag`) | `Schema.Error` |
|
|
249
|
+
| Module-internal, no serialization needed | `Data.TaggedError` |
|
|
250
|
+
|
|
251
|
+
## Creating Tagged Errors
|
|
252
|
+
|
|
253
|
+
Always use `Schema.TaggedError` for domain errors with a `message` field.
|
|
254
|
+
|
|
255
|
+
### Basic Tagged Error
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
import * as Schema from 'effect/Schema';
|
|
259
|
+
|
|
260
|
+
// Simple error with message only
|
|
261
|
+
export class NetworkError extends Schema.TaggedError<NetworkError>()(
|
|
262
|
+
'NetworkError',
|
|
263
|
+
{ message: Schema.String },
|
|
264
|
+
{ description: 'Network request failed.' }
|
|
265
|
+
) {}
|
|
266
|
+
|
|
267
|
+
// Error with rich context
|
|
268
|
+
export class ValidationError extends Schema.TaggedError<ValidationError>()(
|
|
269
|
+
'ValidationError',
|
|
270
|
+
{
|
|
271
|
+
field: Schema.String,
|
|
272
|
+
message: Schema.String,
|
|
273
|
+
value: Schema.optional(Schema.Unknown)
|
|
274
|
+
},
|
|
275
|
+
{ description: 'Input validation failed for a specific field.' }
|
|
276
|
+
) {}
|
|
277
|
+
|
|
278
|
+
// Usage
|
|
279
|
+
const error = new ValidationError({
|
|
280
|
+
field: 'email',
|
|
281
|
+
message: 'Invalid email format',
|
|
282
|
+
value: 'not-an-email'
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Error with Reason Discriminator
|
|
287
|
+
|
|
288
|
+
For bindings that wrap a single external system, use a `reason` literal union to keep the error surface compact while remaining precise:
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
import * as Schema from 'effect/Schema';
|
|
292
|
+
|
|
293
|
+
export class ApiError extends Schema.TaggedError<ApiError>()(
|
|
294
|
+
'ApiError',
|
|
295
|
+
{
|
|
296
|
+
reason: Schema.Literals([
|
|
297
|
+
'BadRequest',
|
|
298
|
+
'Unauthorized',
|
|
299
|
+
'NotFound',
|
|
300
|
+
'RateLimited',
|
|
301
|
+
'ServerError',
|
|
302
|
+
'Timeout'
|
|
303
|
+
]),
|
|
304
|
+
message: Schema.String,
|
|
305
|
+
statusCode: Schema.optional(Schema.Number),
|
|
306
|
+
details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown))
|
|
307
|
+
},
|
|
308
|
+
{ description: 'Failure from an external API operation.' }
|
|
309
|
+
) {}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Error with Nested Reason Types (for `catchReason`/`catchReasons`)
|
|
313
|
+
|
|
314
|
+
When reason variants carry distinct payloads, model each as a separate `TaggedError` and compose with `Schema.Union`. This enables v4's `catchReason` and `catchReasons`:
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
import * as Schema from 'effect/Schema';
|
|
318
|
+
|
|
319
|
+
export class RateLimitError extends Schema.TaggedError<RateLimitError>()(
|
|
320
|
+
'RateLimitError',
|
|
321
|
+
{ retryAfter: Schema.Number },
|
|
322
|
+
{ description: 'Rate limit exceeded.' }
|
|
323
|
+
) {}
|
|
324
|
+
|
|
325
|
+
export class QuotaExceededError extends Schema.TaggedError<QuotaExceededError>()(
|
|
326
|
+
'QuotaExceededError',
|
|
327
|
+
{ limit: Schema.Number },
|
|
328
|
+
{ description: 'Quota exhausted.' }
|
|
329
|
+
) {}
|
|
330
|
+
|
|
331
|
+
export class SafetyBlockedError extends Schema.TaggedError<SafetyBlockedError>()(
|
|
332
|
+
'SafetyBlockedError',
|
|
333
|
+
{ category: Schema.String },
|
|
334
|
+
{ description: 'Blocked by safety filter.' }
|
|
335
|
+
) {}
|
|
336
|
+
|
|
337
|
+
export class AiError extends Schema.TaggedError<AiError>()(
|
|
338
|
+
'AiError',
|
|
339
|
+
{
|
|
340
|
+
reason: Schema.Union([
|
|
341
|
+
RateLimitError,
|
|
342
|
+
QuotaExceededError,
|
|
343
|
+
SafetyBlockedError
|
|
344
|
+
])
|
|
345
|
+
},
|
|
346
|
+
{ description: 'Failure from an AI model call.' }
|
|
347
|
+
) {}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Error with HTTP Status Annotation
|
|
351
|
+
|
|
352
|
+
For errors that map to HTTP responses, use the `httpApiStatus` annotation:
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
import * as Schema from 'effect/Schema';
|
|
356
|
+
|
|
357
|
+
export class Unauthorized extends Schema.TaggedError<Unauthorized>()(
|
|
358
|
+
'Unauthorized',
|
|
359
|
+
{ message: Schema.String },
|
|
360
|
+
{
|
|
361
|
+
httpApiStatus: 401,
|
|
362
|
+
description: 'Request lacks valid authentication credentials.'
|
|
363
|
+
}
|
|
364
|
+
) {}
|
|
365
|
+
|
|
366
|
+
export class EntityNotFound extends Schema.TaggedError<EntityNotFound>()(
|
|
367
|
+
'EntityNotFound',
|
|
368
|
+
{ entityType: Schema.String, id: Schema.String, message: Schema.String },
|
|
369
|
+
{
|
|
370
|
+
httpApiStatus: 404,
|
|
371
|
+
description: 'Requested entity does not exist.'
|
|
372
|
+
}
|
|
373
|
+
) {}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Error with Custom Properties
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
import * as Schema from 'effect/Schema';
|
|
380
|
+
|
|
381
|
+
export class HttpError extends Schema.TaggedError<HttpError>()(
|
|
382
|
+
'HttpError',
|
|
383
|
+
{
|
|
384
|
+
status: Schema.Number,
|
|
385
|
+
body: Schema.String,
|
|
386
|
+
message: Schema.String
|
|
387
|
+
},
|
|
388
|
+
{ description: 'HTTP response error.' }
|
|
389
|
+
) {
|
|
390
|
+
get isClientError() {
|
|
391
|
+
return this.status >= 400 && this.status < 500;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
get isServerError() {
|
|
395
|
+
return this.status >= 500;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
## Error Wrapping Conventions
|
|
401
|
+
|
|
402
|
+
When wrapping upstream errors in domain error classes, choose the `cause` field schema based on intent:
|
|
403
|
+
|
|
404
|
+
| `cause` Schema | When to use | Example |
|
|
405
|
+
| ---------------- | -------------------------------------------------------------- | -------------------------------- |
|
|
406
|
+
| `Schema.Defect()` | Wrapping unknown/untyped upstream errors (throwables, defects) | `DevToolsError`, `DatabaseError` |
|
|
407
|
+
| `Schema.Unknown` | Preserving full upstream error structure for debugging | `SubstackFetchError` |
|
|
408
|
+
| `Schema.String` | Message-only wrapping where structure is irrelevant | `AuthError` |
|
|
409
|
+
| Omitted | When the error tag + fields fully describe the failure | `UserNotFound`, `Unauthorized` |
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
import * as Schema from 'effect/Schema';
|
|
413
|
+
|
|
414
|
+
// Defect-style: wraps throwables and unknown failures
|
|
415
|
+
export class DatabaseError extends Schema.TaggedError<DatabaseError>()(
|
|
416
|
+
'DatabaseError',
|
|
417
|
+
{
|
|
418
|
+
operation: Schema.String,
|
|
419
|
+
message: Schema.String,
|
|
420
|
+
cause: Schema.Defect()
|
|
421
|
+
},
|
|
422
|
+
{ description: 'Database operation failed.' }
|
|
423
|
+
) {}
|
|
424
|
+
|
|
425
|
+
// Unknown-style: preserves full upstream error
|
|
426
|
+
export class FetchError extends Schema.TaggedError<FetchError>()(
|
|
427
|
+
'FetchError',
|
|
428
|
+
{
|
|
429
|
+
url: Schema.String,
|
|
430
|
+
message: Schema.String,
|
|
431
|
+
cause: Schema.Unknown
|
|
432
|
+
},
|
|
433
|
+
{ description: 'HTTP fetch operation failed.' }
|
|
434
|
+
) {}
|
|
435
|
+
|
|
436
|
+
// String-style: message-only wrapper
|
|
437
|
+
export class AuthError extends Schema.TaggedError<AuthError>()(
|
|
438
|
+
'AuthError',
|
|
439
|
+
{
|
|
440
|
+
cause: Schema.String
|
|
441
|
+
},
|
|
442
|
+
{ description: 'Authentication backend failure.' }
|
|
443
|
+
) {}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
## Yieldable Errors
|
|
447
|
+
|
|
448
|
+
In `Effect.gen` blocks, tagged error instances can be yielded directly as a shorthand for `yield* Effect.fail(...)`. This works because `Schema.TaggedError`, `Schema.Error`, and `Data.TaggedError` all extend `Cause.YieldableError`.
|
|
449
|
+
|
|
450
|
+
```typescript
|
|
451
|
+
import { Effect } from 'effect';
|
|
452
|
+
import * as Schema from 'effect/Schema';
|
|
453
|
+
|
|
454
|
+
class NotFound extends Schema.TaggedError<NotFound>()('NotFound', {
|
|
455
|
+
id: Schema.String,
|
|
456
|
+
message: Schema.String
|
|
457
|
+
}) {}
|
|
458
|
+
|
|
459
|
+
// These two are equivalent:
|
|
460
|
+
const explicit = Effect.gen(function* () {
|
|
461
|
+
return yield* Effect.fail(
|
|
462
|
+
new NotFound({ id: '123', message: 'User not found' })
|
|
463
|
+
);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const shorthand = Effect.gen(function* () {
|
|
467
|
+
return yield* new NotFound({ id: '123', message: 'User not found' });
|
|
468
|
+
});
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
The shorthand form is idiomatic and preferred in Effect v4 generators. It reads naturally as "yield this error" and reduces noise.
|
|
472
|
+
|
|
473
|
+
## Handling Errors by Tag
|
|
474
|
+
|
|
475
|
+
### catchTag - Single Error Type
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
import * as Effect from 'effect/Effect';
|
|
479
|
+
import * as Schema from 'effect/Schema';
|
|
480
|
+
|
|
481
|
+
declare const createGuestUser: (id: string) => User;
|
|
482
|
+
|
|
483
|
+
interface User {
|
|
484
|
+
readonly id: string;
|
|
485
|
+
readonly name: string;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
class NotFound extends Schema.TaggedError<NotFound>()('NotFound', {
|
|
489
|
+
id: Schema.String,
|
|
490
|
+
message: Schema.String
|
|
491
|
+
}) {}
|
|
492
|
+
|
|
493
|
+
class Unauthorized extends Schema.TaggedError<Unauthorized>()(
|
|
494
|
+
'Unauthorized',
|
|
495
|
+
{
|
|
496
|
+
message: Schema.String
|
|
497
|
+
}
|
|
498
|
+
) {}
|
|
499
|
+
|
|
500
|
+
// Effect<User, NotFound | Unauthorized, Dependencies>
|
|
501
|
+
// v
|
|
502
|
+
const getUser = (id: string): Effect.Effect<User, NotFound | Unauthorized> =>
|
|
503
|
+
Effect.fail(new NotFound({ id, message: `User ${id} not found` }));
|
|
504
|
+
|
|
505
|
+
// Handle single error type
|
|
506
|
+
// Effect<User, Unauthorized, Dependencies>
|
|
507
|
+
// v
|
|
508
|
+
const program = getUser('123').pipe(
|
|
509
|
+
Effect.catchTag('NotFound', (error) =>
|
|
510
|
+
// Return default user when not found
|
|
511
|
+
Effect.succeed(createGuestUser(error.id))
|
|
512
|
+
)
|
|
513
|
+
);
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
### catchTag - Array Form (v4)
|
|
517
|
+
|
|
518
|
+
In Effect v4, `catchTag` accepts an array of tags to handle multiple error types with a single handler:
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
import { Effect, Schema } from 'effect';
|
|
522
|
+
|
|
523
|
+
class ParseError extends Schema.TaggedError<ParseError>()('ParseError', {
|
|
524
|
+
input: Schema.String,
|
|
525
|
+
message: Schema.String
|
|
526
|
+
}) {}
|
|
527
|
+
|
|
528
|
+
class ReservedPortError extends Schema.TaggedError<ReservedPortError>()(
|
|
529
|
+
'ReservedPortError',
|
|
530
|
+
{
|
|
531
|
+
port: Schema.Number
|
|
532
|
+
}
|
|
533
|
+
) {}
|
|
534
|
+
|
|
535
|
+
declare const loadPort: (
|
|
536
|
+
input: string
|
|
537
|
+
) => Effect.Effect<number, ParseError | ReservedPortError>;
|
|
538
|
+
|
|
539
|
+
// Catch multiple tags with one handler - the error is typed as the union
|
|
540
|
+
const program = loadPort('80').pipe(
|
|
541
|
+
Effect.catchTag(['ParseError', 'ReservedPortError'], (_) =>
|
|
542
|
+
Effect.succeed(3000)
|
|
543
|
+
)
|
|
544
|
+
);
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
### catchTag / catchTags - Optional `orElse` Fallback (v4)
|
|
548
|
+
|
|
549
|
+
In v4, `catchTag`, `catchTags`, and `catchIf` accept an optional trailing `orElse` parameter for unmatched errors:
|
|
550
|
+
|
|
551
|
+
```typescript
|
|
552
|
+
import { Effect, Schema } from 'effect';
|
|
553
|
+
|
|
554
|
+
class NotFound extends Schema.TaggedError<NotFound>()('NotFound', {
|
|
555
|
+
message: Schema.String
|
|
556
|
+
}) {}
|
|
557
|
+
|
|
558
|
+
class Forbidden extends Schema.TaggedError<Forbidden>()('Forbidden', {
|
|
559
|
+
message: Schema.String
|
|
560
|
+
}) {}
|
|
561
|
+
|
|
562
|
+
class ServerError extends Schema.TaggedError<ServerError>()(
|
|
563
|
+
'ServerError',
|
|
564
|
+
{
|
|
565
|
+
message: Schema.String
|
|
566
|
+
}
|
|
567
|
+
) {}
|
|
568
|
+
|
|
569
|
+
declare const riskyOp: () => Effect.Effect<
|
|
570
|
+
string,
|
|
571
|
+
NotFound | Forbidden | ServerError
|
|
572
|
+
>;
|
|
573
|
+
|
|
574
|
+
// The third argument is the orElse handler for unmatched errors
|
|
575
|
+
const program = riskyOp().pipe(
|
|
576
|
+
Effect.catchTag(
|
|
577
|
+
'NotFound',
|
|
578
|
+
(e) => Effect.succeed('default'),
|
|
579
|
+
(unmatched) => Effect.die(unmatched) // Forbidden | ServerError
|
|
580
|
+
)
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
// Works with catchTags too
|
|
584
|
+
const program2 = riskyOp().pipe(
|
|
585
|
+
Effect.catchTags(
|
|
586
|
+
{
|
|
587
|
+
NotFound: (e) => Effect.succeed('default'),
|
|
588
|
+
Forbidden: (e) => Effect.succeed('forbidden')
|
|
589
|
+
},
|
|
590
|
+
(unmatched) => Effect.die(unmatched) // ServerError
|
|
591
|
+
)
|
|
592
|
+
);
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
> **Type preservation (beta.71):** When you omit `orElse`, the tags you do not handle stay in the error channel — `catchTag(['NotFound'], ...)` on `Effect<string, NotFound | Forbidden | ServerError>` yields `Effect<string, Forbidden | ServerError>`. Supplying `orElse` handles those remaining variants, so the resulting error channel reflects only what the fallback produces. A beta.71 fix ensures `catchTag` / `catchTags` / `catchIf` no longer silently drop the unhandled error types from the inferred type.
|
|
596
|
+
|
|
597
|
+
### catchTags - Multiple Error Types
|
|
598
|
+
|
|
599
|
+
```typescript
|
|
600
|
+
import * as Effect from 'effect/Effect';
|
|
601
|
+
import * as Schema from 'effect/Schema';
|
|
602
|
+
|
|
603
|
+
interface Data {
|
|
604
|
+
readonly data: ReadonlyArray<unknown>;
|
|
605
|
+
readonly cached?: boolean;
|
|
606
|
+
readonly timeout?: boolean;
|
|
607
|
+
readonly parseError?: boolean;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
class NetworkError extends Schema.TaggedError<NetworkError>()(
|
|
611
|
+
'NetworkError',
|
|
612
|
+
{
|
|
613
|
+
message: Schema.String
|
|
614
|
+
}
|
|
615
|
+
) {}
|
|
616
|
+
|
|
617
|
+
class TimeoutError extends Schema.TaggedError<TimeoutError>()(
|
|
618
|
+
'TimeoutError',
|
|
619
|
+
{
|
|
620
|
+
message: Schema.String
|
|
621
|
+
}
|
|
622
|
+
) {}
|
|
623
|
+
|
|
624
|
+
class ParseError extends Schema.TaggedError<ParseError>()('ParseError', {
|
|
625
|
+
input: Schema.String,
|
|
626
|
+
message: Schema.String
|
|
627
|
+
}) {}
|
|
628
|
+
|
|
629
|
+
// Effect<Data, NetworkError | TimeoutError | ParseError, Dependencies>
|
|
630
|
+
// v
|
|
631
|
+
const fetchData = (): Effect.Effect<
|
|
632
|
+
Data,
|
|
633
|
+
NetworkError | TimeoutError | ParseError
|
|
634
|
+
> => Effect.fail(new NetworkError({ message: 'Connection refused' }));
|
|
635
|
+
|
|
636
|
+
// Handle multiple error types at once
|
|
637
|
+
// Effect<Data, never, Dependencies>
|
|
638
|
+
// v
|
|
639
|
+
const program = fetchData().pipe(
|
|
640
|
+
Effect.catchTags({
|
|
641
|
+
NetworkError: (_error) => Effect.succeed({ data: [], cached: true }),
|
|
642
|
+
|
|
643
|
+
TimeoutError: (_error) => Effect.succeed({ data: [], timeout: true }),
|
|
644
|
+
|
|
645
|
+
ParseError: (error) =>
|
|
646
|
+
// Access error-specific fields
|
|
647
|
+
Effect.logError(`Failed to parse: ${error.input}`).pipe(
|
|
648
|
+
Effect.as({ data: [], parseError: true })
|
|
649
|
+
)
|
|
650
|
+
})
|
|
651
|
+
);
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
### catch - Handle All Errors
|
|
655
|
+
|
|
656
|
+
`Effect.catch` (renamed from `catchAll` in v3) handles all errors with a single handler:
|
|
657
|
+
|
|
658
|
+
```typescript
|
|
659
|
+
import * as Effect from 'effect/Effect';
|
|
660
|
+
import * as Schema from 'effect/Schema';
|
|
661
|
+
|
|
662
|
+
declare const getDefaultResult: () => Result;
|
|
663
|
+
|
|
664
|
+
interface Result {
|
|
665
|
+
readonly value: string;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
class InvalidInput extends Schema.TaggedError<InvalidInput>()(
|
|
669
|
+
'InvalidInput',
|
|
670
|
+
{
|
|
671
|
+
message: Schema.String
|
|
672
|
+
}
|
|
673
|
+
) {}
|
|
674
|
+
|
|
675
|
+
class ProcessingError extends Schema.TaggedError<ProcessingError>()(
|
|
676
|
+
'ProcessingError',
|
|
677
|
+
{
|
|
678
|
+
message: Schema.String
|
|
679
|
+
}
|
|
680
|
+
) {}
|
|
681
|
+
|
|
682
|
+
const process = (): Effect.Effect<Result, InvalidInput | ProcessingError> =>
|
|
683
|
+
Effect.fail(new InvalidInput({ message: 'Bad input' }));
|
|
684
|
+
|
|
685
|
+
const program = process().pipe(
|
|
686
|
+
Effect.catch((error) =>
|
|
687
|
+
// error is typed as: InvalidInput | ProcessingError
|
|
688
|
+
Effect.logError(`Operation failed: ${error._tag}`).pipe(
|
|
689
|
+
Effect.as(getDefaultResult())
|
|
690
|
+
)
|
|
691
|
+
)
|
|
692
|
+
);
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
**Migration note:** When replacing `Effect.promise(() => Service.method())` with direct service yields (`yield* service.method()`), errors that previously flowed as defects (untyped Promise rejections) become typed channel errors. Existing `catchDefect` handlers must be replaced with `catch` or `catchTag` to match the now-typed error channel.
|
|
696
|
+
|
|
697
|
+
### catchEager - Synchronous Recovery (v4)
|
|
698
|
+
|
|
699
|
+
`Effect.catchEager` is an optimization of `catch` that evaluates synchronous recovery effects immediately rather than suspending. Use for lightweight wrapping where the recovery handler is always synchronous:
|
|
700
|
+
|
|
701
|
+
```typescript
|
|
702
|
+
import { Effect, Schema } from 'effect';
|
|
703
|
+
|
|
704
|
+
class CliConfigError extends Schema.TaggedError<CliConfigError>()(
|
|
705
|
+
'CliConfigError',
|
|
706
|
+
{
|
|
707
|
+
message: Schema.String
|
|
708
|
+
}
|
|
709
|
+
) {}
|
|
710
|
+
|
|
711
|
+
const loadCredentials = Effect.tryPromise({
|
|
712
|
+
try: () => readFile('~/.config/myapp/credentials.json'),
|
|
713
|
+
catch: (cause) => cause
|
|
714
|
+
}).pipe(
|
|
715
|
+
Effect.catchEager((cause) =>
|
|
716
|
+
Effect.fail(
|
|
717
|
+
new CliConfigError({
|
|
718
|
+
message: `Failed to load credentials: ${cause}`
|
|
719
|
+
})
|
|
720
|
+
)
|
|
721
|
+
)
|
|
722
|
+
);
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
### catchNoSuchElement - Convert NoSuchElementError to Option (v4)
|
|
726
|
+
|
|
727
|
+
`Effect.catchNoSuchElement` (renamed from `optionFromOptional` in v3) catches `Cause.NoSuchElementError` and converts the result to `Option`:
|
|
728
|
+
|
|
729
|
+
```typescript
|
|
730
|
+
import { Effect } from 'effect';
|
|
731
|
+
import * as Option from 'effect/Option';
|
|
732
|
+
|
|
733
|
+
declare const maybeFindItem: () => Effect.Effect<
|
|
734
|
+
string,
|
|
735
|
+
Cause.NoSuchElementError
|
|
736
|
+
>;
|
|
737
|
+
|
|
738
|
+
// Effect<Option<string>, never>
|
|
739
|
+
const program = Effect.catchNoSuchElement(maybeFindItem());
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
## Handling Nested Error Reasons (v4)
|
|
743
|
+
|
|
744
|
+
When errors use a `reason` field containing a tagged union (see "Error with Nested Reason Types" above), v4 provides three purpose-built APIs.
|
|
745
|
+
|
|
746
|
+
### catchReason - Catch One Specific Reason
|
|
747
|
+
|
|
748
|
+
Catches a specific `reason` variant within a tagged error without removing the parent error from the error channel:
|
|
749
|
+
|
|
750
|
+
```typescript
|
|
751
|
+
import { Effect } from 'effect';
|
|
752
|
+
|
|
753
|
+
declare const callModel: Effect.Effect<string, AiError>;
|
|
754
|
+
|
|
755
|
+
// Catch only RateLimitError reason within AiError
|
|
756
|
+
const program = callModel.pipe(
|
|
757
|
+
Effect.catchReason(
|
|
758
|
+
'AiError', // parent error _tag
|
|
759
|
+
'RateLimitError', // reason _tag to catch
|
|
760
|
+
(reason) => Effect.succeed(`Retry after ${reason.retryAfter} seconds`)
|
|
761
|
+
)
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
// With optional orElse for uncaught reasons
|
|
765
|
+
const withFallback = callModel.pipe(
|
|
766
|
+
Effect.catchReason(
|
|
767
|
+
'AiError',
|
|
768
|
+
'RateLimitError',
|
|
769
|
+
(reason) => Effect.succeed(`Retry after ${reason.retryAfter} seconds`),
|
|
770
|
+
(reason) => Effect.succeed(`Model call failed: ${reason._tag}`) // QuotaExceeded | SafetyBlocked
|
|
771
|
+
)
|
|
772
|
+
);
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
### catchReasons - Catch Multiple Reasons
|
|
776
|
+
|
|
777
|
+
Handle multiple reason variants at once via an object of handlers:
|
|
778
|
+
|
|
779
|
+
```typescript
|
|
780
|
+
import { Effect } from 'effect';
|
|
781
|
+
|
|
782
|
+
declare const callModel: Effect.Effect<string, AiError>;
|
|
783
|
+
|
|
784
|
+
const program = callModel.pipe(
|
|
785
|
+
Effect.catchReasons('AiError', {
|
|
786
|
+
RateLimitError: (reason) =>
|
|
787
|
+
Effect.succeed(`Retry after ${reason.retryAfter} seconds`),
|
|
788
|
+
QuotaExceededError: (reason) =>
|
|
789
|
+
Effect.succeed(`Quota exceeded at ${reason.limit} tokens`)
|
|
790
|
+
})
|
|
791
|
+
// SafetyBlockedError remains unhandled in the error channel
|
|
792
|
+
);
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
### unwrapReason - Promote Reasons to Error Channel
|
|
796
|
+
|
|
797
|
+
Unwraps the `reason` field, replacing the parent error with its reason variants in the error channel. Useful when you want to `catchTags` the individual reasons directly:
|
|
798
|
+
|
|
799
|
+
```typescript
|
|
800
|
+
import { Effect } from 'effect';
|
|
801
|
+
|
|
802
|
+
declare const callModel: Effect.Effect<string, AiError>;
|
|
803
|
+
|
|
804
|
+
const program = callModel.pipe(
|
|
805
|
+
Effect.unwrapReason('AiError'),
|
|
806
|
+
// Error channel is now: RateLimitError | QuotaExceededError | SafetyBlockedError
|
|
807
|
+
Effect.catchTags({
|
|
808
|
+
RateLimitError: (r) => Effect.succeed(`Back off for ${r.retryAfter}s`),
|
|
809
|
+
QuotaExceededError: (r) =>
|
|
810
|
+
Effect.succeed(`Increase quota beyond ${r.limit}`),
|
|
811
|
+
SafetyBlockedError: (r) => Effect.succeed(`Blocked: ${r.category}`)
|
|
812
|
+
})
|
|
813
|
+
);
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
## Catching by Filter (v4)
|
|
817
|
+
|
|
818
|
+
`Effect.catchFilter` replaces v3's `Effect.catchSome` (which used `Option`). It uses the `Filter` module:
|
|
819
|
+
|
|
820
|
+
```typescript
|
|
821
|
+
import { Effect, Filter } from 'effect';
|
|
822
|
+
|
|
823
|
+
// v3 (DO NOT USE):
|
|
824
|
+
// Effect.catchSome((error) =>
|
|
825
|
+
// error === 42 ? Option.some(Effect.succeed("caught")) : Option.none()
|
|
826
|
+
// )
|
|
827
|
+
|
|
828
|
+
// v4:
|
|
829
|
+
const program = Effect.fail(42).pipe(
|
|
830
|
+
Effect.catchFilter(
|
|
831
|
+
Filter.fromPredicate((error: number) => error === 42),
|
|
832
|
+
(error) => Effect.succeed('caught')
|
|
833
|
+
)
|
|
834
|
+
);
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
`Effect.catchCauseFilter` is the Cause-level equivalent (replaces `catchSomeCause`).
|
|
838
|
+
|
|
839
|
+
## Cause Structure (v4)
|
|
840
|
+
|
|
841
|
+
In v4, `Cause<E>` is a flat wrapper around an array of reasons — **not** a recursive tree.
|
|
842
|
+
|
|
843
|
+
```typescript
|
|
844
|
+
interface Cause<E> {
|
|
845
|
+
readonly reasons: ReadonlyArray<Reason<E>>;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
type Reason<E> = Fail<E> | Die | Interrupt;
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
There are only three reason variants:
|
|
852
|
+
|
|
853
|
+
- `Fail<E>` — `{ readonly error: E }` — expected typed failures
|
|
854
|
+
- `Die` — `{ readonly defect: unknown }` — unexpected defects
|
|
855
|
+
- `Interrupt` — `{ readonly fiberId: number | undefined }` — fiber interruptions
|
|
856
|
+
|
|
857
|
+
An empty cause is `cause.reasons.length === 0`. The `Empty`, `Sequential`, and `Parallel` variants from v3 no longer exist.
|
|
858
|
+
|
|
859
|
+
### Inspecting Causes
|
|
860
|
+
|
|
861
|
+
```typescript
|
|
862
|
+
import { Cause } from 'effect';
|
|
863
|
+
|
|
864
|
+
const inspectCause = <E>(cause: Cause.Cause<E>) => {
|
|
865
|
+
// Iterate over the flat reasons array
|
|
866
|
+
for (const reason of cause.reasons) {
|
|
867
|
+
if (Cause.isFailReason(reason)) {
|
|
868
|
+
console.log('Expected error:', reason.error);
|
|
869
|
+
} else if (Cause.isDieReason(reason)) {
|
|
870
|
+
console.log('Defect:', reason.defect);
|
|
871
|
+
} else if (Cause.isInterruptReason(reason)) {
|
|
872
|
+
console.log('Interrupted by fiber:', reason.fiberId);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
### Cause Extractors
|
|
879
|
+
|
|
880
|
+
```typescript
|
|
881
|
+
import { Cause } from 'effect';
|
|
882
|
+
import * as Option from 'effect/Option';
|
|
883
|
+
|
|
884
|
+
declare const cause: Cause.Cause<string>;
|
|
885
|
+
|
|
886
|
+
// Extract first error as Option
|
|
887
|
+
const errorOpt: Option.Option<string> = Cause.findErrorOption(cause);
|
|
888
|
+
|
|
889
|
+
// Extract first error as Result (Result.Result<E, Cause<never>>)
|
|
890
|
+
const errorResult = Cause.findError(cause);
|
|
891
|
+
|
|
892
|
+
// Extract first defect as Result
|
|
893
|
+
const defectResult = Cause.findDefect(cause);
|
|
894
|
+
|
|
895
|
+
// Check what a cause contains
|
|
896
|
+
Cause.hasFails(cause); // has at least one Fail reason
|
|
897
|
+
Cause.hasDies(cause); // has at least one Die reason
|
|
898
|
+
Cause.hasInterrupts(cause); // has at least one Interrupt reason
|
|
899
|
+
Cause.hasInterruptsOnly(cause); // only Interrupt reasons, no Fail/Die
|
|
900
|
+
|
|
901
|
+
// Human-readable rendering
|
|
902
|
+
const pretty: string = Cause.pretty(cause);
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
### Cause Constructors (v4)
|
|
906
|
+
|
|
907
|
+
```typescript
|
|
908
|
+
import { Cause } from 'effect';
|
|
909
|
+
|
|
910
|
+
Cause.empty; // empty cause (no reasons)
|
|
911
|
+
Cause.fail(error); // single Fail reason
|
|
912
|
+
Cause.die(defect); // single Die reason
|
|
913
|
+
Cause.interrupt(fiberId); // single Interrupt reason
|
|
914
|
+
Cause.combine(left, right); // concatenate two causes' reasons
|
|
915
|
+
Cause.fromReasons(reasons); // construct from array of Reason values
|
|
916
|
+
Cause.makeFailReason(error); // construct a Fail reason
|
|
917
|
+
Cause.makeDieReason(defect); // construct a Die reason
|
|
918
|
+
Cause.makeInterruptReason(fiberId); // construct an Interrupt reason
|
|
919
|
+
Cause.annotate(cause, annotations); // attach metadata
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
### Cause.Done - Graceful Completion Signal (v4)
|
|
923
|
+
|
|
924
|
+
`Cause.Done<A>` is a graceful completion value used by queues, pulls, and streams. It travels through the typed error channel, but consumers interpret it as successful end-of-input rather than an operational failure; its `value` can carry a final leftover payload.
|
|
925
|
+
|
|
926
|
+
```typescript
|
|
927
|
+
import { Cause } from 'effect';
|
|
928
|
+
|
|
929
|
+
Cause.Done(); // create a Done<void> signal
|
|
930
|
+
Cause.Done('leftover'); // create Done<string> with a final payload
|
|
931
|
+
Cause.done('leftover'); // Effect<never, Cause.Done<string>>
|
|
932
|
+
Cause.isDone(value); // type guard
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
A `Fail` reason carrying `Done` can be combined with a real failure, for example when stream completion and resource finalization both settle unsuccessfully. Pull/stream completion handlers remove the `Done` signal but preserve any other merged failure reasons; do not treat the presence of `Done` as permission to discard the whole cause.
|
|
936
|
+
|
|
937
|
+
## Exhaustive Error Handling with Match
|
|
938
|
+
|
|
939
|
+
Use Match for exhaustive error handling with compile-time guarantees:
|
|
940
|
+
|
|
941
|
+
```typescript
|
|
942
|
+
import * as Effect from 'effect/Effect';
|
|
943
|
+
import * as Match from 'effect/Match';
|
|
944
|
+
import * as Schema from 'effect/Schema';
|
|
945
|
+
|
|
946
|
+
declare const dangerousOperation: () => Effect.Effect<string, AppError>;
|
|
947
|
+
|
|
948
|
+
class ConnectionError extends Schema.TaggedError<ConnectionError>()(
|
|
949
|
+
'ConnectionError',
|
|
950
|
+
{
|
|
951
|
+
message: Schema.String
|
|
952
|
+
}
|
|
953
|
+
) {}
|
|
954
|
+
|
|
955
|
+
class AuthError extends Schema.TaggedError<AuthError>()('AuthError', {
|
|
956
|
+
message: Schema.String
|
|
957
|
+
}) {}
|
|
958
|
+
|
|
959
|
+
class DataError extends Schema.TaggedError<DataError>()('DataError', {
|
|
960
|
+
message: Schema.String
|
|
961
|
+
}) {}
|
|
962
|
+
|
|
963
|
+
type AppError = ConnectionError | AuthError | DataError;
|
|
964
|
+
|
|
965
|
+
const handleError = (error: AppError): Effect.Effect<string> =>
|
|
966
|
+
Match.value(error).pipe(
|
|
967
|
+
Match.tag('ConnectionError', () =>
|
|
968
|
+
Effect.succeed('Please check your network connection')
|
|
969
|
+
),
|
|
970
|
+
Match.tag('AuthError', () => Effect.succeed('Authentication required')),
|
|
971
|
+
Match.tag('DataError', (err) =>
|
|
972
|
+
Effect.succeed(`Data error: ${err.message}`)
|
|
973
|
+
),
|
|
974
|
+
Match.exhaustive // Compiler ensures all cases handled
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
const program = dangerousOperation().pipe(Effect.catch(handleError));
|
|
978
|
+
```
|
|
979
|
+
|
|
980
|
+
## Error Transformation
|
|
981
|
+
|
|
982
|
+
### mapError - Transform Error Type
|
|
983
|
+
|
|
984
|
+
```typescript
|
|
985
|
+
import * as Effect from 'effect/Effect';
|
|
986
|
+
import * as Schema from 'effect/Schema';
|
|
987
|
+
|
|
988
|
+
declare const fetchFromDatabase: () => Effect.Effect<Data, InfrastructureError>;
|
|
989
|
+
|
|
990
|
+
interface Data {
|
|
991
|
+
readonly value: string;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
class DomainError extends Schema.TaggedError<DomainError>()(
|
|
995
|
+
'DomainError',
|
|
996
|
+
{
|
|
997
|
+
message: Schema.String
|
|
998
|
+
}
|
|
999
|
+
) {}
|
|
1000
|
+
|
|
1001
|
+
class InfrastructureError extends Schema.TaggedError<InfrastructureError>()(
|
|
1002
|
+
'InfrastructureError',
|
|
1003
|
+
{ message: Schema.String, cause: Schema.optional(Schema.Unknown) }
|
|
1004
|
+
) {}
|
|
1005
|
+
|
|
1006
|
+
// Transform infrastructure errors to domain errors
|
|
1007
|
+
const program = fetchFromDatabase().pipe(
|
|
1008
|
+
Effect.mapError(
|
|
1009
|
+
(infraError: InfrastructureError) =>
|
|
1010
|
+
new DomainError({
|
|
1011
|
+
message: `Database operation failed: ${infraError.message}`
|
|
1012
|
+
})
|
|
1013
|
+
)
|
|
1014
|
+
);
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
### Idempotent Error Wrapping
|
|
1018
|
+
|
|
1019
|
+
When writing reusable error-mapping combinators shared across multiple call sites, guard against double-wrapping:
|
|
1020
|
+
|
|
1021
|
+
```typescript
|
|
1022
|
+
import { Effect, Schema } from 'effect';
|
|
1023
|
+
|
|
1024
|
+
class ServiceError extends Schema.TaggedError<ServiceError>()(
|
|
1025
|
+
'ServiceError',
|
|
1026
|
+
{
|
|
1027
|
+
message: Schema.String,
|
|
1028
|
+
cause: Schema.optional(Schema.Unknown)
|
|
1029
|
+
}
|
|
1030
|
+
) {}
|
|
1031
|
+
|
|
1032
|
+
const mapServiceError =
|
|
1033
|
+
(message = 'Service operation failed') =>
|
|
1034
|
+
<A, E, R>(
|
|
1035
|
+
effect: Effect.Effect<A, E, R>
|
|
1036
|
+
): Effect.Effect<A, ServiceError, R> =>
|
|
1037
|
+
effect.pipe(
|
|
1038
|
+
Effect.mapError((cause) =>
|
|
1039
|
+
cause instanceof ServiceError
|
|
1040
|
+
? cause
|
|
1041
|
+
: new ServiceError({ message, cause })
|
|
1042
|
+
)
|
|
1043
|
+
);
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
The `instanceof` guard prevents double-wrapping when an upstream operation already returns the target error type.
|
|
1047
|
+
|
|
1048
|
+
## Error Recovery Patterns
|
|
1049
|
+
|
|
1050
|
+
Retry timing and recurrence design belong in the dedicated effect-scheduling skill. This skill determines which failures are typed and recoverable; scheduling determines whether, when, and how often an idempotent operation is retried.
|
|
1051
|
+
|
|
1052
|
+
### Translate at Service Boundaries
|
|
1053
|
+
|
|
1054
|
+
Translate infrastructure errors into the service's public error vocabulary at the boundary that owns the abstraction, using `mapError`, `catchTag`, or `catchTags`. Preserve useful context in the translated error and avoid repeatedly wrapping an error already in the target vocabulary.
|
|
1055
|
+
|
|
1056
|
+
Typed error combinators preserve defects and interruption. Keep that property: do not use blanket `catchCause`, `ignoreCause`, or cause-to-domain-error conversion in ordinary services, because they can turn cancellation into a recoverable failure. Cause-level recovery belongs only at an explicit supervision/runtime boundary; detect and re-propagate interruption rather than logging it as an operational error and continuing.
|
|
1057
|
+
|
|
1058
|
+
### Fallback with orElse
|
|
1059
|
+
|
|
1060
|
+
```typescript
|
|
1061
|
+
import * as Effect from 'effect/Effect';
|
|
1062
|
+
import * as Schema from 'effect/Schema';
|
|
1063
|
+
|
|
1064
|
+
interface Data {
|
|
1065
|
+
readonly value: string;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
class PrimaryServiceError extends Schema.TaggedError<PrimaryServiceError>()(
|
|
1069
|
+
'PrimaryServiceError',
|
|
1070
|
+
{ message: Schema.String }
|
|
1071
|
+
) {}
|
|
1072
|
+
|
|
1073
|
+
class SecondaryServiceError extends Schema.TaggedError<SecondaryServiceError>()(
|
|
1074
|
+
'SecondaryServiceError',
|
|
1075
|
+
{ message: Schema.String }
|
|
1076
|
+
) {}
|
|
1077
|
+
|
|
1078
|
+
const primaryService: Effect.Effect<Data, PrimaryServiceError> = Effect.fail(
|
|
1079
|
+
new PrimaryServiceError({ message: 'Primary down' })
|
|
1080
|
+
);
|
|
1081
|
+
const secondaryService: Effect.Effect<Data, SecondaryServiceError> =
|
|
1082
|
+
Effect.fail(new SecondaryServiceError({ message: 'Secondary down' }));
|
|
1083
|
+
|
|
1084
|
+
// Try primary, fallback to secondary
|
|
1085
|
+
// Effect<Data, SecondaryServiceError, Dependencies>
|
|
1086
|
+
const program = primaryService.pipe(Effect.orElse(() => secondaryService));
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
### Retry with Schedule
|
|
1090
|
+
|
|
1091
|
+
For policy selection, bounds, backoff, jitter, rate-limit delays, polling, and idempotency requirements, see the effect-scheduling skill.
|
|
1092
|
+
|
|
1093
|
+
```typescript
|
|
1094
|
+
import * as Effect from 'effect/Effect';
|
|
1095
|
+
import * as Schedule from 'effect/Schedule';
|
|
1096
|
+
import * as Schema from 'effect/Schema';
|
|
1097
|
+
|
|
1098
|
+
interface Data {
|
|
1099
|
+
readonly value: string;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
class TransientError extends Schema.TaggedError<TransientError>()(
|
|
1103
|
+
'TransientError',
|
|
1104
|
+
{
|
|
1105
|
+
message: Schema.String
|
|
1106
|
+
}
|
|
1107
|
+
) {}
|
|
1108
|
+
|
|
1109
|
+
const unreliableOperation: Effect.Effect<Data, TransientError> = Effect.fail(
|
|
1110
|
+
new TransientError({ message: 'Temporary failure' })
|
|
1111
|
+
);
|
|
1112
|
+
|
|
1113
|
+
// Retry with exponential backoff
|
|
1114
|
+
const program = unreliableOperation.pipe(
|
|
1115
|
+
Effect.retry(
|
|
1116
|
+
Schedule.exponential('100 millis').pipe(
|
|
1117
|
+
Schedule.upTo({ times: 5 })
|
|
1118
|
+
) // Max 5 retries
|
|
1119
|
+
)
|
|
1120
|
+
);
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
### Provide Default Value
|
|
1124
|
+
|
|
1125
|
+
```typescript
|
|
1126
|
+
import * as Effect from 'effect/Effect';
|
|
1127
|
+
import * as Schema from 'effect/Schema';
|
|
1128
|
+
|
|
1129
|
+
declare const getDefaultConfig: () => Config;
|
|
1130
|
+
|
|
1131
|
+
interface Config {
|
|
1132
|
+
readonly port: number;
|
|
1133
|
+
readonly host: string;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
class FetchError extends Schema.TaggedError<FetchError>()('FetchError', {
|
|
1137
|
+
message: Schema.String
|
|
1138
|
+
}) {}
|
|
1139
|
+
|
|
1140
|
+
const fetchConfig: Effect.Effect<Config, FetchError> = Effect.fail(
|
|
1141
|
+
new FetchError({ message: 'Config not available' })
|
|
1142
|
+
);
|
|
1143
|
+
|
|
1144
|
+
// Provide default on failure
|
|
1145
|
+
const program = fetchConfig.pipe(
|
|
1146
|
+
Effect.orElseSucceed(() => getDefaultConfig())
|
|
1147
|
+
);
|
|
1148
|
+
```
|
|
1149
|
+
|
|
1150
|
+
### Convert Error to Option
|
|
1151
|
+
|
|
1152
|
+
```typescript
|
|
1153
|
+
import * as Effect from 'effect/Effect';
|
|
1154
|
+
import * as Schema from 'effect/Schema';
|
|
1155
|
+
|
|
1156
|
+
interface Item {
|
|
1157
|
+
readonly id: string;
|
|
1158
|
+
readonly name: string;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
class NotFoundError extends Schema.TaggedError<NotFoundError>()(
|
|
1162
|
+
'NotFoundError',
|
|
1163
|
+
{
|
|
1164
|
+
message: Schema.String
|
|
1165
|
+
}
|
|
1166
|
+
) {}
|
|
1167
|
+
|
|
1168
|
+
const findItem: Effect.Effect<Item, NotFoundError> = Effect.fail(
|
|
1169
|
+
new NotFoundError({ message: 'Not found' })
|
|
1170
|
+
);
|
|
1171
|
+
|
|
1172
|
+
// Convert to Option (None if error)
|
|
1173
|
+
// Effect<Option<Item>, never, Dependencies>
|
|
1174
|
+
const program = findItem.pipe(Effect.option);
|
|
1175
|
+
```
|
|
1176
|
+
|
|
1177
|
+
## Error Channel vs Defect Operators
|
|
1178
|
+
|
|
1179
|
+
### Converting Errors to Defects
|
|
1180
|
+
|
|
1181
|
+
```typescript
|
|
1182
|
+
import * as Effect from 'effect/Effect';
|
|
1183
|
+
import * as Schema from 'effect/Schema';
|
|
1184
|
+
|
|
1185
|
+
interface Config {
|
|
1186
|
+
readonly port: number;
|
|
1187
|
+
readonly host: string;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
class ConfigError extends Schema.TaggedError<ConfigError>()(
|
|
1191
|
+
'ConfigError',
|
|
1192
|
+
{
|
|
1193
|
+
message: Schema.String
|
|
1194
|
+
}
|
|
1195
|
+
) {}
|
|
1196
|
+
|
|
1197
|
+
const loadConfig: Effect.Effect<Config, ConfigError> = Effect.fail(
|
|
1198
|
+
new ConfigError({ message: 'Missing config' })
|
|
1199
|
+
);
|
|
1200
|
+
|
|
1201
|
+
// Convert error to defect (terminates fiber)
|
|
1202
|
+
const program = loadConfig.pipe(
|
|
1203
|
+
Effect.orDie // Error becomes a defect
|
|
1204
|
+
);
|
|
1205
|
+
|
|
1206
|
+
// With custom defect message
|
|
1207
|
+
const program2 = loadConfig.pipe(
|
|
1208
|
+
Effect.orDieWith(
|
|
1209
|
+
(error) =>
|
|
1210
|
+
new Error(`Fatal: Configuration failed to load: ${error._tag}`)
|
|
1211
|
+
)
|
|
1212
|
+
);
|
|
1213
|
+
```
|
|
1214
|
+
|
|
1215
|
+
### Handling Defects (Boundary Only)
|
|
1216
|
+
|
|
1217
|
+
```typescript
|
|
1218
|
+
import * as Effect from 'effect/Effect';
|
|
1219
|
+
|
|
1220
|
+
declare const dangerousPlugin: () => Effect.Effect<unknown>;
|
|
1221
|
+
declare const getDefaultPluginBehavior: () => unknown;
|
|
1222
|
+
|
|
1223
|
+
// NOTE: ONLY use at application boundaries
|
|
1224
|
+
const safeProgram = dangerousPlugin().pipe(
|
|
1225
|
+
Effect.catchDefect((defect) =>
|
|
1226
|
+
Effect.logError(`Plugin crashed: ${defect}`).pipe(
|
|
1227
|
+
Effect.as(getDefaultPluginBehavior())
|
|
1228
|
+
)
|
|
1229
|
+
)
|
|
1230
|
+
);
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
## ErrorReporter (v4)
|
|
1234
|
+
|
|
1235
|
+
The `ErrorReporter` module is new in v4. It provides pluggable, structured error reporting with severity levels and metadata.
|
|
1236
|
+
|
|
1237
|
+
### Defining a Reporter
|
|
1238
|
+
|
|
1239
|
+
```typescript
|
|
1240
|
+
import { ErrorReporter } from 'effect';
|
|
1241
|
+
|
|
1242
|
+
// Create a custom reporter — the callback receives a single options object
|
|
1243
|
+
const myReporter = ErrorReporter.make(({ cause, error, severity, attributes }) => {
|
|
1244
|
+
console.error(`[${severity}]`, error.message, attributes);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
// Register reporters via Layer
|
|
1248
|
+
const ReporterLayer = ErrorReporter.layer([myReporter]);
|
|
1249
|
+
```
|
|
1250
|
+
|
|
1251
|
+
### Reporting Errors
|
|
1252
|
+
|
|
1253
|
+
```typescript
|
|
1254
|
+
import { Effect, ErrorReporter } from 'effect';
|
|
1255
|
+
|
|
1256
|
+
// Automatically report errors from an effect
|
|
1257
|
+
const program = riskyOperation.pipe(Effect.withErrorReporting);
|
|
1258
|
+
|
|
1259
|
+
// Or, to report defects only:
|
|
1260
|
+
const defectsOnly = riskyOperation.pipe(
|
|
1261
|
+
Effect.withErrorReporting({ defectsOnly: true })
|
|
1262
|
+
);
|
|
1263
|
+
```
|
|
1264
|
+
|
|
1265
|
+
### Per-Error Annotations
|
|
1266
|
+
|
|
1267
|
+
Error objects can carry reporting annotations as string-keyed properties (the keys are namespaced strings such as `"~effect/ErrorReporter/severity"`):
|
|
1268
|
+
|
|
1269
|
+
```typescript
|
|
1270
|
+
import { ErrorReporter, Schema } from 'effect';
|
|
1271
|
+
|
|
1272
|
+
class MyError extends Schema.TaggedError<MyError>()('MyError', {
|
|
1273
|
+
message: Schema.String
|
|
1274
|
+
}) {}
|
|
1275
|
+
|
|
1276
|
+
const error = new MyError({ message: 'something went wrong' });
|
|
1277
|
+
|
|
1278
|
+
// Mark an error to be ignored by reporters
|
|
1279
|
+
ErrorReporter.ignore; // string key — set to true to skip reporting
|
|
1280
|
+
|
|
1281
|
+
// Override severity (defaults to "Info" when unset or invalid)
|
|
1282
|
+
ErrorReporter.severity; // string key — "Trace" | "Debug" | "Info" | "Warn" | "Error" | "Fatal"
|
|
1283
|
+
|
|
1284
|
+
// Attach extra structured metadata
|
|
1285
|
+
ErrorReporter.attributes; // string key — Record<string, unknown>
|
|
1286
|
+
|
|
1287
|
+
// Guards
|
|
1288
|
+
ErrorReporter.isIgnored(error); // check if ignored
|
|
1289
|
+
ErrorReporter.getSeverity(error); // read severity
|
|
1290
|
+
ErrorReporter.getAttributes(error); // read attributes
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
## Layered Error Handling
|
|
1294
|
+
|
|
1295
|
+
Structure error handling in layers from specific to general:
|
|
1296
|
+
|
|
1297
|
+
```typescript
|
|
1298
|
+
import * as Effect from 'effect/Effect';
|
|
1299
|
+
import * as Schema from 'effect/Schema';
|
|
1300
|
+
|
|
1301
|
+
declare const validateUserData: (
|
|
1302
|
+
data: UserData
|
|
1303
|
+
) => Effect.Effect<ValidatedUserData, ValidationError>;
|
|
1304
|
+
declare const saveToDatabase: (
|
|
1305
|
+
data: ValidatedUserData
|
|
1306
|
+
) => Effect.Effect<string, DatabaseError>;
|
|
1307
|
+
declare const notifyUserCreated: (
|
|
1308
|
+
userId: string
|
|
1309
|
+
) => Effect.Effect<void, NetworkError>;
|
|
1310
|
+
|
|
1311
|
+
interface UserData {
|
|
1312
|
+
readonly name: string;
|
|
1313
|
+
readonly email: string;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
interface ValidatedUserData {
|
|
1317
|
+
readonly name: string;
|
|
1318
|
+
readonly email: string;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
class ValidationError extends Schema.TaggedError<ValidationError>()(
|
|
1322
|
+
'ValidationError',
|
|
1323
|
+
{
|
|
1324
|
+
message: Schema.String
|
|
1325
|
+
}
|
|
1326
|
+
) {}
|
|
1327
|
+
|
|
1328
|
+
class DatabaseError extends Schema.TaggedError<DatabaseError>()(
|
|
1329
|
+
'DatabaseError',
|
|
1330
|
+
{
|
|
1331
|
+
message: Schema.String
|
|
1332
|
+
}
|
|
1333
|
+
) {}
|
|
1334
|
+
|
|
1335
|
+
class NetworkError extends Schema.TaggedError<NetworkError>()(
|
|
1336
|
+
'NetworkError',
|
|
1337
|
+
{
|
|
1338
|
+
message: Schema.String
|
|
1339
|
+
}
|
|
1340
|
+
) {}
|
|
1341
|
+
|
|
1342
|
+
class UnknownError extends Schema.TaggedError<UnknownError>()(
|
|
1343
|
+
'UnknownError',
|
|
1344
|
+
{
|
|
1345
|
+
message: Schema.String,
|
|
1346
|
+
cause: Schema.optional(Schema.Unknown)
|
|
1347
|
+
}
|
|
1348
|
+
) {}
|
|
1349
|
+
|
|
1350
|
+
const createUser = (data: UserData) =>
|
|
1351
|
+
Effect.gen(function* () {
|
|
1352
|
+
// Layer 1: Validate input
|
|
1353
|
+
const validated = yield* validateUserData(data).pipe(
|
|
1354
|
+
Effect.catchTag('ValidationError', (error) =>
|
|
1355
|
+
Effect.fail(
|
|
1356
|
+
new UnknownError({ message: error.message, cause: error })
|
|
1357
|
+
)
|
|
1358
|
+
)
|
|
1359
|
+
);
|
|
1360
|
+
|
|
1361
|
+
// Layer 2: Database operation
|
|
1362
|
+
const userId = yield* saveToDatabase(validated).pipe(
|
|
1363
|
+
Effect.catchTag('DatabaseError', (error) =>
|
|
1364
|
+
Effect.fail(
|
|
1365
|
+
new UnknownError({ message: error.message, cause: error })
|
|
1366
|
+
)
|
|
1367
|
+
)
|
|
1368
|
+
);
|
|
1369
|
+
|
|
1370
|
+
// Layer 3: Network notification
|
|
1371
|
+
yield* notifyUserCreated(userId).pipe(
|
|
1372
|
+
Effect.catchTag('NetworkError', (error) =>
|
|
1373
|
+
// Non-critical: log but don't fail
|
|
1374
|
+
Effect.logWarning(`Failed to notify: ${error._tag}`)
|
|
1375
|
+
)
|
|
1376
|
+
);
|
|
1377
|
+
|
|
1378
|
+
return userId;
|
|
1379
|
+
});
|
|
1380
|
+
```
|
|
1381
|
+
|
|
1382
|
+
## Domain-Specific Error Patterns
|
|
1383
|
+
|
|
1384
|
+
### HTTP Response Discrimination
|
|
1385
|
+
|
|
1386
|
+
Model ambiguous HTTP responses (where the body structure differs for success vs error) as a `Schema.Union` of `Schema.Class` types:
|
|
1387
|
+
|
|
1388
|
+
```typescript
|
|
1389
|
+
import { Effect, Schema } from 'effect';
|
|
1390
|
+
import { HttpClientResponse } from 'effect/unstable/HttpClient';
|
|
1391
|
+
|
|
1392
|
+
class TokenSuccess extends Schema.Class<TokenSuccess>('TokenSuccess')({
|
|
1393
|
+
access_token: AccessToken,
|
|
1394
|
+
expires_in: Schema.Number
|
|
1395
|
+
}) {}
|
|
1396
|
+
|
|
1397
|
+
class TokenError extends Schema.Class<TokenError>('TokenError')({
|
|
1398
|
+
error: Schema.String,
|
|
1399
|
+
error_description: Schema.optional(Schema.String)
|
|
1400
|
+
}) {}
|
|
1401
|
+
|
|
1402
|
+
const TokenResponse = Schema.Union([TokenSuccess, TokenError]);
|
|
1403
|
+
|
|
1404
|
+
// Decode and discriminate
|
|
1405
|
+
const response = yield* HttpClientResponse.schemaBodyJson(TokenResponse)(res);
|
|
1406
|
+
if (response instanceof TokenError) {
|
|
1407
|
+
return (
|
|
1408
|
+
yield*
|
|
1409
|
+
new AuthError({
|
|
1410
|
+
message: response.error_description ?? response.error
|
|
1411
|
+
})
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
```
|
|
1415
|
+
|
|
1416
|
+
This replaces ad-hoc optional-field checking (`if (!body.access_token)`) with compile-time-safe discrimination via `instanceof`.
|
|
1417
|
+
|
|
1418
|
+
### Repository Errors
|
|
1419
|
+
|
|
1420
|
+
```typescript
|
|
1421
|
+
import * as Schema from 'effect/Schema';
|
|
1422
|
+
|
|
1423
|
+
export class EntityNotFound extends Schema.TaggedError<EntityNotFound>()(
|
|
1424
|
+
'EntityNotFound',
|
|
1425
|
+
{
|
|
1426
|
+
entityType: Schema.String,
|
|
1427
|
+
id: Schema.String,
|
|
1428
|
+
message: Schema.String
|
|
1429
|
+
},
|
|
1430
|
+
{ httpApiStatus: 404, description: 'Requested entity does not exist.' }
|
|
1431
|
+
) {}
|
|
1432
|
+
|
|
1433
|
+
export class DuplicateEntity extends Schema.TaggedError<DuplicateEntity>()(
|
|
1434
|
+
'DuplicateEntity',
|
|
1435
|
+
{
|
|
1436
|
+
entityType: Schema.String,
|
|
1437
|
+
id: Schema.String,
|
|
1438
|
+
message: Schema.String
|
|
1439
|
+
},
|
|
1440
|
+
{ httpApiStatus: 409, description: 'Entity already exists.' }
|
|
1441
|
+
) {}
|
|
1442
|
+
|
|
1443
|
+
export class QueryError extends Schema.TaggedError<QueryError>()(
|
|
1444
|
+
'QueryError',
|
|
1445
|
+
{
|
|
1446
|
+
query: Schema.String,
|
|
1447
|
+
message: Schema.String,
|
|
1448
|
+
cause: Schema.optional(Schema.Unknown)
|
|
1449
|
+
},
|
|
1450
|
+
{ description: 'Database query failed.' }
|
|
1451
|
+
) {}
|
|
1452
|
+
|
|
1453
|
+
export type RepositoryError = EntityNotFound | DuplicateEntity | QueryError;
|
|
1454
|
+
```
|
|
1455
|
+
|
|
1456
|
+
### Service Errors
|
|
1457
|
+
|
|
1458
|
+
```typescript
|
|
1459
|
+
import * as Schema from 'effect/Schema';
|
|
1460
|
+
|
|
1461
|
+
export class ServiceUnavailable extends Schema.TaggedError<ServiceUnavailable>()(
|
|
1462
|
+
'ServiceUnavailable',
|
|
1463
|
+
{
|
|
1464
|
+
service: Schema.String,
|
|
1465
|
+
message: Schema.String,
|
|
1466
|
+
retryAfter: Schema.optional(Schema.Number)
|
|
1467
|
+
},
|
|
1468
|
+
{ httpApiStatus: 503, description: 'Upstream service is unavailable.' }
|
|
1469
|
+
) {}
|
|
1470
|
+
|
|
1471
|
+
export class ServiceTimeout extends Schema.TaggedError<ServiceTimeout>()(
|
|
1472
|
+
'ServiceTimeout',
|
|
1473
|
+
{
|
|
1474
|
+
service: Schema.String,
|
|
1475
|
+
message: Schema.String,
|
|
1476
|
+
timeoutMs: Schema.Number
|
|
1477
|
+
},
|
|
1478
|
+
{ httpApiStatus: 504, description: 'Upstream service timed out.' }
|
|
1479
|
+
) {}
|
|
1480
|
+
|
|
1481
|
+
export class InvalidResponse extends Schema.TaggedError<InvalidResponse>()(
|
|
1482
|
+
'InvalidResponse',
|
|
1483
|
+
{
|
|
1484
|
+
service: Schema.String,
|
|
1485
|
+
message: Schema.String,
|
|
1486
|
+
response: Schema.optional(Schema.Unknown)
|
|
1487
|
+
},
|
|
1488
|
+
{ description: 'Upstream service returned an unexpected response.' }
|
|
1489
|
+
) {}
|
|
1490
|
+
|
|
1491
|
+
export type ServiceError =
|
|
1492
|
+
| ServiceUnavailable
|
|
1493
|
+
| ServiceTimeout
|
|
1494
|
+
| InvalidResponse;
|
|
1495
|
+
```
|
|
1496
|
+
|
|
1497
|
+
### Error Boundaries
|
|
1498
|
+
|
|
1499
|
+
```typescript
|
|
1500
|
+
import * as Effect from 'effect/Effect';
|
|
1501
|
+
import * as Schema from 'effect/Schema';
|
|
1502
|
+
|
|
1503
|
+
declare const processRequest: (
|
|
1504
|
+
request: Request
|
|
1505
|
+
) => Effect.Effect<Response, ValidationError | NotFoundError | DatabaseError>;
|
|
1506
|
+
declare const HttpResponse: {
|
|
1507
|
+
badRequest: (message: string) => Response;
|
|
1508
|
+
notFound: () => Response;
|
|
1509
|
+
internalServerError: () => Response;
|
|
1510
|
+
};
|
|
1511
|
+
|
|
1512
|
+
interface Request {
|
|
1513
|
+
readonly url: string;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
interface Response {
|
|
1517
|
+
readonly status: number;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
class ValidationError extends Schema.TaggedError<ValidationError>()(
|
|
1521
|
+
'ValidationError',
|
|
1522
|
+
{
|
|
1523
|
+
message: Schema.String
|
|
1524
|
+
}
|
|
1525
|
+
) {}
|
|
1526
|
+
|
|
1527
|
+
class NotFoundError extends Schema.TaggedError<NotFoundError>()(
|
|
1528
|
+
'NotFoundError',
|
|
1529
|
+
{
|
|
1530
|
+
message: Schema.String
|
|
1531
|
+
}
|
|
1532
|
+
) {}
|
|
1533
|
+
|
|
1534
|
+
class DatabaseError extends Schema.TaggedError<DatabaseError>()(
|
|
1535
|
+
'DatabaseError',
|
|
1536
|
+
{
|
|
1537
|
+
message: Schema.String
|
|
1538
|
+
}
|
|
1539
|
+
) {}
|
|
1540
|
+
|
|
1541
|
+
// Define clear boundaries where errors are handled
|
|
1542
|
+
const apiEndpoint = (request: Request) =>
|
|
1543
|
+
Effect.gen(function* () {
|
|
1544
|
+
const result = yield* processRequest(request);
|
|
1545
|
+
return result;
|
|
1546
|
+
}).pipe(
|
|
1547
|
+
// Error boundary: convert all errors to HTTP responses
|
|
1548
|
+
Effect.catchTags({
|
|
1549
|
+
ValidationError: (error) =>
|
|
1550
|
+
Effect.succeed(HttpResponse.badRequest(error.message)),
|
|
1551
|
+
NotFoundError: () => Effect.succeed(HttpResponse.notFound()),
|
|
1552
|
+
DatabaseError: (error) =>
|
|
1553
|
+
Effect.logError(error).pipe(
|
|
1554
|
+
Effect.as(HttpResponse.internalServerError())
|
|
1555
|
+
)
|
|
1556
|
+
})
|
|
1557
|
+
);
|
|
1558
|
+
```
|
|
1559
|
+
|
|
1560
|
+
## Quality Checklist
|
|
1561
|
+
|
|
1562
|
+
Before completing error handling implementation:
|
|
1563
|
+
|
|
1564
|
+
- [ ] All domain errors use `Schema.TaggedError` with a `message` field
|
|
1565
|
+
- [ ] Error types have meaningful, specific names and `description` annotation
|
|
1566
|
+
- [ ] Errors include relevant context (ids, values, reasons)
|
|
1567
|
+
- [ ] Business failures in error channel, programmer errors as defects
|
|
1568
|
+
- [ ] catchTag/catchTags used for specific error handling
|
|
1569
|
+
- [ ] catch (NOT catchAll) only when handling truly all error types
|
|
1570
|
+
- [ ] Error transformations preserve important context
|
|
1571
|
+
- [ ] Recovery strategies match business requirements
|
|
1572
|
+
- [ ] Defect handling only at application boundaries
|
|
1573
|
+
- [ ] Error types exported from domain modules
|
|
1574
|
+
- [ ] Tests cover error scenarios
|
|
1575
|
+
- [ ] Type signatures accurately reflect error channel
|
|
1576
|
+
- [ ] No v3 API names used (catchAll, catchSome, \*Exception, etc.)
|
|
1577
|
+
- [ ] Errors with `reason` union consider `catchReason`/`catchReasons`/`unwrapReason`
|
|
1578
|
+
- [ ] HTTP-facing errors carry `httpApiStatus` annotation
|
|
1579
|
+
- [ ] Error wrapping uses appropriate `cause` field schema (`Schema.Defect()`/`Schema.Unknown`/`Schema.String`)
|
|
1580
|
+
|
|
1581
|
+
Your error handling implementations should be type-safe, exhaustive, and maintain clear separation between expected failures and programmer errors. Always use v4 API names.
|