@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.
Files changed (149) hide show
  1. package/DOJO.md +22 -0
  2. package/dojo.json +50 -0
  3. package/katas/001-hello-effect/SENSEI.md +72 -0
  4. package/katas/001-hello-effect/solution.test.ts +35 -0
  5. package/katas/001-hello-effect/solution.ts +16 -0
  6. package/katas/002-transform-with-map/SENSEI.md +72 -0
  7. package/katas/002-transform-with-map/solution.test.ts +33 -0
  8. package/katas/002-transform-with-map/solution.ts +16 -0
  9. package/katas/003-generator-pipelines/SENSEI.md +72 -0
  10. package/katas/003-generator-pipelines/solution.test.ts +40 -0
  11. package/katas/003-generator-pipelines/solution.ts +29 -0
  12. package/katas/004-flatmap-and-chaining/SENSEI.md +80 -0
  13. package/katas/004-flatmap-and-chaining/solution.test.ts +34 -0
  14. package/katas/004-flatmap-and-chaining/solution.ts +18 -0
  15. package/katas/005-pipe-composition/SENSEI.md +81 -0
  16. package/katas/005-pipe-composition/solution.test.ts +41 -0
  17. package/katas/005-pipe-composition/solution.ts +19 -0
  18. package/katas/006-handle-errors/SENSEI.md +86 -0
  19. package/katas/006-handle-errors/solution.test.ts +53 -0
  20. package/katas/006-handle-errors/solution.ts +30 -0
  21. package/katas/007-tagged-errors/SENSEI.md +79 -0
  22. package/katas/007-tagged-errors/solution.test.ts +82 -0
  23. package/katas/007-tagged-errors/solution.ts +37 -0
  24. package/katas/008-error-patterns/SENSEI.md +89 -0
  25. package/katas/008-error-patterns/solution.test.ts +41 -0
  26. package/katas/008-error-patterns/solution.ts +38 -0
  27. package/katas/009-option-type/SENSEI.md +96 -0
  28. package/katas/009-option-type/solution.test.ts +49 -0
  29. package/katas/009-option-type/solution.ts +26 -0
  30. package/katas/010-either-and-exit/SENSEI.md +86 -0
  31. package/katas/010-either-and-exit/solution.test.ts +33 -0
  32. package/katas/010-either-and-exit/solution.ts +17 -0
  33. package/katas/011-services-and-context/SENSEI.md +82 -0
  34. package/katas/011-services-and-context/solution.test.ts +23 -0
  35. package/katas/011-services-and-context/solution.ts +17 -0
  36. package/katas/012-layers/SENSEI.md +73 -0
  37. package/katas/012-layers/solution.test.ts +23 -0
  38. package/katas/012-layers/solution.ts +26 -0
  39. package/katas/013-testing-effects/SENSEI.md +88 -0
  40. package/katas/013-testing-effects/solution.test.ts +41 -0
  41. package/katas/013-testing-effects/solution.ts +20 -0
  42. package/katas/014-schema-basics/SENSEI.md +81 -0
  43. package/katas/014-schema-basics/solution.test.ts +35 -0
  44. package/katas/014-schema-basics/solution.ts +25 -0
  45. package/katas/015-domain-modeling/SENSEI.md +85 -0
  46. package/katas/015-domain-modeling/solution.test.ts +46 -0
  47. package/katas/015-domain-modeling/solution.ts +42 -0
  48. package/katas/016-retry-and-schedule/SENSEI.md +72 -0
  49. package/katas/016-retry-and-schedule/solution.test.ts +26 -0
  50. package/katas/016-retry-and-schedule/solution.ts +23 -0
  51. package/katas/017-parallel-effects/SENSEI.md +70 -0
  52. package/katas/017-parallel-effects/solution.test.ts +33 -0
  53. package/katas/017-parallel-effects/solution.ts +17 -0
  54. package/katas/018-race-and-timeout/SENSEI.md +75 -0
  55. package/katas/018-race-and-timeout/solution.test.ts +30 -0
  56. package/katas/018-race-and-timeout/solution.ts +27 -0
  57. package/katas/019-ref-and-state/SENSEI.md +72 -0
  58. package/katas/019-ref-and-state/solution.test.ts +29 -0
  59. package/katas/019-ref-and-state/solution.ts +16 -0
  60. package/katas/020-fibers/SENSEI.md +80 -0
  61. package/katas/020-fibers/solution.test.ts +23 -0
  62. package/katas/020-fibers/solution.ts +23 -0
  63. package/katas/021-acquire-release/SENSEI.md +57 -0
  64. package/katas/021-acquire-release/solution.test.ts +23 -0
  65. package/katas/021-acquire-release/solution.ts +22 -0
  66. package/katas/022-scoped-layers/SENSEI.md +52 -0
  67. package/katas/022-scoped-layers/solution.test.ts +35 -0
  68. package/katas/022-scoped-layers/solution.ts +19 -0
  69. package/katas/023-resource-patterns/SENSEI.md +52 -0
  70. package/katas/023-resource-patterns/solution.test.ts +20 -0
  71. package/katas/023-resource-patterns/solution.ts +13 -0
  72. package/katas/024-streams-basics/SENSEI.md +61 -0
  73. package/katas/024-streams-basics/solution.test.ts +30 -0
  74. package/katas/024-streams-basics/solution.ts +16 -0
  75. package/katas/025-stream-operations/SENSEI.md +59 -0
  76. package/katas/025-stream-operations/solution.test.ts +26 -0
  77. package/katas/025-stream-operations/solution.ts +17 -0
  78. package/katas/026-combining-streams/SENSEI.md +54 -0
  79. package/katas/026-combining-streams/solution.test.ts +20 -0
  80. package/katas/026-combining-streams/solution.ts +16 -0
  81. package/katas/027-data-pipelines/SENSEI.md +58 -0
  82. package/katas/027-data-pipelines/solution.test.ts +22 -0
  83. package/katas/027-data-pipelines/solution.ts +16 -0
  84. package/katas/028-logging-and-spans/SENSEI.md +58 -0
  85. package/katas/028-logging-and-spans/solution.test.ts +50 -0
  86. package/katas/028-logging-and-spans/solution.ts +20 -0
  87. package/katas/029-http-client/SENSEI.md +59 -0
  88. package/katas/029-http-client/solution.test.ts +49 -0
  89. package/katas/029-http-client/solution.ts +24 -0
  90. package/katas/030-capstone/SENSEI.md +63 -0
  91. package/katas/030-capstone/solution.test.ts +67 -0
  92. package/katas/030-capstone/solution.ts +55 -0
  93. package/katas/031-config-and-environment/SENSEI.md +77 -0
  94. package/katas/031-config-and-environment/solution.test.ts +38 -0
  95. package/katas/031-config-and-environment/solution.ts +11 -0
  96. package/katas/032-cause-and-defects/SENSEI.md +90 -0
  97. package/katas/032-cause-and-defects/solution.test.ts +50 -0
  98. package/katas/032-cause-and-defects/solution.ts +23 -0
  99. package/katas/033-pattern-matching/SENSEI.md +86 -0
  100. package/katas/033-pattern-matching/solution.test.ts +36 -0
  101. package/katas/033-pattern-matching/solution.ts +28 -0
  102. package/katas/034-deferred-and-coordination/SENSEI.md +85 -0
  103. package/katas/034-deferred-and-coordination/solution.test.ts +25 -0
  104. package/katas/034-deferred-and-coordination/solution.ts +24 -0
  105. package/katas/035-queue-and-backpressure/SENSEI.md +100 -0
  106. package/katas/035-queue-and-backpressure/solution.test.ts +25 -0
  107. package/katas/035-queue-and-backpressure/solution.ts +21 -0
  108. package/katas/036-schema-advanced/SENSEI.md +81 -0
  109. package/katas/036-schema-advanced/solution.test.ts +55 -0
  110. package/katas/036-schema-advanced/solution.ts +19 -0
  111. package/katas/037-cache-and-memoization/SENSEI.md +73 -0
  112. package/katas/037-cache-and-memoization/solution.test.ts +47 -0
  113. package/katas/037-cache-and-memoization/solution.ts +24 -0
  114. package/katas/038-metrics/SENSEI.md +91 -0
  115. package/katas/038-metrics/solution.test.ts +39 -0
  116. package/katas/038-metrics/solution.ts +23 -0
  117. package/katas/039-managed-runtime/SENSEI.md +75 -0
  118. package/katas/039-managed-runtime/solution.test.ts +29 -0
  119. package/katas/039-managed-runtime/solution.ts +19 -0
  120. package/katas/040-request-batching/SENSEI.md +87 -0
  121. package/katas/040-request-batching/solution.test.ts +56 -0
  122. package/katas/040-request-batching/solution.ts +32 -0
  123. package/package.json +22 -0
  124. package/skills/effect-patterns-building-apis/SKILL.md +2393 -0
  125. package/skills/effect-patterns-building-data-pipelines/SKILL.md +1876 -0
  126. package/skills/effect-patterns-concurrency/SKILL.md +2999 -0
  127. package/skills/effect-patterns-concurrency-getting-started/SKILL.md +351 -0
  128. package/skills/effect-patterns-core-concepts/SKILL.md +3199 -0
  129. package/skills/effect-patterns-domain-modeling/SKILL.md +1385 -0
  130. package/skills/effect-patterns-error-handling/SKILL.md +1212 -0
  131. package/skills/effect-patterns-error-handling-resilience/SKILL.md +179 -0
  132. package/skills/effect-patterns-error-management/SKILL.md +1668 -0
  133. package/skills/effect-patterns-getting-started/SKILL.md +237 -0
  134. package/skills/effect-patterns-making-http-requests/SKILL.md +1756 -0
  135. package/skills/effect-patterns-observability/SKILL.md +1586 -0
  136. package/skills/effect-patterns-platform/SKILL.md +1195 -0
  137. package/skills/effect-patterns-platform-getting-started/SKILL.md +179 -0
  138. package/skills/effect-patterns-project-setup--execution/SKILL.md +233 -0
  139. package/skills/effect-patterns-resource-management/SKILL.md +827 -0
  140. package/skills/effect-patterns-scheduling/SKILL.md +451 -0
  141. package/skills/effect-patterns-scheduling-periodic-tasks/SKILL.md +763 -0
  142. package/skills/effect-patterns-streams/SKILL.md +2052 -0
  143. package/skills/effect-patterns-streams-getting-started/SKILL.md +421 -0
  144. package/skills/effect-patterns-streams-sinks/SKILL.md +1181 -0
  145. package/skills/effect-patterns-testing/SKILL.md +1632 -0
  146. package/skills/effect-patterns-tooling-and-debugging/SKILL.md +1125 -0
  147. package/skills/effect-patterns-value-handling/SKILL.md +676 -0
  148. package/tsconfig.json +20 -0
  149. package/vitest.config.ts +3 -0
@@ -0,0 +1,59 @@
1
+ # SENSEI — 029 HTTP Client
2
+
3
+ ## Briefing
4
+
5
+ ### Goal
6
+
7
+ Learn to define an `HttpClient` service abstraction for testability, parse HTTP responses with `Schema`, and use `Effect.retry` with a `Schedule` for transient failures.
8
+
9
+ ### Tasks
10
+
11
+ 1. Implement `fetchUser` -- use `HttpClient.get` to fetch from a URL, then decode the response with `UserSchema`.
12
+ 2. Implement `fetchUserWithRetry` -- fetch a user with retry logic (up to 2 retries).
13
+
14
+ ## Prerequisites
15
+
16
+ - **011 Services and Context** — `Context.Tag`, `provideService`
17
+ - **014 Schema Basics** — `Schema`, encode/decode
18
+ - **016 Retry and Schedule** — `Schedule`, retry policies
19
+
20
+ ## Skills
21
+
22
+ Invoke `effect-patterns-making-http-requests` before teaching this kata.
23
+
24
+ > **Note**: `Effect.runSync`, `Effect.runSyncExit`, and `Effect.provideService` appear only in tests. Never attribute them to the user's learning.
25
+
26
+ ## Test Map
27
+
28
+ | Test | Concept | Verifies |
29
+ |------|---------|----------|
30
+ | `fetchUser succeeds with valid response` | `HttpClient.get` + `Schema.decodeUnknown` | Fetch from `/user/1`, decode to `{ id: 1, name: "Alice" }` |
31
+ | `fetchUser fails with invalid url` | Error propagation | Fetch from `/user/999` fails — HttpClient returns `"not found"` |
32
+ | `fetchUserWithRetry succeeds with valid response` | `fetchUser` + `Effect.retry` | Same success case, but wrapped with retry logic |
33
+ | `fetchUserWithRetry retries on failure` | `Effect.retry` + `Schedule.recurs` | Flaky client fails twice, succeeds on third — verifies retry behavior |
34
+
35
+ ## Teaching Approach
36
+
37
+ ### Socratic prompts
38
+
39
+ - "The `HttpClient` is defined as a `Context.Tag`. Why is this useful for testing — why not just use `fetch` directly?"
40
+ - "After getting a response from the HTTP client, how do you know the data has the right shape? What if the response is missing a field?"
41
+ - "For `fetchUserWithRetry`, should you re-implement the HTTP call, or can you build on top of `fetchUser`?"
42
+
43
+ ### Common pitfalls
44
+
45
+ 1. **Schema.decodeUnknown returns an Effect with ParseError** — the function signature expects errors as `string`, so you may need `Effect.mapError` to convert the ParseError to a string. Ask: "What error type does `Schema.decodeUnknown(UserSchema)` produce? What does your function's type signature expect?"
46
+ 2. **fetchUserWithRetry wraps fetchUser, not the raw HTTP call** — avoid duplicating the fetch+decode logic. Simply call `fetchUser(url)` and pipe it through `Effect.retry`. Ask: "You already have `fetchUser` working. How can you add retry to it without rewriting it?"
47
+ 3. **Accessing the HttpClient service** — inside `Effect.gen`, use `yield* HttpClient` to get the service implementation. Students sometimes try to call `HttpClient.get` directly as a static method. Ask: "How do you access a service inside a generator?"
48
+ 4. **Schedule.recurs argument** — `Schedule.recurs(2)` means 2 retries (3 total attempts). Students may confuse retries with total attempts.
49
+ 5. **Error mapping for Schema** — if `Schema.decodeUnknown` gives you a `ParseError` but you need a `string`, use `Effect.mapError` to convert it.
50
+
51
+ ## On Completion
52
+
53
+ ### Insight
54
+
55
+ The HttpClient is a service — making it injectable means tests don't need a real HTTP server. Schema validates the response shape at the boundary. Retry handles transient failures. This pattern — **service abstraction + schema validation + retry** — is the standard approach for HTTP in Effect applications. Notice how each concern is separate and composable: you didn't write retry logic inside your HTTP call, and you didn't hardcode the HTTP implementation.
56
+
57
+ ### Bridge
58
+
59
+ Kata 030 is the **capstone** — bringing together services, layers, tagged errors, Option, Schema, and everything else you've learned into a complete mini-application. It's the final kata in the dojo.
@@ -0,0 +1,49 @@
1
+ import { Effect, Exit } from "effect";
2
+ import { describe, expect, it } from "vitest";
3
+ import { HttpClient, fetchUser, fetchUserWithRetry } from "@/katas/029-http-client/solution.js";
4
+
5
+ const TestClient = {
6
+ get: (url: string) => {
7
+ if (url === "/user/1") return Effect.succeed({ id: 1, name: "Alice" });
8
+ return Effect.fail("not found");
9
+ },
10
+ };
11
+
12
+ describe("029 — HTTP Client", () => {
13
+ it("fetchUser succeeds with valid response", () => {
14
+ const result = Effect.runSync(
15
+ Effect.provideService(fetchUser("/user/1"), HttpClient, TestClient),
16
+ );
17
+ expect(result).toEqual({ id: 1, name: "Alice" });
18
+ });
19
+
20
+ it("fetchUser fails with invalid url", () => {
21
+ const exit = Effect.runSyncExit(
22
+ Effect.provideService(fetchUser("/user/999"), HttpClient, TestClient),
23
+ );
24
+ expect(Exit.isFailure(exit)).toBe(true);
25
+ });
26
+
27
+ it("fetchUserWithRetry succeeds with valid response", () => {
28
+ const result = Effect.runSync(
29
+ Effect.provideService(fetchUserWithRetry("/user/1"), HttpClient, TestClient),
30
+ );
31
+ expect(result).toEqual({ id: 1, name: "Alice" });
32
+ });
33
+
34
+ it("fetchUserWithRetry retries on failure", () => {
35
+ let attempts = 0;
36
+ const FlakyClient = {
37
+ get: (_url: string) => {
38
+ attempts++;
39
+ if (attempts <= 2) return Effect.fail("network error");
40
+ return Effect.succeed({ id: 1, name: "Alice" });
41
+ },
42
+ };
43
+ const result = Effect.runSync(
44
+ Effect.provideService(fetchUserWithRetry("/user/1"), HttpClient, FlakyClient),
45
+ );
46
+ expect(result).toEqual({ id: 1, name: "Alice" });
47
+ expect(attempts).toBe(3);
48
+ });
49
+ });
@@ -0,0 +1,24 @@
1
+ import { Context, Effect, Schema } from "effect";
2
+
3
+ // Abstract HTTP client service for testability
4
+ export class HttpClient extends Context.Tag("HttpClient")<
5
+ HttpClient,
6
+ { readonly get: (url: string) => Effect.Effect<unknown, string> }
7
+ >() {}
8
+
9
+ export const UserSchema = Schema.Struct({
10
+ id: Schema.Number,
11
+ name: Schema.String,
12
+ });
13
+
14
+ export type User = typeof UserSchema.Type;
15
+
16
+ /** Use HttpClient.get to fetch from the url, then decode with UserSchema */
17
+ export const fetchUser = (url: string): Effect.Effect<User, string, HttpClient> => {
18
+ throw new Error("Not implemented");
19
+ };
20
+
21
+ /** Fetch user with retry (up to 2 retries) */
22
+ export const fetchUserWithRetry = (url: string): Effect.Effect<User, string, HttpClient> => {
23
+ throw new Error("Not implemented");
24
+ };
@@ -0,0 +1,63 @@
1
+ # SENSEI — 030 Capstone
2
+
3
+ ## Briefing
4
+
5
+ ### Goal
6
+
7
+ Bring it all together -- services, layers, tagged errors, Option, Schema, and testing in one mini-application.
8
+
9
+ ### Tasks
10
+
11
+ 1. Implement `findProduct` -- look up a product by id. If found, return it wrapped in `Option.some`. If not found, catch the error and return `Option.none`.
12
+ 2. Implement `getValidatedProduct` -- validate that price meets `minPrice`, then look up the product by id. Fail with `ValidationError` if price is invalid, or `NotFoundError` if the product is not found.
13
+ 3. Implement `formatExpensiveProducts` -- get all products, filter those with price above `minPrice`, and format each as `"{name}: ${price}"`.
14
+ 4. The `TestProductRepo` is provided in the test file — study it to understand the service interface.
15
+
16
+ ## Prerequisites
17
+
18
+ - **All prior katas** — this capstone draws on every area: basics, error handling, services, layers, Option, Schema, and composition.
19
+
20
+ ## Skills
21
+
22
+ Invoke `effect-patterns-building-apis` before teaching this kata.
23
+
24
+ > **Note**: `Effect.runSync`, `Effect.runSyncExit`, `Effect.provide`, `Option.isSome`, `Option.isNone`, and `Exit.isFailure` appear only in tests. Never attribute them to the user's learning.
25
+
26
+ ## Test Map
27
+
28
+ | Test | Concept | Verifies |
29
+ |------|---------|----------|
30
+ | `findProduct(1) returns Some(Widget)` | Service access + `Option.some` | Repo lookup wraps found product in `Some` |
31
+ | `findProduct(99) returns None` | `Effect.catchTag` + `Option.none` | Repo throws `NotFoundError`, caught and converted to `None` |
32
+ | `getValidatedProduct(1, 5) succeeds` | Service + validation | Widget (price 9.99) passes `minPrice: 5` check |
33
+ | `getValidatedProduct(1, 20) fails with ValidationError` | `Effect.fail` + `ValidationError` | Widget (price 9.99) fails `minPrice: 20` check |
34
+ | `getValidatedProduct(99, 0) fails with NotFoundError` | Error propagation | Product not found, `NotFoundError` propagates |
35
+ | `formatExpensiveProducts(5) returns Widget and Gadget` | Service + filter + format | `findAll` -> filter `price > 5` -> format as `"Name: $price"` |
36
+
37
+ ## Teaching Approach
38
+
39
+ ### Socratic prompts
40
+
41
+ - "You need to call `repo.findById(id)` which might fail with `NotFoundError`. But `findProduct` should return `Option<Product>`, not fail. How do you convert a failure into a `None`?"
42
+ - "For `getValidatedProduct`, there are two things that can go wrong: invalid price and product not found. Which should you check first? Does the order matter?"
43
+ - "How do you create a `Layer` that provides a `ProductRepo` implementation? What methods does the service interface require?"
44
+ - "For `formatExpensiveProducts`, the return type has `never` in the error channel. What does that tell you about error handling?"
45
+
46
+ ### Common pitfalls
47
+
48
+ 1. **findProduct must catch NotFoundError and return None** — the return type is `Effect<Option<Product>, never, ProductRepo>` with `never` in the error position. This means the function must not fail. Use `Effect.catchTag("NotFoundError", () => Effect.succeed(Option.none()))` to convert the error. Ask: "What does `never` in the error type mean for your implementation?"
49
+ 2. **getValidatedProduct validation order** — find the product first, then check if its price meets `minPrice`. The test for id=1 minPrice=20 expects `ValidationError`, meaning the product was found (price 9.99) but failed validation. Ask: "Do you validate the price before or after looking up the product?"
50
+ 3. **TestProductRepo needs both findById and findAll** — `findById` should find a product by id or fail with `NotFoundError`. `findAll` should return all three products. Students may forget one method. Ask: "What does the `ProductRepo` interface require?"
51
+ 4. **Format string must match exactly** — the test expects `"Widget: $9.99"` format. Students may use different formatting. Ask: "Check the test assertion — what exact string format does it expect?"
52
+ 5. **Layer.succeed vs Layer.effect** — since the repo methods return Effects but don't need external state, `Layer.succeed(ProductRepo, { ... })` works. The service value is the object with `findById` and `findAll` methods.
53
+ 6. **Start with `TestProductRepo`** — define the three products first, implement `findById` (find in array or fail with NotFoundError) and `findAll` (return all). Everything else depends on having the repo.
54
+
55
+ ## On Completion
56
+
57
+ ### Insight
58
+
59
+ This capstone proves you can build real applications with Effect: **services** for architecture, **layers** for composition and testing, **tagged errors** for type-safe failure handling, **Option** for optional results, **Schema** for domain modeling. Every pattern you learned works together naturally — services are injected, errors are caught and transformed, optional values are wrapped, and layers make it all testable. The `never` error type in `findProduct` and `formatExpensiveProducts` is a compile-time guarantee that these functions handle all their errors internally.
60
+
61
+ ### Bridge
62
+
63
+ Congratulations — you've completed the core curriculum! You've gone from `Effect.succeed` to building a fully-typed, testable, service-oriented application. The patterns you practiced — composition, typed errors, dependency injection, streams, observability, and resource management — are the foundation of production Effect-TS code. Katas 031-040 cover advanced topics like configuration, causes and defects, pattern matching, coordination primitives, caching, metrics, managed runtimes, and request batching.
@@ -0,0 +1,67 @@
1
+ import { Effect, Exit, Layer, Option } from "effect";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ findProduct,
5
+ getValidatedProduct,
6
+ formatExpensiveProducts,
7
+ ProductRepo,
8
+ NotFoundError,
9
+ } from "@/katas/030-capstone/solution.js";
10
+
11
+ const testProducts = [
12
+ { id: 1, name: "Widget", price: 9.99 },
13
+ { id: 2, name: "Gadget", price: 24.99 },
14
+ { id: 3, name: "Gizmo", price: 4.99 },
15
+ ];
16
+
17
+ const TestProductRepo = Layer.succeed(ProductRepo, {
18
+ findById: (id: number) => {
19
+ const product = testProducts.find((p) => p.id === id);
20
+ if (product) return Effect.succeed(product);
21
+ return Effect.fail(new NotFoundError({ id }));
22
+ },
23
+ findAll: () => Effect.succeed(testProducts),
24
+ });
25
+
26
+ const run = <A, E>(effect: Effect.Effect<A, E, any>) =>
27
+ Effect.runSync(Effect.provide(effect, TestProductRepo));
28
+
29
+ const runExit = <A, E>(effect: Effect.Effect<A, E, any>) =>
30
+ Effect.runSyncExit(Effect.provide(effect, TestProductRepo));
31
+
32
+ describe("030 — Capstone", () => {
33
+ it("findProduct(1) returns Some(Widget)", () => {
34
+ const result = run(findProduct(1));
35
+ expect(Option.isSome(result)).toBe(true);
36
+ if (Option.isSome(result)) {
37
+ expect(result.value.name).toBe("Widget");
38
+ }
39
+ });
40
+
41
+ it("findProduct(99) returns None", () => {
42
+ const result = run(findProduct(99));
43
+ expect(Option.isNone(result)).toBe(true);
44
+ });
45
+
46
+ it("getValidatedProduct(1, 5) succeeds", () => {
47
+ const result = run(getValidatedProduct(1, 5));
48
+ expect(result.name).toBe("Widget");
49
+ });
50
+
51
+ it("getValidatedProduct(1, 20) fails with ValidationError", () => {
52
+ const exit = runExit(getValidatedProduct(1, 20));
53
+ expect(Exit.isFailure(exit)).toBe(true);
54
+ });
55
+
56
+ it("getValidatedProduct(99, 0) fails with NotFoundError", () => {
57
+ const exit = runExit(getValidatedProduct(99, 0));
58
+ expect(Exit.isFailure(exit)).toBe(true);
59
+ });
60
+
61
+ it("formatExpensiveProducts(5) returns Widget and Gadget", () => {
62
+ const result = run(formatExpensiveProducts(5));
63
+ expect(result).toContain("Widget: $9.99");
64
+ expect(result).toContain("Gadget: $24.99");
65
+ expect(result).not.toContain("Gizmo");
66
+ });
67
+ });
@@ -0,0 +1,55 @@
1
+ import { Context, Data, Effect, Option, Schema } from "effect";
2
+
3
+ // === Domain Types ===
4
+
5
+ export class NotFoundError extends Data.TaggedError("NotFoundError")<{
6
+ readonly id: number;
7
+ }> {}
8
+
9
+ export class ValidationError extends Data.TaggedError("ValidationError")<{
10
+ readonly message: string;
11
+ }> {}
12
+
13
+ export const ProductSchema = Schema.Struct({
14
+ id: Schema.Number,
15
+ name: Schema.String,
16
+ price: Schema.Number,
17
+ });
18
+ export type Product = typeof ProductSchema.Type;
19
+
20
+ // === Services ===
21
+
22
+ export class ProductRepo extends Context.Tag("ProductRepo")<
23
+ ProductRepo,
24
+ {
25
+ readonly findById: (id: number) => Effect.Effect<Product, NotFoundError>;
26
+ readonly findAll: () => Effect.Effect<Product[]>;
27
+ }
28
+ >() {}
29
+
30
+ // === Tasks ===
31
+
32
+ /** Look up a product by id. If found, return it. If not, return Option.none. */
33
+ export const findProduct = (
34
+ id: number,
35
+ ): Effect.Effect<Option.Option<Product>, never, ProductRepo> => {
36
+ throw new Error("Not implemented");
37
+ };
38
+
39
+ /** Validate that price > 0, then look up product by id.
40
+ * If price invalid: fail with ValidationError
41
+ * If product not found: fail with NotFoundError */
42
+ export const getValidatedProduct = (
43
+ id: number,
44
+ minPrice: number,
45
+ ): Effect.Effect<Product, NotFoundError | ValidationError, ProductRepo> => {
46
+ throw new Error("Not implemented");
47
+ };
48
+
49
+ /** Get all products, filter those with price > minPrice,
50
+ * format each as "{name}: ${price}", return as array */
51
+ export const formatExpensiveProducts = (
52
+ minPrice: number,
53
+ ): Effect.Effect<string[], never, ProductRepo> => {
54
+ throw new Error("Not implemented");
55
+ };
@@ -0,0 +1,77 @@
1
+ # SENSEI — 031 Config and Environment
2
+
3
+ ## Briefing
4
+
5
+ ### Goal
6
+
7
+ Read typed configuration values from a provider using Effect's `Config` module, with defaults and composition.
8
+
9
+ ### Tasks
10
+
11
+ 1. Implement `getAppName` — read a string config value for the key `"APP_NAME"`
12
+ 2. Implement `getPort` — read a numeric config value for `"PORT"`, falling back to `3000` if missing
13
+ 3. Implement `getAppConfig` — combine both reads and return `{ name, port }`
14
+
15
+ ### Hints
16
+
17
+ ```ts
18
+ import { Config, ConfigProvider, Effect, Layer } from "effect";
19
+
20
+ // Read a string config
21
+ const name = Config.string("MY_KEY");
22
+
23
+ // Read a number config with a default
24
+ const port = Config.withDefault(Config.number("PORT"), 3000);
25
+
26
+ // Config values are Effects — use them in generators
27
+ const program = Effect.gen(function* () {
28
+ const n = yield* Config.string("NAME");
29
+ const p = yield* Config.withDefault(Config.number("PORT"), 8080);
30
+ return { n, p };
31
+ });
32
+
33
+ // Provide a config in tests
34
+ const provider = ConfigProvider.fromMap(new Map([["KEY", "value"]]));
35
+ const withProvider = Effect.provide(program, Layer.setConfigProvider(provider));
36
+ ```
37
+
38
+ ## Prerequisites
39
+
40
+ - **011 Services and Context** — Dependency injection with `Context`
41
+ - **012 Layers** — Building and providing layers
42
+ - **005 Pipe Composition** — Composing effects with `pipe`
43
+
44
+ ## Test Map
45
+
46
+ > **Note**: `ConfigProvider.fromMap`, `Layer.setConfigProvider`, and `Effect.runSync` appear only in tests. Never attribute them to the user's learning.
47
+
48
+ | Test | Concept | Verifies |
49
+ |------|---------|----------|
50
+ | `getAppName reads APP_NAME config` | `Config.string` | Reading a string config value by key |
51
+ | `getPort reads PORT config as number` | `Config.number` | Reading a numeric config value |
52
+ | `getPort falls back to 3000 when PORT is missing` | `Config.withDefault` | Default value when config key is absent |
53
+ | `getAppConfig returns both name and port` | Composing configs | Combining multiple config reads into a single object |
54
+
55
+ ## Teaching Approach
56
+
57
+ ### Socratic prompts
58
+
59
+ - "Config values in Effect are described declaratively, not read imperatively. What does `Config.string('APP_NAME')` return — a string, or something else?"
60
+ - "Why does Effect treat configuration as a dependency rather than reading `process.env` directly? What does this make easier?"
61
+ - "When you compose two config reads in a generator, what happens if one of them fails? How does this compare to manually checking `process.env.PORT || 3000`?"
62
+
63
+ ### Common pitfalls
64
+
65
+ 1. **Treating Config as a raw value instead of an Effect** — `Config.string("APP_NAME")` returns an `Effect`, not a string. You must `yield*` it in a generator or use it in a pipeline. Ask: "What type does `Config.string` return?"
66
+ 2. **Applying `withDefault` to the wrong thing** — `Config.withDefault` wraps a `Config`, not an `Effect`. It should be `Config.withDefault(Config.number("PORT"), 3000)`, not applied after yielding. Ask: "At what level does the default apply — to the config description or to the effect result?"
67
+ 3. **Forgetting that Config.number parses strings** — The config provider stores strings. `Config.number` handles the parsing for you. Don't try to manually parse with `parseInt`.
68
+
69
+ ## On Completion
70
+
71
+ ### Insight
72
+
73
+ Effect's Config module separates _what_ configuration your program needs from _where_ it comes from. By declaring configs as typed descriptors (`Config.string`, `Config.number`), your program becomes testable and portable — you can swap `process.env` for a `Map` in tests without changing any application code. `Config.withDefault` is not just a convenience; it documents which values are optional and what the fallback behavior is, making configuration self-describing.
74
+
75
+ ### Bridge
76
+
77
+ Now that you can read configuration, what happens when things go wrong in unexpected ways — not just typed errors, but crashes and defects? Kata 032 explores `Cause` and defects, the deeper error model beneath `Effect.fail`.
@@ -0,0 +1,38 @@
1
+ import { Config, ConfigProvider, Effect, Layer } from "effect";
2
+ import { describe, expect, it } from "vitest";
3
+ import { getAppName, getPort, getAppConfig } from "@/katas/031-config-and-environment/solution.js";
4
+
5
+ const TestConfig = ConfigProvider.fromMap(
6
+ new Map([
7
+ ["APP_NAME", "my-app"],
8
+ ["PORT", "8080"],
9
+ ]),
10
+ );
11
+
12
+ const withConfig = <A, E>(effect: Effect.Effect<A, E>) =>
13
+ Effect.provide(effect, Layer.setConfigProvider(TestConfig));
14
+
15
+ describe("031 — Config and Environment", () => {
16
+ it("getAppName reads APP_NAME config", () => {
17
+ const result = Effect.runSync(withConfig(getAppName));
18
+ expect(result).toBe("my-app");
19
+ });
20
+
21
+ it("getPort reads PORT config as number", () => {
22
+ const result = Effect.runSync(withConfig(getPort));
23
+ expect(result).toBe(8080);
24
+ });
25
+
26
+ it("getPort falls back to 3000 when PORT is missing", () => {
27
+ const emptyConfig = ConfigProvider.fromMap(new Map([["APP_NAME", "app"]]));
28
+ const result = Effect.runSync(
29
+ Effect.provide(getPort, Layer.setConfigProvider(emptyConfig)),
30
+ );
31
+ expect(result).toBe(3000);
32
+ });
33
+
34
+ it("getAppConfig returns both name and port", () => {
35
+ const result = Effect.runSync(withConfig(getAppConfig));
36
+ expect(result).toEqual({ name: "my-app", port: 8080 });
37
+ });
38
+ });
@@ -0,0 +1,11 @@
1
+ import { Config, Effect } from "effect";
2
+
3
+ /** Read the APP_NAME config as a string */
4
+ export const getAppName: Effect.Effect<string> = Effect.fail("Not implemented") as any;
5
+
6
+ /** Read the PORT config as a number, defaulting to 3000 */
7
+ export const getPort: Effect.Effect<number> = Effect.fail("Not implemented") as any;
8
+
9
+ /** Read both APP_NAME and PORT into an object { name, port } */
10
+ export const getAppConfig: Effect.Effect<{ name: string; port: number }> =
11
+ Effect.fail("Not implemented") as any;
@@ -0,0 +1,90 @@
1
+ # SENSEI — 032 Cause and Defects
2
+
3
+ ## Briefing
4
+
5
+ ### Goal
6
+
7
+ Understand Effect's two-tier error model: expected failures (`Effect.fail`) vs unexpected defects (`Effect.die`), and the tools to inspect and recover from each.
8
+
9
+ ### Tasks
10
+
11
+ 1. Implement `boom` — an effect that dies with the string `"boom"`
12
+ 2. Implement `catchDefect(effect)` — catch any defect; if it's an `Error`, return its `.message`; otherwise return `"unknown defect"`. Normal successes pass through unchanged.
13
+ 3. Implement `sandboxed(effect)` — use `Effect.sandbox` to expose the full `Cause`, then map the cause back to a string error
14
+ 4. Implement `safeDivide(n)` — die if `n < 0`, fail with `"zero"` if `n === 0`, succeed with `n` otherwise
15
+
16
+ ### Hints
17
+
18
+ ```ts
19
+ import { Cause, Effect } from "effect";
20
+
21
+ // Die with a defect (unrecoverable by default)
22
+ const defect = Effect.die("something unexpected");
23
+
24
+ // Catch defects
25
+ const recovered = Effect.catchAllDefect(myEffect, (defect) => {
26
+ if (defect instanceof Error) return Effect.succeed(defect.message);
27
+ return Effect.succeed("unknown defect");
28
+ });
29
+
30
+ // Sandbox exposes the full Cause
31
+ const exposed = Effect.sandbox(myEffect);
32
+ // exposed has error type Cause<OriginalError>
33
+
34
+ // Catch the sandboxed cause and extract failures
35
+ const handled = exposed.pipe(
36
+ Effect.catchAll((cause) => {
37
+ const failures = Cause.failures(cause); // Chunk of original errors
38
+ return Effect.fail(Array.from(failures)[0] ?? "unknown");
39
+ }),
40
+ );
41
+ ```
42
+
43
+ ## Prerequisites
44
+
45
+ - **006 Handle Errors** — `Effect.catchAll`, `Effect.catchTag`
46
+ - **007 Tagged Errors** — `Data.TaggedError` patterns
47
+ - **008 Error Patterns** — `Effect.mapError`, `Effect.catchSome`
48
+
49
+ ## Skills
50
+
51
+ Invoke `effect-patterns-error-handling` before teaching this kata.
52
+
53
+ ## Test Map
54
+
55
+ > **Note**: `Effect.runSyncExit`, `Exit.isFailure`, and `Cause.isDie` appear only in tests. Never attribute them to the user's learning.
56
+
57
+ | Test | Concept | Verifies |
58
+ |------|---------|----------|
59
+ | `boom dies with 'boom'` | `Effect.die` | Creating a defect (not a typed failure) |
60
+ | `catchDefect recovers from a die` | `Effect.catchAllDefect` | Recovering from a string defect |
61
+ | `catchDefect recovers from an Error defect` | `Effect.catchAllDefect` | Extracting `.message` from Error defects |
62
+ | `catchDefect passes through normal values` | `Effect.catchAllDefect` | Defect recovery does not interfere with success |
63
+ | `sandboxed exposes cause on failure` | `Effect.sandbox` | Converting typed errors to `Cause` for full inspection |
64
+ | `safeDivide succeeds with positive numbers` | `Effect.succeed` | Happy path branching |
65
+ | `safeDivide fails with 'zero' for 0` | `Effect.fail` | Expected failure branch |
66
+ | `safeDivide dies for negative numbers` | `Effect.die` | Defect branch for truly invalid input |
67
+
68
+ ## Teaching Approach
69
+
70
+ ### Socratic prompts
71
+
72
+ - "What is the difference between `Effect.fail('error')` and `Effect.die('error')`? If both stop execution, why have two mechanisms?"
73
+ - "`Effect.catchAll` handles typed failures. What happens if you use `catchAll` on an effect that dies — does it catch the defect?"
74
+ - "When would you intentionally use `Effect.die` in your own code? What kind of errors deserve to be defects rather than typed failures?"
75
+
76
+ ### Common pitfalls
77
+
78
+ 1. **Using `Effect.fail` instead of `Effect.die` for `boom`** — `fail` creates a typed, recoverable error. `die` creates an unrecoverable defect. The test checks for `Cause.isDie`. Ask: "What does the test expect to find in the Cause — a `Fail` or a `Die`?"
79
+ 2. **Forgetting that `catchAllDefect` receives the raw defect, not a Cause** — The callback gets whatever value was passed to `Effect.die`. It might be a string, an Error, or anything. You need to check its type. Ask: "If someone calls `Effect.die(42)`, what type does your defect handler receive?"
80
+ 3. **Confusing `sandbox` with `catchAllDefect`** — `sandbox` changes the error channel to `Cause<E>`, exposing the full error model. `catchAllDefect` specifically intercepts defects. For `sandboxed`, you need to work with the Cause type. Ask: "After `Effect.sandbox`, what is the new error type of the effect?"
81
+
82
+ ## On Completion
83
+
84
+ ### Insight
85
+
86
+ Effect's error model has two layers by design. Typed failures (`Effect.fail`) represent expected, recoverable business errors — they appear in the type signature and must be handled. Defects (`Effect.die`) represent bugs, assertion violations, or truly unexpected crashes — they propagate silently through `catchAll` and only surface when you explicitly use `catchAllDefect` or `sandbox`. This separation keeps your typed error channel meaningful: if the types say the only error is `NotFoundError`, you can trust that. Defects are for things that "should never happen" — and when they do, `Cause` gives you the full forensic picture including stack traces, parallel failures, and interruption.
87
+
88
+ ### Bridge
89
+
90
+ You now understand Effect's complete error hierarchy. Kata 033 shifts to a different kind of safety: exhaustive pattern matching with `Match`, ensuring every case is handled at compile time rather than runtime.
@@ -0,0 +1,50 @@
1
+ import { Cause, Effect, Exit } from "effect";
2
+ import { describe, expect, it } from "vitest";
3
+ import { boom, catchDefect, sandboxed, safeDivide } from "@/katas/032-cause-and-defects/solution.js";
4
+
5
+ describe("032 — Cause and Defects", () => {
6
+ it("boom dies with 'boom'", () => {
7
+ const exit = Effect.runSyncExit(boom);
8
+ expect(Exit.isFailure(exit)).toBe(true);
9
+ if (Exit.isFailure(exit)) {
10
+ expect(Cause.isDie(exit.cause)).toBe(true);
11
+ }
12
+ });
13
+
14
+ it("catchDefect recovers from a die", () => {
15
+ const result = Effect.runSync(catchDefect(Effect.die("crash")));
16
+ expect(result).toBe("crash");
17
+ });
18
+
19
+ it("catchDefect recovers from an Error defect", () => {
20
+ const result = Effect.runSync(catchDefect(Effect.die(new Error("oops"))));
21
+ expect(result).toBe("oops");
22
+ });
23
+
24
+ it("catchDefect passes through normal values", () => {
25
+ const result = Effect.runSync(catchDefect(Effect.succeed(42)));
26
+ expect(result).toBe(42);
27
+ });
28
+
29
+ it("sandboxed exposes cause on failure", () => {
30
+ const exit = Effect.runSyncExit(sandboxed(Effect.fail("bad")));
31
+ expect(Exit.isFailure(exit)).toBe(true);
32
+ });
33
+
34
+ it("safeDivide succeeds with positive numbers", () => {
35
+ expect(Effect.runSync(safeDivide(5))).toBe(5);
36
+ });
37
+
38
+ it("safeDivide fails with 'zero' for 0", () => {
39
+ const exit = Effect.runSyncExit(safeDivide(0));
40
+ expect(Exit.isFailure(exit)).toBe(true);
41
+ });
42
+
43
+ it("safeDivide dies for negative numbers", () => {
44
+ const exit = Effect.runSyncExit(safeDivide(-1));
45
+ expect(Exit.isFailure(exit)).toBe(true);
46
+ if (Exit.isFailure(exit)) {
47
+ expect(Cause.isDie(exit.cause)).toBe(true);
48
+ }
49
+ });
50
+ });
@@ -0,0 +1,23 @@
1
+ import { Effect } from "effect";
2
+
3
+ /** Create an effect that dies with "boom" */
4
+ export const boom: Effect.Effect<never> = Effect.fail("Not implemented") as any;
5
+
6
+ /** Catch defects from an effect, returning the defect message as a string */
7
+ export const catchDefect = <A>(
8
+ effect: Effect.Effect<A>,
9
+ ): Effect.Effect<A | string> => {
10
+ throw new Error("Not implemented");
11
+ };
12
+
13
+ /** Sandbox an effect to expose its full Cause */
14
+ export const sandboxed = <A, E>(
15
+ effect: Effect.Effect<A, E>,
16
+ ): Effect.Effect<A, unknown> => {
17
+ throw new Error("Not implemented");
18
+ };
19
+
20
+ /** If n < 0, die with "negative". If n === 0, fail with "zero". Otherwise succeed. */
21
+ export const safeDivide = (n: number): Effect.Effect<number, string> => {
22
+ throw new Error("Not implemented");
23
+ };