@dojocho/effect-ts 0.0.1
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/DOJO.md +22 -0
- package/dojo.json +50 -0
- package/katas/001-hello-effect/SENSEI.md +72 -0
- package/katas/001-hello-effect/solution.test.ts +35 -0
- package/katas/001-hello-effect/solution.ts +16 -0
- package/katas/002-transform-with-map/SENSEI.md +72 -0
- package/katas/002-transform-with-map/solution.test.ts +33 -0
- package/katas/002-transform-with-map/solution.ts +16 -0
- package/katas/003-generator-pipelines/SENSEI.md +72 -0
- package/katas/003-generator-pipelines/solution.test.ts +40 -0
- package/katas/003-generator-pipelines/solution.ts +29 -0
- package/katas/004-flatmap-and-chaining/SENSEI.md +80 -0
- package/katas/004-flatmap-and-chaining/solution.test.ts +34 -0
- package/katas/004-flatmap-and-chaining/solution.ts +18 -0
- package/katas/005-pipe-composition/SENSEI.md +81 -0
- package/katas/005-pipe-composition/solution.test.ts +41 -0
- package/katas/005-pipe-composition/solution.ts +19 -0
- package/katas/006-handle-errors/SENSEI.md +86 -0
- package/katas/006-handle-errors/solution.test.ts +53 -0
- package/katas/006-handle-errors/solution.ts +30 -0
- package/katas/007-tagged-errors/SENSEI.md +79 -0
- package/katas/007-tagged-errors/solution.test.ts +82 -0
- package/katas/007-tagged-errors/solution.ts +37 -0
- package/katas/008-error-patterns/SENSEI.md +89 -0
- package/katas/008-error-patterns/solution.test.ts +41 -0
- package/katas/008-error-patterns/solution.ts +38 -0
- package/katas/009-option-type/SENSEI.md +96 -0
- package/katas/009-option-type/solution.test.ts +49 -0
- package/katas/009-option-type/solution.ts +26 -0
- package/katas/010-either-and-exit/SENSEI.md +86 -0
- package/katas/010-either-and-exit/solution.test.ts +33 -0
- package/katas/010-either-and-exit/solution.ts +17 -0
- package/katas/011-services-and-context/SENSEI.md +82 -0
- package/katas/011-services-and-context/solution.test.ts +23 -0
- package/katas/011-services-and-context/solution.ts +17 -0
- package/katas/012-layers/SENSEI.md +73 -0
- package/katas/012-layers/solution.test.ts +23 -0
- package/katas/012-layers/solution.ts +26 -0
- package/katas/013-testing-effects/SENSEI.md +88 -0
- package/katas/013-testing-effects/solution.test.ts +41 -0
- package/katas/013-testing-effects/solution.ts +20 -0
- package/katas/014-schema-basics/SENSEI.md +81 -0
- package/katas/014-schema-basics/solution.test.ts +35 -0
- package/katas/014-schema-basics/solution.ts +25 -0
- package/katas/015-domain-modeling/SENSEI.md +85 -0
- package/katas/015-domain-modeling/solution.test.ts +46 -0
- package/katas/015-domain-modeling/solution.ts +42 -0
- package/katas/016-retry-and-schedule/SENSEI.md +72 -0
- package/katas/016-retry-and-schedule/solution.test.ts +26 -0
- package/katas/016-retry-and-schedule/solution.ts +23 -0
- package/katas/017-parallel-effects/SENSEI.md +70 -0
- package/katas/017-parallel-effects/solution.test.ts +33 -0
- package/katas/017-parallel-effects/solution.ts +17 -0
- package/katas/018-race-and-timeout/SENSEI.md +75 -0
- package/katas/018-race-and-timeout/solution.test.ts +30 -0
- package/katas/018-race-and-timeout/solution.ts +27 -0
- package/katas/019-ref-and-state/SENSEI.md +72 -0
- package/katas/019-ref-and-state/solution.test.ts +29 -0
- package/katas/019-ref-and-state/solution.ts +16 -0
- package/katas/020-fibers/SENSEI.md +80 -0
- package/katas/020-fibers/solution.test.ts +23 -0
- package/katas/020-fibers/solution.ts +23 -0
- package/katas/021-acquire-release/SENSEI.md +57 -0
- package/katas/021-acquire-release/solution.test.ts +23 -0
- package/katas/021-acquire-release/solution.ts +22 -0
- package/katas/022-scoped-layers/SENSEI.md +52 -0
- package/katas/022-scoped-layers/solution.test.ts +35 -0
- package/katas/022-scoped-layers/solution.ts +19 -0
- package/katas/023-resource-patterns/SENSEI.md +52 -0
- package/katas/023-resource-patterns/solution.test.ts +20 -0
- package/katas/023-resource-patterns/solution.ts +13 -0
- package/katas/024-streams-basics/SENSEI.md +61 -0
- package/katas/024-streams-basics/solution.test.ts +30 -0
- package/katas/024-streams-basics/solution.ts +16 -0
- package/katas/025-stream-operations/SENSEI.md +59 -0
- package/katas/025-stream-operations/solution.test.ts +26 -0
- package/katas/025-stream-operations/solution.ts +17 -0
- package/katas/026-combining-streams/SENSEI.md +54 -0
- package/katas/026-combining-streams/solution.test.ts +20 -0
- package/katas/026-combining-streams/solution.ts +16 -0
- package/katas/027-data-pipelines/SENSEI.md +58 -0
- package/katas/027-data-pipelines/solution.test.ts +22 -0
- package/katas/027-data-pipelines/solution.ts +16 -0
- package/katas/028-logging-and-spans/SENSEI.md +58 -0
- package/katas/028-logging-and-spans/solution.test.ts +50 -0
- package/katas/028-logging-and-spans/solution.ts +20 -0
- package/katas/029-http-client/SENSEI.md +59 -0
- package/katas/029-http-client/solution.test.ts +49 -0
- package/katas/029-http-client/solution.ts +24 -0
- package/katas/030-capstone/SENSEI.md +63 -0
- package/katas/030-capstone/solution.test.ts +67 -0
- package/katas/030-capstone/solution.ts +55 -0
- package/katas/031-config-and-environment/SENSEI.md +77 -0
- package/katas/031-config-and-environment/solution.test.ts +38 -0
- package/katas/031-config-and-environment/solution.ts +11 -0
- package/katas/032-cause-and-defects/SENSEI.md +90 -0
- package/katas/032-cause-and-defects/solution.test.ts +50 -0
- package/katas/032-cause-and-defects/solution.ts +23 -0
- package/katas/033-pattern-matching/SENSEI.md +86 -0
- package/katas/033-pattern-matching/solution.test.ts +36 -0
- package/katas/033-pattern-matching/solution.ts +28 -0
- package/katas/034-deferred-and-coordination/SENSEI.md +85 -0
- package/katas/034-deferred-and-coordination/solution.test.ts +25 -0
- package/katas/034-deferred-and-coordination/solution.ts +24 -0
- package/katas/035-queue-and-backpressure/SENSEI.md +100 -0
- package/katas/035-queue-and-backpressure/solution.test.ts +25 -0
- package/katas/035-queue-and-backpressure/solution.ts +21 -0
- package/katas/036-schema-advanced/SENSEI.md +81 -0
- package/katas/036-schema-advanced/solution.test.ts +55 -0
- package/katas/036-schema-advanced/solution.ts +19 -0
- package/katas/037-cache-and-memoization/SENSEI.md +73 -0
- package/katas/037-cache-and-memoization/solution.test.ts +47 -0
- package/katas/037-cache-and-memoization/solution.ts +24 -0
- package/katas/038-metrics/SENSEI.md +91 -0
- package/katas/038-metrics/solution.test.ts +39 -0
- package/katas/038-metrics/solution.ts +23 -0
- package/katas/039-managed-runtime/SENSEI.md +75 -0
- package/katas/039-managed-runtime/solution.test.ts +29 -0
- package/katas/039-managed-runtime/solution.ts +19 -0
- package/katas/040-request-batching/SENSEI.md +87 -0
- package/katas/040-request-batching/solution.test.ts +56 -0
- package/katas/040-request-batching/solution.ts +32 -0
- package/package.json +22 -0
- package/skills/effect-patterns-building-apis/SKILL.md +2393 -0
- package/skills/effect-patterns-building-data-pipelines/SKILL.md +1876 -0
- package/skills/effect-patterns-concurrency/SKILL.md +2999 -0
- package/skills/effect-patterns-concurrency-getting-started/SKILL.md +351 -0
- package/skills/effect-patterns-core-concepts/SKILL.md +3199 -0
- package/skills/effect-patterns-domain-modeling/SKILL.md +1385 -0
- package/skills/effect-patterns-error-handling/SKILL.md +1212 -0
- package/skills/effect-patterns-error-handling-resilience/SKILL.md +179 -0
- package/skills/effect-patterns-error-management/SKILL.md +1668 -0
- package/skills/effect-patterns-getting-started/SKILL.md +237 -0
- package/skills/effect-patterns-making-http-requests/SKILL.md +1756 -0
- package/skills/effect-patterns-observability/SKILL.md +1586 -0
- package/skills/effect-patterns-platform/SKILL.md +1195 -0
- package/skills/effect-patterns-platform-getting-started/SKILL.md +179 -0
- package/skills/effect-patterns-project-setup--execution/SKILL.md +233 -0
- package/skills/effect-patterns-resource-management/SKILL.md +827 -0
- package/skills/effect-patterns-scheduling/SKILL.md +451 -0
- package/skills/effect-patterns-scheduling-periodic-tasks/SKILL.md +763 -0
- package/skills/effect-patterns-streams/SKILL.md +2052 -0
- package/skills/effect-patterns-streams-getting-started/SKILL.md +421 -0
- package/skills/effect-patterns-streams-sinks/SKILL.md +1181 -0
- package/skills/effect-patterns-testing/SKILL.md +1632 -0
- package/skills/effect-patterns-tooling-and-debugging/SKILL.md +1125 -0
- package/skills/effect-patterns-value-handling/SKILL.md +676 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +3 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# SENSEI — 005 Pipe Composition
|
|
2
|
+
|
|
3
|
+
## Briefing
|
|
4
|
+
|
|
5
|
+
### Goal
|
|
6
|
+
|
|
7
|
+
Compose multi-step Effect transformations using pipe and the fluent `.pipe()` style.
|
|
8
|
+
|
|
9
|
+
### Tasks
|
|
10
|
+
|
|
11
|
+
1. Implement `processNumber(n)` — pipe a number through: double, add 1, convert to string
|
|
12
|
+
2. Implement `processUser(name, age)` — pipe through validation and formatting using `.pipe()`
|
|
13
|
+
3. Implement `pipeline(s)` — compose parse, validate, and format using `.pipe()` chaining
|
|
14
|
+
|
|
15
|
+
### Hints
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Effect, pipe } from "effect";
|
|
19
|
+
|
|
20
|
+
// Standalone pipe function
|
|
21
|
+
const result = pipe(
|
|
22
|
+
Effect.succeed(10),
|
|
23
|
+
Effect.map((n) => n * 2),
|
|
24
|
+
Effect.map((n) => `Result: ${n}`)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// Fluent .pipe() style
|
|
28
|
+
const fluent = Effect.succeed("hello").pipe(
|
|
29
|
+
Effect.map((s) => s.toUpperCase()),
|
|
30
|
+
Effect.map((s) => `${s}!`)
|
|
31
|
+
);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Prerequisites
|
|
35
|
+
|
|
36
|
+
- **001 Hello Effect** — `Effect.succeed`, `Effect.sync`
|
|
37
|
+
- **002 Transform with Map** — `Effect.map`, `pipe`
|
|
38
|
+
- **003 Generator Pipelines** — `Effect.gen`, `yield*`, `Effect.fail`
|
|
39
|
+
- **004 FlatMap and Chaining** — `Effect.flatMap`, `Effect.andThen`, `Effect.tap`
|
|
40
|
+
|
|
41
|
+
> **Note**: `Effect.runSync`, `Effect.runSyncExit`, and `Exit.isFailure` appear only in tests. Never attribute them to the user's learning.
|
|
42
|
+
|
|
43
|
+
## Test Map
|
|
44
|
+
|
|
45
|
+
| Test | Concept | Verifies |
|
|
46
|
+
|------|---------|----------|
|
|
47
|
+
| `processNumber(5) succeeds with '11'` | `pipe` + `Effect.map` | Multi-step: double, +1, toString |
|
|
48
|
+
| `processNumber(0) succeeds with '1'` | `pipe` | Edge case — 0 doubled + 1 = 1 |
|
|
49
|
+
| `processUser('Alice', 30) succeeds with 'Alice (age 30)'` | `.pipe()` + `Effect.flatMap` | Fluent style with validation |
|
|
50
|
+
| `processUser('', 30) fails with 'InvalidName'` | `.pipe()` + `Effect.fail` | Validation — empty name |
|
|
51
|
+
| `processUser('Alice', -1) fails with 'InvalidAge'` | `.pipe()` + `Effect.fail` | Validation — negative age |
|
|
52
|
+
| `pipeline('5') succeeds with 'Value: 5'` | `.pipe()` composition | Parse + validate + format |
|
|
53
|
+
| `pipeline('abc') fails` | `.pipe()` + `Effect.fail` | Parse failure |
|
|
54
|
+
| `pipeline('-3') fails with not positive` | `.pipe()` + `Effect.fail` | Validation failure |
|
|
55
|
+
|
|
56
|
+
## Teaching Approach
|
|
57
|
+
|
|
58
|
+
### Socratic prompts
|
|
59
|
+
|
|
60
|
+
- "You've used `pipe` before in kata 002. What's different about using `.pipe()` as a method on an Effect?"
|
|
61
|
+
- "For `processNumber`, the steps are: double, add 1, to string. Which operator works for each step?"
|
|
62
|
+
- "In `processUser`, you need to validate TWO things before formatting. What happens if the first validation fails — does the second one run?"
|
|
63
|
+
- "For `pipeline`, there are three phases: parse, validate, format. Which might fail? What error should each produce?"
|
|
64
|
+
|
|
65
|
+
### Common pitfalls
|
|
66
|
+
|
|
67
|
+
1. **Standalone `pipe()` vs fluent `.pipe()`** — both work the same way but read differently. `pipe(value, fn1, fn2)` vs `value.pipe(fn1, fn2)`. Ask: "Which style makes this code more readable?"
|
|
68
|
+
2. **Validation ordering in `processUser`** — students may validate name and age in a single step. Nudge: "What if name is empty AND age is negative? Which error should win? Check the tests."
|
|
69
|
+
3. **Parse + validate confusion in `pipeline`** — parsing and positivity check are separate concerns. Ask: "What does `parseInt` return for `'abc'`? How about `'-3'` — does it parse? Is it positive?"
|
|
70
|
+
4. **String conversion** — `processNumber` needs the final value as a string. Students may forget the `String()` or template literal step.
|
|
71
|
+
5. **Start with `processNumber`** — it's pure `pipe` + `map`, no errors: three maps in a row (double, add one, stringify).
|
|
72
|
+
|
|
73
|
+
## On Completion
|
|
74
|
+
|
|
75
|
+
### Insight
|
|
76
|
+
|
|
77
|
+
You've now completed the Basics area. You can create Effects (`succeed`, `sync`, `fail`), transform them (`map`), chain them (`flatMap`, `andThen`), observe them (`tap`), sequence them with generators (`gen`, `yield*`), and compose them into pipelines (`pipe`, `.pipe()`). These are the atoms of Effect programming — everything else builds on them.
|
|
78
|
+
|
|
79
|
+
### Bridge
|
|
80
|
+
|
|
81
|
+
With the basics solid, it's time for what makes Effect truly powerful: **typed error handling**. Kata 006 introduces `Effect.fail`, `catchAll`, and `catchTag` — giving you fine-grained control over what goes wrong and how to recover. In regular TypeScript, errors are `unknown`. In Effect, they're part of the type signature.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Effect, Exit } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { processNumber, processUser, pipeline } from "@/katas/005-pipe-composition/solution.js";
|
|
4
|
+
|
|
5
|
+
describe("005 — Pipe Composition", () => {
|
|
6
|
+
it("processNumber(5) succeeds with '11'", () => {
|
|
7
|
+
expect(Effect.runSync(processNumber(5))).toBe("11");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("processNumber(0) succeeds with '1'", () => {
|
|
11
|
+
expect(Effect.runSync(processNumber(0))).toBe("1");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("processUser('Alice', 30) succeeds with 'Alice (age 30)'", () => {
|
|
15
|
+
expect(Effect.runSync(processUser("Alice", 30))).toBe("Alice (age 30)");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("processUser('', 30) fails with 'InvalidName'", () => {
|
|
19
|
+
const exit = Effect.runSyncExit(processUser("", 30));
|
|
20
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("processUser('Alice', -1) fails with 'InvalidAge'", () => {
|
|
24
|
+
const exit = Effect.runSyncExit(processUser("Alice", -1));
|
|
25
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("pipeline('5') succeeds with 'Value: 5'", () => {
|
|
29
|
+
expect(Effect.runSync(pipeline("5"))).toBe("Value: 5");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("pipeline('abc') fails", () => {
|
|
33
|
+
const exit = Effect.runSyncExit(pipeline("abc"));
|
|
34
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("pipeline('-3') fails with not positive", () => {
|
|
38
|
+
const exit = Effect.runSyncExit(pipeline("-3"));
|
|
39
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Effect, pipe } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Use pipe to: start with Effect.succeed(n), double it, add 1, convert to string */
|
|
4
|
+
export const processNumber = (n: number): Effect.Effect<string> => {
|
|
5
|
+
throw new Error("Not implemented");
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Use .pipe() fluent style to validate name is non-empty and age >= 0,
|
|
9
|
+
* then format as "{name} (age {age})"
|
|
10
|
+
* Fail with "InvalidName" or "InvalidAge" respectively */
|
|
11
|
+
export const processUser = (name: string, age: number): Effect.Effect<string, string> => {
|
|
12
|
+
throw new Error("Not implemented");
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Use pipe to compose: parse string to int (fail with "ParseError"),
|
|
16
|
+
* validate > 0 (fail with "NotPositive"), format as "Value: {n}" */
|
|
17
|
+
export const pipeline = (s: string): Effect.Effect<string, string> => {
|
|
18
|
+
throw new Error("Not implemented");
|
|
19
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SENSEI — 006 Handle Errors
|
|
2
|
+
|
|
3
|
+
## Briefing
|
|
4
|
+
|
|
5
|
+
### Goal
|
|
6
|
+
|
|
7
|
+
Create Effects that can fail, and recover from those failures.
|
|
8
|
+
|
|
9
|
+
### Tasks
|
|
10
|
+
|
|
11
|
+
1. Implement `divide(a, b)` — returns an Effect that divides `a / b`, failing with `{ _tag: "DivisionByZero" }` when `b === 0`
|
|
12
|
+
2. Implement `safeDivide(a, b)` — wraps `divide` and recovers from `DivisionByZero` by returning `0`
|
|
13
|
+
3. Implement `parseInteger(s)` — parses a string to int, failing with `{ _tag: "ParseError", input: s }` on invalid input
|
|
14
|
+
|
|
15
|
+
### Hints
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Effect } from "effect";
|
|
19
|
+
|
|
20
|
+
// Effect.fail creates a failed Effect
|
|
21
|
+
const failed = Effect.fail({ _tag: "MyError" as const });
|
|
22
|
+
|
|
23
|
+
// catchAll recovers from any error
|
|
24
|
+
const recovered = Effect.catchAll(failed, (error) =>
|
|
25
|
+
Effect.succeed("fallback"),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// catchTag recovers from a specific tagged error
|
|
29
|
+
const recovered2 = Effect.catchTag(failed, "MyError", (error) =>
|
|
30
|
+
Effect.succeed("recovered"),
|
|
31
|
+
);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Prerequisites
|
|
35
|
+
|
|
36
|
+
- **001 Hello Effect** — `Effect.succeed`, `Effect.sync`
|
|
37
|
+
- **002 Transform with Map** — `Effect.map`, `pipe`
|
|
38
|
+
- **003 Generator Pipelines** — `Effect.gen`, `yield*`, `Effect.fail`
|
|
39
|
+
- **004 FlatMap and Chaining** — `Effect.flatMap`, `Effect.andThen`, `Effect.tap`
|
|
40
|
+
- **005 Pipe Composition** — `pipe`, `.pipe()`, composition
|
|
41
|
+
|
|
42
|
+
## Skills
|
|
43
|
+
|
|
44
|
+
Invoke `effect-patterns-error-handling` before teaching this kata.
|
|
45
|
+
|
|
46
|
+
> **Note**: `Effect.runSync`, `Effect.runSyncExit`, and `Exit.isFailure` appear only in tests. Never attribute them to the user's learning.
|
|
47
|
+
|
|
48
|
+
## Test Map
|
|
49
|
+
|
|
50
|
+
| Test | Concept | Verifies |
|
|
51
|
+
|------|---------|----------|
|
|
52
|
+
| `divide(10, 2) succeeds with 5` | `Effect.succeed` | Happy path — normal division |
|
|
53
|
+
| `divide(10, 0) fails with DivisionByZero` | `Effect.fail` | Error path — division by zero |
|
|
54
|
+
| `safeDivide(10, 2) succeeds with 5` | `Effect.catchAll` or `Effect.catchTag` | Recovery pass-through on success |
|
|
55
|
+
| `safeDivide(10, 0) recovers with 0` | `Effect.catchAll` or `Effect.catchTag` | Recovery — returning a default on error |
|
|
56
|
+
| `parseInteger('42') succeeds with 42` | `Effect.succeed` | Parsing a valid positive integer |
|
|
57
|
+
| `parseInteger('-7') succeeds with -7` | `Effect.succeed` | Parsing a valid negative integer |
|
|
58
|
+
| `parseInteger('abc') fails with ParseError` | `Effect.fail` | Non-numeric input |
|
|
59
|
+
| `parseInteger('3.14') fails with ParseError` | `Effect.fail` | Float input rejected |
|
|
60
|
+
|
|
61
|
+
## Teaching Approach
|
|
62
|
+
|
|
63
|
+
### Socratic prompts
|
|
64
|
+
|
|
65
|
+
- "In regular TypeScript, division by zero gives `Infinity`. What if you want to treat it as an error instead?"
|
|
66
|
+
- "What does the type `Effect<number, DivisionByZero>` tell you that `number` alone doesn't?"
|
|
67
|
+
- "`safeDivide` has no error type — `Effect<number>`. How did the error disappear? Where did it go?"
|
|
68
|
+
- "What's the difference between `catchAll` and `catchTag`? When would you prefer one over the other?"
|
|
69
|
+
|
|
70
|
+
### Common pitfalls
|
|
71
|
+
|
|
72
|
+
1. **Forgetting to check for zero** — `divide` needs an explicit check before dividing. Ask: "What should `divide` do first — compute the result or check the divisor?"
|
|
73
|
+
2. **Using try/catch instead of Effect.fail** — students coming from imperative code may wrap division in try/catch. Nudge: "In Effect, errors are values, not exceptions. How do you create an error value?"
|
|
74
|
+
3. **Double-wrapping in safeDivide** — students may try `Effect.succeed(divide(a, b))` instead of calling `divide` and catching errors. Ask: "What does `divide(a, b)` return? Is it a plain number or an Effect?"
|
|
75
|
+
4. **ParseError for integers** — `parseInt('3.14')` returns `3`, not `NaN`. Students need to check that the string represents a whole number. Ask: "Does `parseInt('3.14')` fail? How do you detect that `'3.14'` isn't an integer?"
|
|
76
|
+
5. **Start with `divide`** — check the divisor first; if it's zero, fail; otherwise, succeed with the result.
|
|
77
|
+
|
|
78
|
+
## On Completion
|
|
79
|
+
|
|
80
|
+
### Insight
|
|
81
|
+
|
|
82
|
+
Effect errors are typed — the type system tracks exactly what can go wrong. Unlike try/catch where errors are `unknown`, an `Effect<number, DivisionByZero>` tells you at the type level that this computation either produces a number or fails with a `DivisionByZero`. When you use `catchAll` or `catchTag` to handle all possible errors, the error channel becomes `never` — the compiler proves your code handles every failure case.
|
|
83
|
+
|
|
84
|
+
### Bridge
|
|
85
|
+
|
|
86
|
+
Now that you can create and catch errors, the next step is making them richer. Kata 007 introduces `Data.TaggedError` — class-based errors with `_tag` discrimination that enable `catchTag` to selectively recover from specific error types while letting others propagate.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Effect, Exit } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { divide, parseInteger, safeDivide } from "@/katas/006-handle-errors/solution.js";
|
|
4
|
+
|
|
5
|
+
describe("006 — Handle Errors", () => {
|
|
6
|
+
it("divide(10, 2) succeeds with 5", () => {
|
|
7
|
+
expect(Effect.runSync(divide(10, 2))).toBe(5);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("divide(10, 0) fails with DivisionByZero", () => {
|
|
11
|
+
const exit = Effect.runSyncExit(divide(10, 0));
|
|
12
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
13
|
+
if (Exit.isFailure(exit)) {
|
|
14
|
+
const error = exit.cause;
|
|
15
|
+
expect(error).toMatchObject({
|
|
16
|
+
_tag: "Fail",
|
|
17
|
+
error: { _tag: "DivisionByZero" },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("safeDivide(10, 2) succeeds with 5", () => {
|
|
23
|
+
expect(Effect.runSync(safeDivide(10, 2))).toBe(5);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("safeDivide(10, 0) recovers with 0", () => {
|
|
27
|
+
expect(Effect.runSync(safeDivide(10, 0))).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("parseInteger('42') succeeds with 42", () => {
|
|
31
|
+
expect(Effect.runSync(parseInteger("42"))).toBe(42);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("parseInteger('-7') succeeds with -7", () => {
|
|
35
|
+
expect(Effect.runSync(parseInteger("-7"))).toBe(-7);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("parseInteger('abc') fails with ParseError", () => {
|
|
39
|
+
const exit = Effect.runSyncExit(parseInteger("abc"));
|
|
40
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
41
|
+
if (Exit.isFailure(exit)) {
|
|
42
|
+
expect(exit.cause).toMatchObject({
|
|
43
|
+
_tag: "Fail",
|
|
44
|
+
error: { _tag: "ParseError", input: "abc" },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("parseInteger('3.14') fails with ParseError", () => {
|
|
50
|
+
const exit = Effect.runSyncExit(parseInteger("3.14"));
|
|
51
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
export interface DivisionByZero {
|
|
4
|
+
readonly _tag: "DivisionByZero";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ParseError {
|
|
8
|
+
readonly _tag: "ParseError";
|
|
9
|
+
readonly input: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Return a / b as an Effect. Fail with DivisionByZero when b === 0. */
|
|
13
|
+
export const divide = (
|
|
14
|
+
a: number,
|
|
15
|
+
b: number,
|
|
16
|
+
): Effect.Effect<number, DivisionByZero> => {
|
|
17
|
+
throw new Error("Not implemented");
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Wrap divide and recover from DivisionByZero by returning 0. */
|
|
21
|
+
export const safeDivide = (a: number, b: number): Effect.Effect<number> => {
|
|
22
|
+
throw new Error("Not implemented");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Parse s as an integer. Fail with ParseError if it's not a valid integer. */
|
|
26
|
+
export const parseInteger = (
|
|
27
|
+
s: string,
|
|
28
|
+
): Effect.Effect<number, ParseError> => {
|
|
29
|
+
throw new Error("Not implemented");
|
|
30
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# SENSEI — 007 Tagged Errors
|
|
2
|
+
|
|
3
|
+
## Briefing
|
|
4
|
+
|
|
5
|
+
### Goal
|
|
6
|
+
|
|
7
|
+
Model domain errors using `Data.TaggedError` for type-safe error handling.
|
|
8
|
+
|
|
9
|
+
### Tasks
|
|
10
|
+
|
|
11
|
+
1. Define `NotFoundError` and `ValidationError` as `Data.TaggedError` classes
|
|
12
|
+
2. Implement `findUser(id)` — fails with `NotFoundError` when `id < 0`, succeeds with `{ id, name: "User {id}" }`
|
|
13
|
+
3. Implement `validateAge(age)` — fails with `ValidationError` when `age < 0 || age > 150`, succeeds with the age
|
|
14
|
+
4. Implement `findUserOrDefault(id)` — wraps `findUser`, recovering from `NotFoundError` with a default user `{ id: 0, name: "Guest" }`
|
|
15
|
+
|
|
16
|
+
### Hints
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { Data, Effect } from "effect";
|
|
20
|
+
|
|
21
|
+
class MyError extends Data.TaggedError("MyError")<{
|
|
22
|
+
readonly message: string;
|
|
23
|
+
}> {}
|
|
24
|
+
|
|
25
|
+
const failing = Effect.fail(new MyError({ message: "oops" }));
|
|
26
|
+
|
|
27
|
+
// catchTag matches on the _tag field
|
|
28
|
+
const recovered = Effect.catchTag(failing, "MyError", (e) =>
|
|
29
|
+
Effect.succeed(`Recovered: ${e.message}`),
|
|
30
|
+
);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Prerequisites
|
|
34
|
+
|
|
35
|
+
- **006 Handle Errors** — `Effect.fail`, `Effect.catchAll`, `Effect.catchTag`
|
|
36
|
+
|
|
37
|
+
> **Note**: `Effect.runSync`, `Effect.runSyncExit`, and `Exit.isFailure` appear only in tests. Never attribute them to the user's learning.
|
|
38
|
+
|
|
39
|
+
## Test Map
|
|
40
|
+
|
|
41
|
+
| Test | Concept | Verifies |
|
|
42
|
+
|------|---------|----------|
|
|
43
|
+
| `findUser(1) succeeds with { id: 1, name: 'User 1' }` | `Effect.succeed` | Happy path — known user |
|
|
44
|
+
| `findUser(42) succeeds with { id: 42, name: 'User 42' }` | `Effect.succeed` | Happy path — any positive id |
|
|
45
|
+
| `findUser(-1) fails with NotFoundError` | `Data.TaggedError` + `Effect.fail` | Negative id triggers typed error |
|
|
46
|
+
| `validateAge(25) succeeds with 25` | `Effect.succeed` | Valid age in range |
|
|
47
|
+
| `validateAge(0) succeeds with 0` | `Effect.succeed` | Boundary case — zero is valid |
|
|
48
|
+
| `validateAge(150) succeeds with 150` | `Effect.succeed` | Boundary case — 150 is valid |
|
|
49
|
+
| `validateAge(-1) fails with ValidationError` | `Effect.fail` | Below minimum boundary |
|
|
50
|
+
| `validateAge(151) fails with ValidationError` | `Effect.fail` | Above maximum boundary |
|
|
51
|
+
| `findUserOrDefault(1) succeeds with { id: 1, name: 'User 1' }` | `Effect.catchTag` | Pass-through on success |
|
|
52
|
+
| `findUserOrDefault(-1) recovers with { id: 0, name: 'Guest' }` | `Effect.catchTag` | Selective recovery from NotFoundError |
|
|
53
|
+
|
|
54
|
+
## Teaching Approach
|
|
55
|
+
|
|
56
|
+
### Socratic prompts
|
|
57
|
+
|
|
58
|
+
- "In kata 006 you used plain objects as errors. What does `Data.TaggedError` add on top of that?"
|
|
59
|
+
- "What is the `_tag` property on a TaggedError? How does `catchTag` use it?"
|
|
60
|
+
- "In `findUserOrDefault`, you only want to catch `NotFoundError`. What happens to other error types if there were any?"
|
|
61
|
+
- "What's the difference between `catchAll` (kata 006) and `catchTag`? Which is more precise?"
|
|
62
|
+
|
|
63
|
+
### Common pitfalls
|
|
64
|
+
|
|
65
|
+
1. **Forgetting `new` when constructing TaggedError** — `Effect.fail(NotFoundError())` won't work. It must be `Effect.fail(new NotFoundError())`. Ask: "TaggedError creates a class — how do you create an instance of a class in JavaScript?"
|
|
66
|
+
2. **Boundary conditions in validateAge** — the tests show `0` and `150` are valid, but `-1` and `151` are not. Ask: "What exact range is valid? Check the boundary test cases carefully."
|
|
67
|
+
3. **Using `catchAll` instead of `catchTag`** — for `findUserOrDefault`, `catchAll` works but `catchTag` is the lesson. Nudge: "Can you be more specific about which error you're catching?"
|
|
68
|
+
4. **Wrong user format** — `findUser` must return `{ id, name: 'User ${id}' }`. Students may forget the name format. Ask: "Check the test expectations — what shape does the user object need?"
|
|
69
|
+
5. **Start with error classes** — define `NotFoundError` and `ValidationError` using `Data.TaggedError` first, then implement the functions that use them.
|
|
70
|
+
|
|
71
|
+
## On Completion
|
|
72
|
+
|
|
73
|
+
### Insight
|
|
74
|
+
|
|
75
|
+
TaggedError gives you class-based errors with `_tag` discrimination. This enables `catchTag` to selectively recover from specific error types while letting others propagate. The pattern is powerful: define your domain errors as TaggedError classes, fail with them, and catch only the ones you want to handle. The type system ensures you know exactly which errors remain unhandled.
|
|
76
|
+
|
|
77
|
+
### Bridge
|
|
78
|
+
|
|
79
|
+
You can now create and selectively catch typed errors. Kata 008 expands your error-handling toolbox with `catchTags` (handle multiple error types at once), `orElse` (try a fallback effect), and `match` (handle both success and failure) — completing the Error Patterns area.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Effect, Exit } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
findUser,
|
|
5
|
+
findUserOrDefault,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
validateAge,
|
|
8
|
+
ValidationError,
|
|
9
|
+
} from "@/katas/007-tagged-errors/solution.js";
|
|
10
|
+
|
|
11
|
+
describe("007 — Tagged Errors", () => {
|
|
12
|
+
describe("findUser", () => {
|
|
13
|
+
it("findUser(1) succeeds with { id: 1, name: 'User 1' }", () => {
|
|
14
|
+
const result = Effect.runSync(findUser(1));
|
|
15
|
+
expect(result).toEqual({ id: 1, name: "User 1" });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("findUser(42) succeeds with { id: 42, name: 'User 42' }", () => {
|
|
19
|
+
const result = Effect.runSync(findUser(42));
|
|
20
|
+
expect(result).toEqual({ id: 42, name: "User 42" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("findUser(-1) fails with NotFoundError", () => {
|
|
24
|
+
const exit = Effect.runSyncExit(findUser(-1));
|
|
25
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
26
|
+
if (Exit.isFailure(exit)) {
|
|
27
|
+
expect(exit.cause).toMatchObject({
|
|
28
|
+
_tag: "Fail",
|
|
29
|
+
error: { _tag: "NotFoundError" },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("validateAge", () => {
|
|
36
|
+
it("validateAge(25) succeeds with 25", () => {
|
|
37
|
+
expect(Effect.runSync(validateAge(25))).toBe(25);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("validateAge(0) succeeds with 0", () => {
|
|
41
|
+
expect(Effect.runSync(validateAge(0))).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("validateAge(150) succeeds with 150", () => {
|
|
45
|
+
expect(Effect.runSync(validateAge(150))).toBe(150);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("validateAge(-1) fails with ValidationError", () => {
|
|
49
|
+
const exit = Effect.runSyncExit(validateAge(-1));
|
|
50
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
51
|
+
if (Exit.isFailure(exit)) {
|
|
52
|
+
expect(exit.cause).toMatchObject({
|
|
53
|
+
_tag: "Fail",
|
|
54
|
+
error: { _tag: "ValidationError" },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("validateAge(151) fails with ValidationError", () => {
|
|
60
|
+
const exit = Effect.runSyncExit(validateAge(151));
|
|
61
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
62
|
+
if (Exit.isFailure(exit)) {
|
|
63
|
+
expect(exit.cause).toMatchObject({
|
|
64
|
+
_tag: "Fail",
|
|
65
|
+
error: { _tag: "ValidationError" },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("findUserOrDefault", () => {
|
|
72
|
+
it("findUserOrDefault(1) succeeds with { id: 1, name: 'User 1' }", () => {
|
|
73
|
+
const result = Effect.runSync(findUserOrDefault(1));
|
|
74
|
+
expect(result).toEqual({ id: 1, name: "User 1" });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("findUserOrDefault(-1) recovers with { id: 0, name: 'Guest' }", () => {
|
|
78
|
+
const result = Effect.runSync(findUserOrDefault(-1));
|
|
79
|
+
expect(result).toEqual({ id: 0, name: "Guest" });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Define NotFoundError as a Data.TaggedError with a message field */
|
|
4
|
+
export class NotFoundError extends Data.TaggedError("NotFoundError")<{
|
|
5
|
+
readonly message: string;
|
|
6
|
+
}> {}
|
|
7
|
+
|
|
8
|
+
/** Define ValidationError as a Data.TaggedError with a message field */
|
|
9
|
+
export class ValidationError extends Data.TaggedError("ValidationError")<{
|
|
10
|
+
readonly message: string;
|
|
11
|
+
}> {}
|
|
12
|
+
|
|
13
|
+
export interface User {
|
|
14
|
+
readonly id: number;
|
|
15
|
+
readonly name: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Return user { id, name: "User {id}" }. Fail with NotFoundError when id < 0. */
|
|
19
|
+
export const findUser = (
|
|
20
|
+
id: number,
|
|
21
|
+
): Effect.Effect<User, NotFoundError> => {
|
|
22
|
+
throw new Error("Not implemented");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Fail with ValidationError when age < 0 or age > 150. Succeed with the age. */
|
|
26
|
+
export const validateAge = (
|
|
27
|
+
age: number,
|
|
28
|
+
): Effect.Effect<number, ValidationError> => {
|
|
29
|
+
throw new Error("Not implemented");
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Wrap findUser, recovering from NotFoundError with { id: 0, name: "Guest" }. */
|
|
33
|
+
export const findUserOrDefault = (
|
|
34
|
+
id: number,
|
|
35
|
+
): Effect.Effect<User> => {
|
|
36
|
+
throw new Error("Not implemented");
|
|
37
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# SENSEI — 008 Error Patterns
|
|
2
|
+
|
|
3
|
+
## Briefing
|
|
4
|
+
|
|
5
|
+
### Goal
|
|
6
|
+
|
|
7
|
+
Master advanced error handling patterns using catchTags, orElse, and match.
|
|
8
|
+
|
|
9
|
+
### Tasks
|
|
10
|
+
|
|
11
|
+
1. Implement `handleAllErrors` — use `Effect.catchTags` to handle NetworkError, TimeoutError, and AuthError differently
|
|
12
|
+
2. Implement `withFallback` — use `Effect.orElse` to try a primary effect, falling back on failure
|
|
13
|
+
3. Implement `toResult` — use `Effect.match` to wrap success as "ok: {value}" and failure as "err: {error}"
|
|
14
|
+
|
|
15
|
+
### Hints
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Effect } from "effect";
|
|
19
|
+
|
|
20
|
+
// catchTags handles each tagged error by _tag
|
|
21
|
+
const handled = effect.pipe(
|
|
22
|
+
Effect.catchTags({
|
|
23
|
+
NetworkError: (e) => Effect.succeed(`network: ${e.url}`),
|
|
24
|
+
TimeoutError: (e) => Effect.succeed(`timeout: ${e.ms}`),
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// orElse runs fallback if primary fails
|
|
29
|
+
const withFallback = primary.pipe(Effect.orElse(() => fallback));
|
|
30
|
+
|
|
31
|
+
// match folds both channels into a single success value
|
|
32
|
+
const matched = effect.pipe(
|
|
33
|
+
Effect.match({
|
|
34
|
+
onFailure: (e) => `err: ${e}`,
|
|
35
|
+
onSuccess: (a) => `ok: ${a}`,
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Prerequisites
|
|
41
|
+
|
|
42
|
+
- **006 Handle Errors** — `Effect.fail`, `Effect.catchAll`, `Effect.catchTag`
|
|
43
|
+
- **007 Tagged Errors** — `Data.TaggedError`, selective recovery
|
|
44
|
+
|
|
45
|
+
## Skills
|
|
46
|
+
|
|
47
|
+
Invoke `effect-patterns-error-handling-resilience` before teaching this kata.
|
|
48
|
+
|
|
49
|
+
> **Note**: `Effect.runSync` appears only in tests. Never attribute it to the user's learning.
|
|
50
|
+
|
|
51
|
+
## Test Map
|
|
52
|
+
|
|
53
|
+
| Test | Concept | Verifies |
|
|
54
|
+
|------|---------|----------|
|
|
55
|
+
| `handleAllErrors handles NetworkError` | `Effect.catchTags` | Recovery from NetworkError |
|
|
56
|
+
| `handleAllErrors handles TimeoutError` | `Effect.catchTags` | Recovery from TimeoutError |
|
|
57
|
+
| `handleAllErrors handles AuthError` | `Effect.catchTags` | Recovery from AuthError |
|
|
58
|
+
| `handleAllErrors passes through success` | `Effect.catchTags` | Success is unaffected |
|
|
59
|
+
| `withFallback uses primary on success` | `Effect.orElse` | Primary Effect succeeds — fallback not used |
|
|
60
|
+
| `withFallback uses fallback on failure` | `Effect.orElse` | Primary Effect fails — fallback takes over |
|
|
61
|
+
| `toResult wraps success` | `Effect.match` | Success path folded to string |
|
|
62
|
+
| `toResult wraps failure` | `Effect.match` | Failure path folded to string |
|
|
63
|
+
|
|
64
|
+
## Teaching Approach
|
|
65
|
+
|
|
66
|
+
### Socratic prompts
|
|
67
|
+
|
|
68
|
+
- "In kata 007 you used `catchTag` for one error type. What if you have three different error types to handle? Would you chain three `catchTag` calls?"
|
|
69
|
+
- "`catchTags` takes an object with handlers. What happens to the error channel if you handle every possible error type?"
|
|
70
|
+
- "`orElse` ignores the original error and tries something else. How is that different from `catchAll`?"
|
|
71
|
+
- "`match` handles both success and failure. What type does the result have — can it still fail?"
|
|
72
|
+
|
|
73
|
+
### Common pitfalls
|
|
74
|
+
|
|
75
|
+
1. **Incomplete `catchTags` object** — if the effect has three error types, the handler object needs all three keys. The compiler will tell you if you miss one. Ask: "What errors can this effect produce? Does your handler cover all of them?"
|
|
76
|
+
2. **Confusing `orElse` and `catchAll`** — `orElse` takes a function that returns an Effect (ignoring the error), while `catchAll` receives the error as a parameter. Ask: "Do you need to inspect the error, or just try something else entirely?"
|
|
77
|
+
3. **`match` return types** — both the success and failure handlers in `match` must return the same type. Ask: "If success returns a string, what must failure return?"
|
|
78
|
+
4. **`withFallback` error type** — when both primary and fallback can fail, the resulting error type is the fallback's error type. Students may be surprised that the primary's error disappears.
|
|
79
|
+
5. **Start with `handleAllErrors`** — use `Effect.catchTags` with an object mapping each `_tag` to a recovery function; check the error type definitions in solution.ts for the exact `_tag` values to handle.
|
|
80
|
+
|
|
81
|
+
## On Completion
|
|
82
|
+
|
|
83
|
+
### Insight
|
|
84
|
+
|
|
85
|
+
`catchTags` is exhaustive — if you handle all error types, the error channel becomes `never`. This is the compiler helping you handle every case. `orElse` is for "try plan B" scenarios where you don't care why plan A failed. `match` collapses both channels into one, eliminating the error type entirely. Together with `catchAll` and `catchTag` from earlier katas, you now have a complete toolkit for error recovery.
|
|
86
|
+
|
|
87
|
+
### Bridge
|
|
88
|
+
|
|
89
|
+
Error handling is now complete. The next area is **Value Handling** — starting with `Option` in kata 009. Option represents values that might not exist, which is different from errors. Use Option when absence is a normal case (e.g., looking up a key), not an exceptional condition.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { handleAllErrors, withFallback, toResult, NetworkError, TimeoutError, AuthError } from "@/katas/008-error-patterns/solution.js";
|
|
4
|
+
|
|
5
|
+
describe("008 — Error Patterns", () => {
|
|
6
|
+
it("handleAllErrors handles NetworkError", () => {
|
|
7
|
+
const effect = Effect.fail(new NetworkError({ url: "https://api.com" }));
|
|
8
|
+
expect(Effect.runSync(handleAllErrors(effect))).toBe("network error: https://api.com");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("handleAllErrors handles TimeoutError", () => {
|
|
12
|
+
const effect = Effect.fail(new TimeoutError({ ms: 5000 }));
|
|
13
|
+
expect(Effect.runSync(handleAllErrors(effect))).toBe("timeout after 5000ms");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("handleAllErrors handles AuthError", () => {
|
|
17
|
+
const effect = Effect.fail(new AuthError({ reason: "expired token" }));
|
|
18
|
+
expect(Effect.runSync(handleAllErrors(effect))).toBe("auth failed: expired token");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("handleAllErrors passes through success", () => {
|
|
22
|
+
const effect = Effect.succeed("data") as Effect.Effect<string, NetworkError | TimeoutError | AuthError>;
|
|
23
|
+
expect(Effect.runSync(handleAllErrors(effect))).toBe("data");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("withFallback uses primary on success", () => {
|
|
27
|
+
expect(Effect.runSync(withFallback(Effect.succeed("primary"), Effect.succeed("fallback")))).toBe("primary");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("withFallback uses fallback on failure", () => {
|
|
31
|
+
expect(Effect.runSync(withFallback(Effect.fail("oops"), Effect.succeed("fallback")))).toBe("fallback");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("toResult wraps success", () => {
|
|
35
|
+
expect(Effect.runSync(toResult(Effect.succeed("hello")))).toBe("ok: hello");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("toResult wraps failure", () => {
|
|
39
|
+
expect(Effect.runSync(toResult(Effect.fail("boom")))).toBe("err: boom");
|
|
40
|
+
});
|
|
41
|
+
});
|