@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,38 @@
|
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
export class NetworkError extends Data.TaggedError("NetworkError")<{
|
|
4
|
+
readonly url: string;
|
|
5
|
+
}> {}
|
|
6
|
+
|
|
7
|
+
export class TimeoutError extends Data.TaggedError("TimeoutError")<{
|
|
8
|
+
readonly ms: number;
|
|
9
|
+
}> {}
|
|
10
|
+
|
|
11
|
+
export class AuthError extends Data.TaggedError("AuthError")<{
|
|
12
|
+
readonly reason: string;
|
|
13
|
+
}> {}
|
|
14
|
+
|
|
15
|
+
/** Use Effect.catchTags to handle each error type differently:
|
|
16
|
+
* NetworkError → "network error: {url}"
|
|
17
|
+
* TimeoutError → "timeout after {ms}ms"
|
|
18
|
+
* AuthError → "auth failed: {reason}" */
|
|
19
|
+
export const handleAllErrors = (
|
|
20
|
+
effect: Effect.Effect<string, NetworkError | TimeoutError | AuthError>,
|
|
21
|
+
): Effect.Effect<string> => {
|
|
22
|
+
throw new Error("Not implemented");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Use Effect.orElse to try the primary effect, and if it fails, run the fallback */
|
|
26
|
+
export const withFallback = (
|
|
27
|
+
primary: Effect.Effect<string, string>,
|
|
28
|
+
fallback: Effect.Effect<string, string>,
|
|
29
|
+
): Effect.Effect<string, string> => {
|
|
30
|
+
throw new Error("Not implemented");
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Use Effect.match to return "ok: {value}" on success or "err: {error}" on failure */
|
|
34
|
+
export const toResult = (
|
|
35
|
+
effect: Effect.Effect<string, string>,
|
|
36
|
+
): Effect.Effect<string> => {
|
|
37
|
+
throw new Error("Not implemented");
|
|
38
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# SENSEI — 009 Option Type
|
|
2
|
+
|
|
3
|
+
## Briefing
|
|
4
|
+
|
|
5
|
+
### Goal
|
|
6
|
+
|
|
7
|
+
Work with the Option type to safely represent values that may or may not exist.
|
|
8
|
+
|
|
9
|
+
### Tasks
|
|
10
|
+
|
|
11
|
+
1. Implement `fromNullable` — convert a nullable value to an Option
|
|
12
|
+
2. Implement `describe` — use `Option.match` to return "Found: {value}" or "Nothing"
|
|
13
|
+
3. Implement `doubleOption` — use `Option.map` to double the number inside an Option
|
|
14
|
+
4. Implement `safeDivide` — use `Option.flatMap` to divide safely (None if divisor is 0)
|
|
15
|
+
5. Implement `getOrDefault` — use `Option.getOrElse` to extract value or return a default
|
|
16
|
+
|
|
17
|
+
### Hints
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { Option } from "effect";
|
|
21
|
+
|
|
22
|
+
// Create Options
|
|
23
|
+
const some = Option.some(42);
|
|
24
|
+
const none = Option.none();
|
|
25
|
+
|
|
26
|
+
// fromNullable
|
|
27
|
+
const opt = Option.fromNullable(null); // None
|
|
28
|
+
const opt2 = Option.fromNullable(42); // Some(42)
|
|
29
|
+
|
|
30
|
+
// match
|
|
31
|
+
const result = Option.match(opt, {
|
|
32
|
+
onNone: () => "nothing",
|
|
33
|
+
onSome: (v) => `found: ${v}`,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// map and flatMap
|
|
37
|
+
const doubled = Option.map(some, (n) => n * 2);
|
|
38
|
+
const chained = Option.flatMap(some, (n) =>
|
|
39
|
+
n > 0 ? Option.some(n) : Option.none()
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// getOrElse
|
|
43
|
+
const value = Option.getOrElse(none, () => 0);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Prerequisites
|
|
47
|
+
|
|
48
|
+
- **001-005 Basics** — `Effect.succeed`, `Effect.map`, `pipe`, `Effect.gen`, `Effect.flatMap`
|
|
49
|
+
- **006-008 Error Handling** — `Effect.fail`, `catchAll`, `catchTag`, `catchTags`, `match`
|
|
50
|
+
|
|
51
|
+
## Skills
|
|
52
|
+
|
|
53
|
+
Invoke `effect-patterns-value-handling` before teaching this kata.
|
|
54
|
+
|
|
55
|
+
## Test Map
|
|
56
|
+
|
|
57
|
+
| Test | Concept | Verifies |
|
|
58
|
+
|------|---------|----------|
|
|
59
|
+
| `fromNullable converts value to Some` | `Option.fromNullable` | Non-null value wrapped in Some |
|
|
60
|
+
| `fromNullable converts null to None` | `Option.fromNullable` | Null becomes None |
|
|
61
|
+
| `fromNullable converts undefined to None` | `Option.fromNullable` | Undefined becomes None |
|
|
62
|
+
| `describe returns 'Found: hello' for Some` | `Option.match` | Pattern matching on Some |
|
|
63
|
+
| `describe returns 'Nothing' for None` | `Option.match` | Pattern matching on None |
|
|
64
|
+
| `doubleOption doubles Some(5) to Some(10)` | `Option.map` | Transform inside Some |
|
|
65
|
+
| `doubleOption returns None for None` | `Option.map` | None passes through unchanged |
|
|
66
|
+
| `safeDivide(10, 2) returns Some(5)` | `Option.some` + `Option.flatMap` | Division succeeds — Some result |
|
|
67
|
+
| `safeDivide(10, 0) returns None` | `Option.none` | Division by zero — None result |
|
|
68
|
+
| `getOrDefault extracts Some value` | `Option.getOrElse` | Unwrap when value exists |
|
|
69
|
+
| `getOrDefault returns default for None` | `Option.getOrElse` | Fallback when value is absent |
|
|
70
|
+
|
|
71
|
+
## Teaching Approach
|
|
72
|
+
|
|
73
|
+
### Socratic prompts
|
|
74
|
+
|
|
75
|
+
- "What's the difference between `null` and `Option.none()`? Why bother wrapping it?"
|
|
76
|
+
- "You used `Effect.map` to transform Effects. `Option.map` works the same way. What does it do when the Option is None?"
|
|
77
|
+
- "How is `Option.flatMap` different from `Option.map`? When would your callback need to return an Option?"
|
|
78
|
+
- "In `safeDivide`, the result is `Option<number>`, not `Effect<number, DivisionByZero>`. When would you choose Option over Effect with an error?"
|
|
79
|
+
|
|
80
|
+
### Common pitfalls
|
|
81
|
+
|
|
82
|
+
1. **Confusing Option.flatMap with Effect.flatMap** — they work the same way but on different types. Option.flatMap takes `A => Option<B>`, Effect.flatMap takes `A => Effect<B, E, R>`. Ask: "Are you working with an Option or an Effect here?"
|
|
83
|
+
2. **Using if/else instead of Option.match** — students may extract the value manually. Nudge: "Option.match handles both cases in one expression — what does it look like?"
|
|
84
|
+
3. **fromNullable with falsy values** — `Option.fromNullable(0)` returns `Some(0)`, not `None`. Only `null` and `undefined` produce None. Ask: "Is `0` the same as `null`?"
|
|
85
|
+
4. **Returning raw values from map** — `Option.map(opt, (n) => n * 2)` is correct. Students sometimes try to wrap the result in `Option.some` inside the callback. Ask: "What does `map` do with your callback's return value?"
|
|
86
|
+
5. **Start with `fromNullable`** — just pass the value to `Option.fromNullable` and return the result; it's the simplest function here.
|
|
87
|
+
|
|
88
|
+
## On Completion
|
|
89
|
+
|
|
90
|
+
### Insight
|
|
91
|
+
|
|
92
|
+
Option is for "might not exist" — different from errors. Use Option when absence is a normal case (e.g., looking up a key in a map), not an error condition. Notice how Option has the same vocabulary as Effect: `map`, `flatMap`, `match`. This isn't a coincidence — Effect's design reuses these patterns across types so that once you learn them, they transfer everywhere.
|
|
93
|
+
|
|
94
|
+
### Bridge
|
|
95
|
+
|
|
96
|
+
Option handles missing values. Kata 010 introduces `Either` for pure validation results (no effects needed) and `Exit` for inspecting effect outcomes after execution. `Effect.either` bridges the two worlds — converting an Effect's error channel into an Either value.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Option } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { fromNullable, describeOption, doubleOption, safeDivide, getOrDefault } from "@/katas/009-option-type/solution.js";
|
|
4
|
+
|
|
5
|
+
describe("009 — Option Type", () => {
|
|
6
|
+
it("fromNullable converts value to Some", () => {
|
|
7
|
+
expect(fromNullable(42)).toEqual(Option.some(42));
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("fromNullable converts null to None", () => {
|
|
11
|
+
expect(fromNullable(null)).toEqual(Option.none());
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("fromNullable converts undefined to None", () => {
|
|
15
|
+
expect(fromNullable(undefined)).toEqual(Option.none());
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("describe returns 'Found: hello' for Some", () => {
|
|
19
|
+
expect(describeOption(Option.some("hello"))).toBe("Found: hello");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("describe returns 'Nothing' for None", () => {
|
|
23
|
+
expect(describeOption(Option.none())).toBe("Nothing");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("doubleOption doubles Some(5) to Some(10)", () => {
|
|
27
|
+
expect(doubleOption(Option.some(5))).toEqual(Option.some(10));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("doubleOption returns None for None", () => {
|
|
31
|
+
expect(doubleOption(Option.none())).toEqual(Option.none());
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("safeDivide(10, 2) returns Some(5)", () => {
|
|
35
|
+
expect(safeDivide(10, 2)).toEqual(Option.some(5));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("safeDivide(10, 0) returns None", () => {
|
|
39
|
+
expect(safeDivide(10, 0)).toEqual(Option.none());
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("getOrDefault extracts Some value", () => {
|
|
43
|
+
expect(getOrDefault(Option.some(42), 0)).toBe(42);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("getOrDefault returns default for None", () => {
|
|
47
|
+
expect(getOrDefault(Option.none(), 0)).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Option } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Convert a nullable value to an Option */
|
|
4
|
+
export const fromNullable = <A>(value: A | null | undefined): Option.Option<A> => {
|
|
5
|
+
throw new Error("Not implemented");
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Use Option.match to return "Found: {value}" for Some, "Nothing" for None */
|
|
9
|
+
export const describeOption = (opt: Option.Option<string>): string => {
|
|
10
|
+
throw new Error("Not implemented");
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Use Option.map to double the number inside the Option */
|
|
14
|
+
export const doubleOption = (opt: Option.Option<number>): Option.Option<number> => {
|
|
15
|
+
throw new Error("Not implemented");
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Use Option.flatMap to safely divide a by b (return None if b is 0) */
|
|
19
|
+
export const safeDivide = (a: number, b: number): Option.Option<number> => {
|
|
20
|
+
throw new Error("Not implemented");
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Use Option.getOrElse to extract value or return a default */
|
|
24
|
+
export const getOrDefault = (opt: Option.Option<number>, defaultValue: number): number => {
|
|
25
|
+
throw new Error("Not implemented");
|
|
26
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SENSEI — 010 Either and Exit
|
|
2
|
+
|
|
3
|
+
## Briefing
|
|
4
|
+
|
|
5
|
+
### Goal
|
|
6
|
+
|
|
7
|
+
Use Either for pure validation and Exit to inspect effect results without throwing.
|
|
8
|
+
|
|
9
|
+
### Tasks
|
|
10
|
+
|
|
11
|
+
1. Implement `safeRun` — use `Effect.either` and `Either.match` to return "ok: {value}" or "err: {error}"
|
|
12
|
+
2. Implement `validatePositive` — return `Either.right(n)` if n > 0, `Either.left("not positive")` otherwise
|
|
13
|
+
3. Implement `inspectExit` — use `Effect.runSyncExit` and `Exit.match` to return "success: {value}" or "failure"
|
|
14
|
+
|
|
15
|
+
### Hints
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Effect, Either, Exit } from "effect";
|
|
19
|
+
|
|
20
|
+
// Effect.either converts error channel to Either
|
|
21
|
+
const safe = effect.pipe(
|
|
22
|
+
Effect.either,
|
|
23
|
+
Effect.map(
|
|
24
|
+
Either.match({
|
|
25
|
+
onLeft: (e) => `err: ${e}`,
|
|
26
|
+
onRight: (a) => `ok: ${a}`,
|
|
27
|
+
})
|
|
28
|
+
)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// Either for pure validation
|
|
32
|
+
const validated = n > 0 ? Either.right(n) : Either.left("invalid");
|
|
33
|
+
|
|
34
|
+
// Exit inspection
|
|
35
|
+
const exit = Effect.runSyncExit(effect);
|
|
36
|
+
const result = Exit.match(exit, {
|
|
37
|
+
onFailure: () => "failed",
|
|
38
|
+
onSuccess: (a) => `success: ${a}`,
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Prerequisites
|
|
43
|
+
|
|
44
|
+
- **009 Option Type** — `Option`, `some`, `none`, `match`, `map`, `flatMap`
|
|
45
|
+
|
|
46
|
+
> **Note**: Unlike previous katas, the user DOES write `Effect.runSyncExit` and `Exit.match` in `inspectExit`. This is intentional — inspecting an Exit is the lesson.
|
|
47
|
+
|
|
48
|
+
## Test Map
|
|
49
|
+
|
|
50
|
+
| Test | Concept | Verifies |
|
|
51
|
+
|------|---------|----------|
|
|
52
|
+
| `safeRun wraps success` | `Effect.either` + `Either.match` | Success becomes Right, matched to string |
|
|
53
|
+
| `safeRun wraps failure` | `Effect.either` + `Either.match` | Failure becomes Left, matched to string |
|
|
54
|
+
| `validatePositive returns Right for positive` | `Either.right` | Pure validation — valid input |
|
|
55
|
+
| `validatePositive returns Left for zero` | `Either.left` | Pure validation — zero rejected |
|
|
56
|
+
| `validatePositive returns Left for negative` | `Either.left` | Pure validation — negative rejected |
|
|
57
|
+
| `inspectExit returns success string` | `Effect.runSyncExit` + `Exit.match` | Execute and inspect successful exit |
|
|
58
|
+
| `inspectExit returns failure string` | `Effect.runSyncExit` + `Exit.match` | Execute and inspect failed exit |
|
|
59
|
+
|
|
60
|
+
## Teaching Approach
|
|
61
|
+
|
|
62
|
+
### Socratic prompts
|
|
63
|
+
|
|
64
|
+
- "You've used `Effect.catchAll` to recover from errors. What if instead of recovering, you just want to see whether it succeeded or failed — without losing the information?"
|
|
65
|
+
- "`Effect.either` turns `Effect<A, E>` into `Effect<Either<A, E>, never>`. The error channel becomes `never`. Where did the error go?"
|
|
66
|
+
- "Either doesn't need an Effect to be useful. `validatePositive` is a pure function. When would you use Either without Effect?"
|
|
67
|
+
- "Exit looks a lot like Either. What's the difference? When do you encounter an Exit?"
|
|
68
|
+
|
|
69
|
+
### Common pitfalls
|
|
70
|
+
|
|
71
|
+
1. **Confusing Either and Effect** — Either is a pure data structure (Left or Right), not an Effect. `Either.right(5)` is a value, not a computation. Ask: "Does `Either.right(5)` need to be run?"
|
|
72
|
+
2. **Either.match argument order** — `Either.match` takes `{ onLeft, onRight }`. Students may mix up which handler is for success vs failure. Ask: "In Either, which side is the success — Left or Right?"
|
|
73
|
+
3. **Exit vs Either** — Exit is what you get after running an Effect. It's similar to Either but lives in the "execution" world, not the "pure data" world. Ask: "When do you get an Exit? Before or after running an Effect?"
|
|
74
|
+
4. **Forgetting Effect.either in safeRun** — students may try to use try/catch or Effect.catchAll. Nudge: "`Effect.either` gives you the Either directly — no catching needed."
|
|
75
|
+
5. **inspectExit uses runSyncExit** — this is the one place in the kata series where the user writes runtime execution code. It's intentional. Ask: "Why would you want to inspect the Exit rather than just running the Effect normally?"
|
|
76
|
+
6. **Start with `validatePositive`** — it's pure, no Effects: if `n` is positive, return `Either.right(n)`, otherwise return `Either.left` with an error message.
|
|
77
|
+
|
|
78
|
+
## On Completion
|
|
79
|
+
|
|
80
|
+
### Insight
|
|
81
|
+
|
|
82
|
+
Either is for pure validation (no effects needed). Exit is for inspecting effect outcomes after execution. `Effect.either` bridges the two worlds — converting an Effect's error channel into an Either value. Notice the pattern: Option (might not exist), Either (might be wrong), Exit (might have failed at runtime). Each represents a different kind of "two outcomes" at a different level of abstraction.
|
|
83
|
+
|
|
84
|
+
### Bridge
|
|
85
|
+
|
|
86
|
+
Value Handling is now complete. You can model missing values (Option), validation results (Either), and execution outcomes (Exit). Next up is **Dependency Injection** — kata 011 introduces `Context.Tag` and `provideService`, giving your Effects access to external services without hardcoding them.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Effect, Either } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { safeRun, validatePositive, inspectExit } from "@/katas/010-either-and-exit/solution.js";
|
|
4
|
+
|
|
5
|
+
describe("010 — Either and Exit", () => {
|
|
6
|
+
it("safeRun wraps success", () => {
|
|
7
|
+
expect(Effect.runSync(safeRun(Effect.succeed("hello")))).toBe("ok: hello");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("safeRun wraps failure", () => {
|
|
11
|
+
expect(Effect.runSync(safeRun(Effect.fail("boom")))).toBe("err: boom");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("validatePositive returns Right for positive", () => {
|
|
15
|
+
expect(validatePositive(5)).toEqual(Either.right(5));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("validatePositive returns Left for zero", () => {
|
|
19
|
+
expect(Either.isLeft(validatePositive(0))).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("validatePositive returns Left for negative", () => {
|
|
23
|
+
expect(Either.isLeft(validatePositive(-3))).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("inspectExit returns success string", () => {
|
|
27
|
+
expect(inspectExit(Effect.succeed("data"))).toBe("success: data");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("inspectExit returns failure string", () => {
|
|
31
|
+
expect(inspectExit(Effect.fail("oops"))).toBe("failure");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Effect, Either, Exit } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Use Effect.either to convert an Effect<A, E> into Effect<Either<A, E>>
|
|
4
|
+
* then use Either.match to return "ok: {value}" or "err: {error}" */
|
|
5
|
+
export const safeRun = (effect: Effect.Effect<string, string>): Effect.Effect<string> => {
|
|
6
|
+
throw new Error("Not implemented");
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Use Either.right and Either.left to validate: return Right(n) if n > 0, Left("not positive") otherwise */
|
|
10
|
+
export const validatePositive = (n: number): Either.Either<number, string> => {
|
|
11
|
+
throw new Error("Not implemented");
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Use Effect.runSyncExit and Exit.match to return "success: {value}" or "failure" */
|
|
15
|
+
export const inspectExit = (effect: Effect.Effect<string, string>): string => {
|
|
16
|
+
throw new Error("Not implemented");
|
|
17
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# SENSEI — 011 Services and Context
|
|
2
|
+
|
|
3
|
+
## Briefing
|
|
4
|
+
|
|
5
|
+
### Goal
|
|
6
|
+
|
|
7
|
+
Learn how Effect manages dependencies through the R (Requirements) channel.
|
|
8
|
+
|
|
9
|
+
### Tasks
|
|
10
|
+
|
|
11
|
+
1. Implement `getRandomNumber` — access the `Random` service and return its `next` value
|
|
12
|
+
2. Implement `rollDice` — access the `Random` service and return `"Roll: {n}"` where `n` is the random value
|
|
13
|
+
|
|
14
|
+
### Hints
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { Context, Effect } from "effect";
|
|
18
|
+
|
|
19
|
+
// Declare a service with Context.Tag
|
|
20
|
+
class MyService extends Context.Tag("MyService")<
|
|
21
|
+
MyService,
|
|
22
|
+
{ readonly getValue: Effect.Effect<number> }
|
|
23
|
+
>() {}
|
|
24
|
+
|
|
25
|
+
// Access the service inside Effect.gen
|
|
26
|
+
const program = Effect.gen(function* () {
|
|
27
|
+
const svc = yield* MyService;
|
|
28
|
+
const value = yield* svc.getValue;
|
|
29
|
+
return value;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Provide a concrete implementation
|
|
33
|
+
const result = Effect.runSync(
|
|
34
|
+
Effect.provideService(program, MyService, {
|
|
35
|
+
getValue: Effect.succeed(42),
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Prerequisites
|
|
41
|
+
|
|
42
|
+
- **001-005 Basics** — `Effect.succeed`, `Effect.sync`, `Effect.map`, `pipe`, `Effect.gen`, `yield*`, `Effect.flatMap`
|
|
43
|
+
- **006-008 Error Handling** — `Effect.fail`, `catchAll`, `catchTag`, `Data.TaggedError`
|
|
44
|
+
- **009-010 Value Handling** — `Option`, `Either`, `Exit`
|
|
45
|
+
|
|
46
|
+
## Skills
|
|
47
|
+
|
|
48
|
+
Invoke `effect-patterns-core-concepts` before teaching this kata. This is the first kata in the Dependency Injection area.
|
|
49
|
+
|
|
50
|
+
> **Note**: `Effect.runSync` and `Effect.provideService` appear only in tests. The student does NOT write them. Never attribute them to their learning.
|
|
51
|
+
|
|
52
|
+
## Test Map
|
|
53
|
+
|
|
54
|
+
| Test | Concept | Verifies |
|
|
55
|
+
|------|---------|----------|
|
|
56
|
+
| `getRandomNumber returns the service value` | `yield* Random` + `yield* svc.next` | Accessing a service and calling its method |
|
|
57
|
+
| `rollDice formats the result` | `yield* Random` + `yield* svc.next` + string formatting | Service access plus value transformation |
|
|
58
|
+
|
|
59
|
+
## Teaching Approach
|
|
60
|
+
|
|
61
|
+
### Socratic prompts
|
|
62
|
+
|
|
63
|
+
- "You've used `yield*` to unwrap Effects. What happens if you `yield*` a Context.Tag like `Random`?"
|
|
64
|
+
- "After you get the service with `yield* Random`, you have an object with a `next` property. What is `next` — a value or an Effect? How do you get the value out?"
|
|
65
|
+
- "Look at the type of `getRandomNumber` — what does the `R` channel (the third type parameter) tell you?"
|
|
66
|
+
|
|
67
|
+
### Common pitfalls
|
|
68
|
+
|
|
69
|
+
1. **Calling Random directly** — `Random.next` doesn't exist. `Random` is a tag, not an instance. You need `yield* Random` first to get the service implementation, THEN access `.next` on it. Ask: "What does `Random` represent — the service itself, or a key to look it up?"
|
|
70
|
+
2. **Forgetting the second yield*** — `yield* Random` gives you the service object, but `svc.next` is still an Effect. You need a second `yield*` to unwrap it. Ask: "What's the type of `svc.next`? Is it a number or an Effect?"
|
|
71
|
+
3. **String formatting in rollDice** — students may return the number instead of the formatted string. The test expects `"Roll: 4"`. Nudge: "Check the test — what exact string format does `rollDice` need to produce?"
|
|
72
|
+
4. **Two-step yield pattern** — inside `Effect.gen`, first `yield*` the `Random` tag to get the service, then `yield*` its `next` method to get the number. The Briefing hints show this pattern.
|
|
73
|
+
|
|
74
|
+
## On Completion
|
|
75
|
+
|
|
76
|
+
### Insight
|
|
77
|
+
|
|
78
|
+
`yield* Random` doesn't call anything — it asks Effect's runtime to provide the Random service. The `R` type parameter tracks what's needed. This is dependency injection at the type level: your program declares its dependencies in its type signature, and the runtime satisfies them. Notice how the tests provide a `TestRandom` with a predictable value — your code never knew or cared where the implementation came from.
|
|
79
|
+
|
|
80
|
+
### Bridge
|
|
81
|
+
|
|
82
|
+
You've seen how to USE services, but the tests had to manually wire them with `provideService`. Kata 012 introduces **Layers** — reusable recipes for building services. Layers decouple "how a service is built" from "how it's used", making large programs composable.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { Random, getRandomNumber, rollDice } from "@/katas/011-services-and-context/solution.js";
|
|
4
|
+
|
|
5
|
+
const TestRandom = {
|
|
6
|
+
next: Effect.succeed(4),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe("011 — Services and Context", () => {
|
|
10
|
+
it("getRandomNumber returns the service value", () => {
|
|
11
|
+
const result = Effect.runSync(
|
|
12
|
+
Effect.provideService(getRandomNumber, Random, TestRandom),
|
|
13
|
+
);
|
|
14
|
+
expect(result).toBe(4);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("rollDice formats the result", () => {
|
|
18
|
+
const result = Effect.runSync(
|
|
19
|
+
Effect.provideService(rollDice, Random, TestRandom),
|
|
20
|
+
);
|
|
21
|
+
expect(result).toBe("Roll: 4");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
// A simple "Random" service that produces random numbers
|
|
4
|
+
export class Random extends Context.Tag("Random")<
|
|
5
|
+
Random,
|
|
6
|
+
{ readonly next: Effect.Effect<number> }
|
|
7
|
+
>() {}
|
|
8
|
+
|
|
9
|
+
/** Use Effect.flatMap or Effect.gen to access the Random service and return its next value */
|
|
10
|
+
export const getRandomNumber = Effect.gen(function* () {
|
|
11
|
+
throw new Error("Not implemented");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/** Access Random service and return "Roll: {n}" where n is the random value */
|
|
15
|
+
export const rollDice = Effect.gen(function* () {
|
|
16
|
+
throw new Error("Not implemented");
|
|
17
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# SENSEI — 012 Layers
|
|
2
|
+
|
|
3
|
+
## Briefing
|
|
4
|
+
|
|
5
|
+
### Goal
|
|
6
|
+
|
|
7
|
+
Learn how to compose service implementations using Layers.
|
|
8
|
+
|
|
9
|
+
### Tasks
|
|
10
|
+
|
|
11
|
+
1. Implement `ConfigLive` — a Layer that provides `Config` with `baseUrl` set to `"https://api.example.com"`
|
|
12
|
+
2. Implement `LoggerLive` — a Layer that provides `Logger` with a `log` function that uses `Effect.log`
|
|
13
|
+
3. Implement `getEndpoint` — read `Config.baseUrl` and `Logger.log` to return `"{baseUrl}/users"`
|
|
14
|
+
|
|
15
|
+
### Hints
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Context, Effect, Layer } from "effect";
|
|
19
|
+
|
|
20
|
+
// Create a Layer from a value
|
|
21
|
+
const MyLayer = Layer.succeed(MyService, { value: 42 });
|
|
22
|
+
|
|
23
|
+
// Create a Layer from an effect
|
|
24
|
+
const MyEffectLayer = Layer.effect(
|
|
25
|
+
MyService,
|
|
26
|
+
Effect.succeed({ value: 42 }),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Merge layers
|
|
30
|
+
const Combined = Layer.merge(LayerA, LayerB);
|
|
31
|
+
|
|
32
|
+
// Provide a layer to a program
|
|
33
|
+
const result = Effect.runSync(Effect.provide(program, Combined));
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Prerequisites
|
|
37
|
+
|
|
38
|
+
- **011 Services and Context** — `Context.Tag`, `yield*` on a tag, `yield*` on a service method
|
|
39
|
+
|
|
40
|
+
> **Note**: `Effect.runSync`, `Effect.provide`, `Layer.merge`, and `Layer.succeed` (in tests) appear only in tests. Never attribute them to the user's learning.
|
|
41
|
+
|
|
42
|
+
## Test Map
|
|
43
|
+
|
|
44
|
+
| Test | Concept | Verifies |
|
|
45
|
+
|------|---------|----------|
|
|
46
|
+
| `ConfigLive provides baseUrl` | `Layer.succeed` | Creating a Layer with a static service value |
|
|
47
|
+
| `getEndpoint returns full URL` | `yield* Config` + `yield* Logger` + format | Multi-service program using two services |
|
|
48
|
+
|
|
49
|
+
## Teaching Approach
|
|
50
|
+
|
|
51
|
+
### Socratic prompts
|
|
52
|
+
|
|
53
|
+
- "In kata 011, the tests used `provideService` to wire one service at a time. What if you have TWO services? How would you provide both?"
|
|
54
|
+
- "What's the difference between `Layer.succeed(Config, { baseUrl: '...' })` and directly providing the service? When would the Layer approach be better?"
|
|
55
|
+
- "For `getEndpoint`, you need both Config and Logger. Does the order you `yield*` them matter?"
|
|
56
|
+
|
|
57
|
+
### Common pitfalls
|
|
58
|
+
|
|
59
|
+
1. **Confusing `Layer.succeed` and `Layer.effect`** — `Layer.succeed(Tag, value)` is for static values. `Layer.effect(Tag, someEffect)` is when building the service requires running an Effect. For `ConfigLive`, a static config object works. Ask: "Is the config value known upfront, or does it need computation?"
|
|
60
|
+
2. **Forgetting to call Logger.log** — `getEndpoint` needs to use the Logger service. Students may skip the logging step. Check the test to see if logging is required for the test to pass.
|
|
61
|
+
3. **Layer type annotation** — the stub uses a type cast. Students need to replace the entire implementation with a proper `Layer.succeed(Config, { ... })` call. Nudge: "Delete the placeholder and write a fresh `Layer.succeed` call."
|
|
62
|
+
4. **String concatenation in getEndpoint** — the test expects `"https://test.com/users"`. Make sure to include the `/` between baseUrl and `"users"`.
|
|
63
|
+
5. **Start with `Layer.succeed`** — the simplest form is `Layer.succeed(Config, { baseUrl: 'https://api.example.com' })`. Use that pattern for both `ConfigLive` and `LoggerLive`.
|
|
64
|
+
|
|
65
|
+
## On Completion
|
|
66
|
+
|
|
67
|
+
### Insight
|
|
68
|
+
|
|
69
|
+
Layers decouple "how a service is built" from "how it's used". The program says WHAT it needs (via the R channel), the Layer says HOW to provide it. This separation is what makes Effect programs composable and testable. Notice how the test swapped in a completely different Config and a no-op Logger — your `getEndpoint` code didn't change at all.
|
|
70
|
+
|
|
71
|
+
### Bridge
|
|
72
|
+
|
|
73
|
+
You've built services and wired them with Layers. But the real power shows up in testing. Kata 013 makes this explicit: you'll write programs against a service interface, and the tests will provide different implementations — no mocking frameworks needed.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { Config, Logger, ConfigLive, LoggerLive, getEndpoint } from "@/katas/012-layers/solution.js";
|
|
4
|
+
|
|
5
|
+
describe("012 — Layers", () => {
|
|
6
|
+
it("ConfigLive provides baseUrl", () => {
|
|
7
|
+
const program = Effect.gen(function* () {
|
|
8
|
+
const config = yield* Config;
|
|
9
|
+
return config.baseUrl;
|
|
10
|
+
});
|
|
11
|
+
const result = Effect.runSync(Effect.provide(program, ConfigLive));
|
|
12
|
+
expect(result).toBe("https://api.example.com");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("getEndpoint returns full URL", () => {
|
|
16
|
+
const TestLayer = Layer.merge(
|
|
17
|
+
Layer.succeed(Config, { baseUrl: "https://test.com" }),
|
|
18
|
+
Layer.succeed(Logger, { log: () => Effect.void }),
|
|
19
|
+
);
|
|
20
|
+
const result = Effect.runSync(Effect.provide(getEndpoint, TestLayer));
|
|
21
|
+
expect(result).toBe("https://test.com/users");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
|
|
3
|
+
export class Config extends Context.Tag("Config")<
|
|
4
|
+
Config,
|
|
5
|
+
{ readonly baseUrl: string }
|
|
6
|
+
>() {}
|
|
7
|
+
|
|
8
|
+
export class Logger extends Context.Tag("Logger")<
|
|
9
|
+
Logger,
|
|
10
|
+
{ readonly log: (msg: string) => Effect.Effect<void> }
|
|
11
|
+
>() {}
|
|
12
|
+
|
|
13
|
+
/** Create a Layer that provides Config with baseUrl "https://api.example.com" */
|
|
14
|
+
export const ConfigLive: Layer.Layer<Config> = Layer.effectDiscard(
|
|
15
|
+
Effect.die("Not implemented"),
|
|
16
|
+
) as unknown as Layer.Layer<Config>;
|
|
17
|
+
|
|
18
|
+
/** Create a Layer that provides Logger with a log function that uses Effect.log */
|
|
19
|
+
export const LoggerLive: Layer.Layer<Logger> = Layer.effectDiscard(
|
|
20
|
+
Effect.die("Not implemented"),
|
|
21
|
+
) as unknown as Layer.Layer<Logger>;
|
|
22
|
+
|
|
23
|
+
/** Create a program that reads Config.baseUrl and Logger.log to return "{baseUrl}/users" */
|
|
24
|
+
export const getEndpoint = Effect.gen(function* () {
|
|
25
|
+
throw new Error("Not implemented");
|
|
26
|
+
});
|