@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,1385 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: effect-patterns-domain-modeling
|
|
3
|
+
description: Effect-TS patterns for Domain Modeling. Use when working with domain modeling in Effect-TS applications.
|
|
4
|
+
---
|
|
5
|
+
# Effect-TS Patterns: Domain Modeling
|
|
6
|
+
This skill provides 15 curated Effect-TS patterns for domain modeling.
|
|
7
|
+
Use this skill when working on tasks related to:
|
|
8
|
+
- domain modeling
|
|
9
|
+
- Best practices in Effect-TS applications
|
|
10
|
+
- Real-world patterns and solutions
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 🟢 Beginner Patterns
|
|
15
|
+
|
|
16
|
+
### Create Type-Safe Errors
|
|
17
|
+
|
|
18
|
+
**Rule:** Use Data.TaggedError to create typed, distinguishable errors for your domain.
|
|
19
|
+
|
|
20
|
+
**Good Example:**
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { Effect, Data } from "effect"
|
|
24
|
+
|
|
25
|
+
// ============================================
|
|
26
|
+
// 1. Define tagged errors for your domain
|
|
27
|
+
// ============================================
|
|
28
|
+
|
|
29
|
+
class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
|
|
30
|
+
readonly userId: string
|
|
31
|
+
}> {}
|
|
32
|
+
|
|
33
|
+
class InvalidEmailError extends Data.TaggedError("InvalidEmailError")<{
|
|
34
|
+
readonly email: string
|
|
35
|
+
readonly reason: string
|
|
36
|
+
}> {}
|
|
37
|
+
|
|
38
|
+
class DuplicateUserError extends Data.TaggedError("DuplicateUserError")<{
|
|
39
|
+
readonly email: string
|
|
40
|
+
}> {}
|
|
41
|
+
|
|
42
|
+
// ============================================
|
|
43
|
+
// 2. Use in Effect functions
|
|
44
|
+
// ============================================
|
|
45
|
+
|
|
46
|
+
interface User {
|
|
47
|
+
id: string
|
|
48
|
+
email: string
|
|
49
|
+
name: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const validateEmail = (email: string): Effect.Effect<string, InvalidEmailError> => {
|
|
53
|
+
if (!email.includes("@")) {
|
|
54
|
+
return Effect.fail(new InvalidEmailError({
|
|
55
|
+
email,
|
|
56
|
+
reason: "Missing @ symbol"
|
|
57
|
+
}))
|
|
58
|
+
}
|
|
59
|
+
return Effect.succeed(email)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const findUser = (id: string): Effect.Effect<User, UserNotFoundError> => {
|
|
63
|
+
// Simulate database lookup
|
|
64
|
+
if (id === "123") {
|
|
65
|
+
return Effect.succeed({ id, email: "alice@example.com", name: "Alice" })
|
|
66
|
+
}
|
|
67
|
+
return Effect.fail(new UserNotFoundError({ userId: id }))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const createUser = (
|
|
71
|
+
email: string,
|
|
72
|
+
name: string
|
|
73
|
+
): Effect.Effect<User, InvalidEmailError | DuplicateUserError> =>
|
|
74
|
+
Effect.gen(function* () {
|
|
75
|
+
const validEmail = yield* validateEmail(email)
|
|
76
|
+
|
|
77
|
+
// Simulate duplicate check
|
|
78
|
+
if (validEmail === "taken@example.com") {
|
|
79
|
+
return yield* Effect.fail(new DuplicateUserError({ email: validEmail }))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
id: crypto.randomUUID(),
|
|
84
|
+
email: validEmail,
|
|
85
|
+
name,
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// ============================================
|
|
90
|
+
// 3. Handle errors by tag
|
|
91
|
+
// ============================================
|
|
92
|
+
|
|
93
|
+
const program = createUser("alice@example.com", "Alice").pipe(
|
|
94
|
+
Effect.catchTag("InvalidEmailError", (error) =>
|
|
95
|
+
Effect.succeed({
|
|
96
|
+
id: "fallback",
|
|
97
|
+
email: "default@example.com",
|
|
98
|
+
name: `${error.email} was invalid: ${error.reason}`,
|
|
99
|
+
})
|
|
100
|
+
),
|
|
101
|
+
Effect.catchTag("DuplicateUserError", (error) =>
|
|
102
|
+
Effect.fail(new Error(`Email ${error.email} already registered`))
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// ============================================
|
|
107
|
+
// 4. Match on all errors
|
|
108
|
+
// ============================================
|
|
109
|
+
|
|
110
|
+
const handleAllErrors = createUser("bad-email", "Bob").pipe(
|
|
111
|
+
Effect.catchTags({
|
|
112
|
+
InvalidEmailError: (e) => Effect.succeed(`Invalid: ${e.reason}`),
|
|
113
|
+
DuplicateUserError: (e) => Effect.succeed(`Duplicate: ${e.email}`),
|
|
114
|
+
})
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
// ============================================
|
|
118
|
+
// 5. Run and see results
|
|
119
|
+
// ============================================
|
|
120
|
+
|
|
121
|
+
Effect.runPromise(program)
|
|
122
|
+
.then((user) => console.log("Created:", user))
|
|
123
|
+
.catch((error) => console.error("Failed:", error))
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Rationale:**
|
|
127
|
+
|
|
128
|
+
Create domain-specific errors using `Data.TaggedError`. Each error type gets a unique `_tag` for pattern matching.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
Plain `Error` or string messages cause problems:
|
|
134
|
+
|
|
135
|
+
1. **No type safety** - Can't know what errors a function might throw
|
|
136
|
+
2. **Hard to handle** - Matching on error messages is fragile
|
|
137
|
+
3. **Poor documentation** - Errors aren't part of the function signature
|
|
138
|
+
|
|
139
|
+
Tagged errors solve this by making errors typed and distinguishable.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### Handle Missing Values with Option
|
|
146
|
+
|
|
147
|
+
**Rule:** Use Option instead of null/undefined to make missing values explicit and type-safe.
|
|
148
|
+
|
|
149
|
+
**Good Example:**
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
import { Option, Effect } from "effect"
|
|
153
|
+
|
|
154
|
+
// ============================================
|
|
155
|
+
// 1. Creating Options
|
|
156
|
+
// ============================================
|
|
157
|
+
|
|
158
|
+
// Some - a value is present
|
|
159
|
+
const hasValue = Option.some(42)
|
|
160
|
+
|
|
161
|
+
// None - no value
|
|
162
|
+
const noValue = Option.none<number>()
|
|
163
|
+
|
|
164
|
+
// From nullable - null/undefined becomes None
|
|
165
|
+
const fromNull = Option.fromNullable(null) // None
|
|
166
|
+
const fromValue = Option.fromNullable("hello") // Some("hello")
|
|
167
|
+
|
|
168
|
+
// ============================================
|
|
169
|
+
// 2. Checking and extracting values
|
|
170
|
+
// ============================================
|
|
171
|
+
|
|
172
|
+
const maybeUser = Option.some({ name: "Alice", age: 30 })
|
|
173
|
+
|
|
174
|
+
// Check if value exists
|
|
175
|
+
if (Option.isSome(maybeUser)) {
|
|
176
|
+
console.log(`User: ${maybeUser.value.name}`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Get with default
|
|
180
|
+
const name = Option.getOrElse(
|
|
181
|
+
Option.map(maybeUser, u => u.name),
|
|
182
|
+
() => "Anonymous"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
// ============================================
|
|
186
|
+
// 3. Transforming Options
|
|
187
|
+
// ============================================
|
|
188
|
+
|
|
189
|
+
const maybeNumber = Option.some(5)
|
|
190
|
+
|
|
191
|
+
// Map - transform the value if present
|
|
192
|
+
const doubled = Option.map(maybeNumber, n => n * 2) // Some(10)
|
|
193
|
+
|
|
194
|
+
// FlatMap - chain operations that return Option
|
|
195
|
+
const safeDivide = (a: number, b: number): Option.Option<number> =>
|
|
196
|
+
b === 0 ? Option.none() : Option.some(a / b)
|
|
197
|
+
|
|
198
|
+
const result = Option.flatMap(maybeNumber, n => safeDivide(10, n)) // Some(2)
|
|
199
|
+
|
|
200
|
+
// ============================================
|
|
201
|
+
// 4. Domain modeling example
|
|
202
|
+
// ============================================
|
|
203
|
+
|
|
204
|
+
interface User {
|
|
205
|
+
readonly id: string
|
|
206
|
+
readonly name: string
|
|
207
|
+
readonly email: Option.Option<string> // Email is optional
|
|
208
|
+
readonly phone: Option.Option<string> // Phone is optional
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const createUser = (name: string): User => ({
|
|
212
|
+
id: crypto.randomUUID(),
|
|
213
|
+
name,
|
|
214
|
+
email: Option.none(),
|
|
215
|
+
phone: Option.none(),
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const addEmail = (user: User, email: string): User => ({
|
|
219
|
+
...user,
|
|
220
|
+
email: Option.some(email),
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const getContactInfo = (user: User): string => {
|
|
224
|
+
const email = Option.getOrElse(user.email, () => "no email")
|
|
225
|
+
const phone = Option.getOrElse(user.phone, () => "no phone")
|
|
226
|
+
return `${user.name}: ${email}, ${phone}`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ============================================
|
|
230
|
+
// 5. Use in Effects
|
|
231
|
+
// ============================================
|
|
232
|
+
|
|
233
|
+
const findUser = (id: string): Effect.Effect<Option.Option<User>> =>
|
|
234
|
+
Effect.succeed(
|
|
235
|
+
id === "123"
|
|
236
|
+
? Option.some({ id, name: "Alice", email: Option.none(), phone: Option.none() })
|
|
237
|
+
: Option.none()
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
const program = Effect.gen(function* () {
|
|
241
|
+
const maybeUser = yield* findUser("123")
|
|
242
|
+
|
|
243
|
+
if (Option.isSome(maybeUser)) {
|
|
244
|
+
yield* Effect.log(`Found: ${maybeUser.value.name}`)
|
|
245
|
+
} else {
|
|
246
|
+
yield* Effect.log("User not found")
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
Effect.runPromise(program)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Rationale:**
|
|
254
|
+
|
|
255
|
+
Use `Option<A>` to represent values that might be absent. This makes "might not exist" explicit in your types, forcing you to handle both cases.
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
`null` and `undefined` cause bugs because:
|
|
261
|
+
|
|
262
|
+
1. **Silent failures** - Accessing `.property` on null crashes at runtime
|
|
263
|
+
2. **Unclear intent** - Is null "not found" or "error"?
|
|
264
|
+
3. **Forgotten checks** - Easy to forget `if (x !== null)`
|
|
265
|
+
|
|
266
|
+
Option fixes this by making absence explicit and type-checked.
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
### Your First Domain Model
|
|
273
|
+
|
|
274
|
+
**Rule:** Start domain modeling by defining clear interfaces for your business entities.
|
|
275
|
+
|
|
276
|
+
**Good Example:**
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
import { Effect } from "effect"
|
|
280
|
+
|
|
281
|
+
// ============================================
|
|
282
|
+
// 1. Define domain entities as interfaces
|
|
283
|
+
// ============================================
|
|
284
|
+
|
|
285
|
+
interface User {
|
|
286
|
+
readonly id: string
|
|
287
|
+
readonly email: string
|
|
288
|
+
readonly name: string
|
|
289
|
+
readonly createdAt: Date
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
interface Product {
|
|
293
|
+
readonly sku: string
|
|
294
|
+
readonly name: string
|
|
295
|
+
readonly price: number
|
|
296
|
+
readonly inStock: boolean
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
interface Order {
|
|
300
|
+
readonly id: string
|
|
301
|
+
readonly userId: string
|
|
302
|
+
readonly items: ReadonlyArray<OrderItem>
|
|
303
|
+
readonly total: number
|
|
304
|
+
readonly status: OrderStatus
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
interface OrderItem {
|
|
308
|
+
readonly productSku: string
|
|
309
|
+
readonly quantity: number
|
|
310
|
+
readonly unitPrice: number
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
type OrderStatus = "pending" | "confirmed" | "shipped" | "delivered"
|
|
314
|
+
|
|
315
|
+
// ============================================
|
|
316
|
+
// 2. Create domain functions
|
|
317
|
+
// ============================================
|
|
318
|
+
|
|
319
|
+
const createUser = (email: string, name: string): User => ({
|
|
320
|
+
id: crypto.randomUUID(),
|
|
321
|
+
email,
|
|
322
|
+
name,
|
|
323
|
+
createdAt: new Date(),
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
const calculateOrderTotal = (items: ReadonlyArray<OrderItem>): number =>
|
|
327
|
+
items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0)
|
|
328
|
+
|
|
329
|
+
// ============================================
|
|
330
|
+
// 3. Use in Effect programs
|
|
331
|
+
// ============================================
|
|
332
|
+
|
|
333
|
+
const program = Effect.gen(function* () {
|
|
334
|
+
const user = createUser("alice@example.com", "Alice")
|
|
335
|
+
yield* Effect.log(`Created user: ${user.name}`)
|
|
336
|
+
|
|
337
|
+
const items: OrderItem[] = [
|
|
338
|
+
{ productSku: "WIDGET-001", quantity: 2, unitPrice: 29.99 },
|
|
339
|
+
{ productSku: "GADGET-002", quantity: 1, unitPrice: 49.99 },
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
const order: Order = {
|
|
343
|
+
id: crypto.randomUUID(),
|
|
344
|
+
userId: user.id,
|
|
345
|
+
items,
|
|
346
|
+
total: calculateOrderTotal(items),
|
|
347
|
+
status: "pending",
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
yield* Effect.log(`Order total: $${order.total.toFixed(2)}`)
|
|
351
|
+
return order
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
Effect.runPromise(program)
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
**Rationale:**
|
|
358
|
+
|
|
359
|
+
Start by defining TypeScript interfaces that represent your business entities. Use descriptive names that match your domain language.
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
Good domain modeling:
|
|
365
|
+
|
|
366
|
+
1. **Clarifies intent** - Types document what data means
|
|
367
|
+
2. **Prevents errors** - Compiler catches wrong data usage
|
|
368
|
+
3. **Enables tooling** - IDE autocompletion and refactoring
|
|
369
|
+
4. **Communicates** - Code becomes documentation
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
## 🟡 Intermediate Patterns
|
|
377
|
+
|
|
378
|
+
### Model Optional Values Safely with Option
|
|
379
|
+
|
|
380
|
+
**Rule:** Use Option<A> to explicitly model values that may be absent, avoiding null or undefined.
|
|
381
|
+
|
|
382
|
+
**Good Example:**
|
|
383
|
+
|
|
384
|
+
A function that looks for a user in a database is a classic use case. It might find a user, or it might not. Returning an `Option<User>` makes this contract explicit and safe.
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
import { Effect, Option } from "effect";
|
|
388
|
+
|
|
389
|
+
interface User {
|
|
390
|
+
id: number;
|
|
391
|
+
name: string;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const users: User[] = [
|
|
395
|
+
{ id: 1, name: "Paul" },
|
|
396
|
+
{ id: 2, name: "Alex" },
|
|
397
|
+
];
|
|
398
|
+
|
|
399
|
+
// This function safely returns an Option, not a User or null.
|
|
400
|
+
const findUserById = (id: number): Option.Option<User> => {
|
|
401
|
+
const user = users.find((u) => u.id === id);
|
|
402
|
+
return Option.fromNullable(user); // A useful helper for existing APIs
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// The caller MUST handle both cases.
|
|
406
|
+
const greeting = (id: number): string =>
|
|
407
|
+
findUserById(id).pipe(
|
|
408
|
+
Option.match({
|
|
409
|
+
onNone: () => "User not found.",
|
|
410
|
+
onSome: (user) => `Welcome, ${user.name}!`,
|
|
411
|
+
})
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const program = Effect.gen(function* () {
|
|
415
|
+
yield* Effect.log(greeting(1)); // "Welcome, Paul!"
|
|
416
|
+
yield* Effect.log(greeting(3)); // "User not found."
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
Effect.runPromise(program);
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
**Anti-Pattern:**
|
|
423
|
+
|
|
424
|
+
The anti-pattern is returning a nullable type (e.g., User | null or User | undefined). This relies on the discipline of every single caller to perform a null check. Forgetting even one check can introduce a runtime error.
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
interface User {
|
|
428
|
+
id: number;
|
|
429
|
+
name: string;
|
|
430
|
+
}
|
|
431
|
+
const users: User[] = [{ id: 1, name: "Paul" }];
|
|
432
|
+
|
|
433
|
+
// ❌ WRONG: This function's return type is less safe.
|
|
434
|
+
const findUserUnsafely = (id: number): User | undefined => {
|
|
435
|
+
return users.find((u) => u.id === id);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const user = findUserUnsafely(3);
|
|
439
|
+
|
|
440
|
+
// This will throw "TypeError: Cannot read properties of undefined (reading 'name')"
|
|
441
|
+
// because the caller forgot to check if the user exists.
|
|
442
|
+
console.log(`User's name is ${user.name}`);
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
**Rationale:**
|
|
446
|
+
|
|
447
|
+
Represent values that may be absent with `Option<A>`. Use `Option.some(value)` to represent a present value and `Option.none()` for an absent one. This creates a container that forces you to handle both possibilities.
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
Functions that can return a value or `null`/`undefined` are a primary source of runtime errors in TypeScript (`Cannot read properties of null`).
|
|
453
|
+
|
|
454
|
+
The `Option` type solves this by making the possibility of an absent value explicit in the type system. A function that returns `Option<User>` cannot be mistaken for a function that returns `User`. The compiler forces you to handle the `None` case before you can access the value inside a `Some`, eliminating an entire class of bugs.
|
|
455
|
+
|
|
456
|
+
---
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
### Use Effect.gen for Business Logic
|
|
461
|
+
|
|
462
|
+
**Rule:** Use Effect.gen for business logic.
|
|
463
|
+
|
|
464
|
+
**Good Example:**
|
|
465
|
+
|
|
466
|
+
```typescript
|
|
467
|
+
import { Effect } from "effect";
|
|
468
|
+
|
|
469
|
+
// Concrete implementations for demonstration
|
|
470
|
+
const validateUser = (
|
|
471
|
+
data: any
|
|
472
|
+
): Effect.Effect<{ email: string; password: string }, Error, never> =>
|
|
473
|
+
Effect.gen(function* () {
|
|
474
|
+
yield* Effect.logInfo(`Validating user data: ${JSON.stringify(data)}`);
|
|
475
|
+
|
|
476
|
+
if (!data.email || !data.password) {
|
|
477
|
+
return yield* Effect.fail(new Error("Email and password are required"));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (data.password.length < 6) {
|
|
481
|
+
return yield* Effect.fail(
|
|
482
|
+
new Error("Password must be at least 6 characters")
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
yield* Effect.logInfo("✅ User data validated successfully");
|
|
487
|
+
return { email: data.email, password: data.password };
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const hashPassword = (pw: string): Effect.Effect<string, never, never> =>
|
|
491
|
+
Effect.gen(function* () {
|
|
492
|
+
yield* Effect.logInfo("Hashing password...");
|
|
493
|
+
// Simulate password hashing
|
|
494
|
+
const timestamp = yield* Effect.sync(() => Date.now());
|
|
495
|
+
const hashed = `hashed_${pw}_${timestamp}`;
|
|
496
|
+
yield* Effect.logInfo("✅ Password hashed successfully");
|
|
497
|
+
return hashed;
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const dbCreateUser = (data: {
|
|
501
|
+
email: string;
|
|
502
|
+
password: string;
|
|
503
|
+
}): Effect.Effect<{ id: number; email: string }, never, never> =>
|
|
504
|
+
Effect.gen(function* () {
|
|
505
|
+
yield* Effect.logInfo(`Creating user in database: ${data.email}`);
|
|
506
|
+
// Simulate database operation
|
|
507
|
+
const user = { id: Math.floor(Math.random() * 1000), email: data.email };
|
|
508
|
+
yield* Effect.logInfo(`✅ User created with ID: ${user.id}`);
|
|
509
|
+
return user;
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const createUser = (
|
|
513
|
+
userData: any
|
|
514
|
+
): Effect.Effect<{ id: number; email: string }, Error, never> =>
|
|
515
|
+
Effect.gen(function* () {
|
|
516
|
+
const validated = yield* validateUser(userData);
|
|
517
|
+
const hashed = yield* hashPassword(validated.password);
|
|
518
|
+
return yield* dbCreateUser({ ...validated, password: hashed });
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// Demonstrate using Effect.gen for business logic
|
|
522
|
+
const program = Effect.gen(function* () {
|
|
523
|
+
yield* Effect.logInfo("=== Using Effect.gen for Business Logic Demo ===");
|
|
524
|
+
|
|
525
|
+
// Example 1: Successful user creation
|
|
526
|
+
yield* Effect.logInfo("\n1. Creating a valid user:");
|
|
527
|
+
const validUser = yield* createUser({
|
|
528
|
+
email: "paul@example.com",
|
|
529
|
+
password: "securepassword123",
|
|
530
|
+
}).pipe(
|
|
531
|
+
Effect.catchAll((error) =>
|
|
532
|
+
Effect.gen(function* () {
|
|
533
|
+
yield* Effect.logError(`Failed to create user: ${error.message}`);
|
|
534
|
+
return { id: -1, email: "error" };
|
|
535
|
+
})
|
|
536
|
+
)
|
|
537
|
+
);
|
|
538
|
+
yield* Effect.logInfo(`Created user: ${JSON.stringify(validUser)}`);
|
|
539
|
+
|
|
540
|
+
// Example 2: Invalid user data
|
|
541
|
+
yield* Effect.logInfo("\n2. Attempting to create user with invalid data:");
|
|
542
|
+
const invalidUser = yield* createUser({
|
|
543
|
+
email: "invalid@example.com",
|
|
544
|
+
password: "123", // Too short
|
|
545
|
+
}).pipe(
|
|
546
|
+
Effect.catchAll((error) =>
|
|
547
|
+
Effect.gen(function* () {
|
|
548
|
+
yield* Effect.logError(`Failed to create user: ${error.message}`);
|
|
549
|
+
return { id: -1, email: "error" };
|
|
550
|
+
})
|
|
551
|
+
)
|
|
552
|
+
);
|
|
553
|
+
yield* Effect.logInfo(`Result: ${JSON.stringify(invalidUser)}`);
|
|
554
|
+
|
|
555
|
+
yield* Effect.logInfo("\n✅ Business logic demonstration completed!");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
Effect.runPromise(program);
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
**Explanation:**
|
|
562
|
+
`Effect.gen` allows you to express business logic in a clear, sequential style,
|
|
563
|
+
improving maintainability.
|
|
564
|
+
|
|
565
|
+
**Anti-Pattern:**
|
|
566
|
+
|
|
567
|
+
Using long chains of `.andThen` or `.flatMap` for multi-step business logic.
|
|
568
|
+
This is harder to read and pass state between steps.
|
|
569
|
+
|
|
570
|
+
**Rationale:**
|
|
571
|
+
|
|
572
|
+
Use `Effect.gen` to write your core business logic, especially when it involves
|
|
573
|
+
multiple sequential steps or conditional branching.
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
Generators provide a syntax that closely resembles standard synchronous code
|
|
577
|
+
(`async/await`), making complex workflows significantly easier to read, write,
|
|
578
|
+
and debug.
|
|
579
|
+
|
|
580
|
+
---
|
|
581
|
+
|
|
582
|
+
### Transform Data During Validation with Schema
|
|
583
|
+
|
|
584
|
+
**Rule:** Use Schema.transform to safely convert data types during the validation and parsing process.
|
|
585
|
+
|
|
586
|
+
**Good Example:**
|
|
587
|
+
|
|
588
|
+
This schema parses a string but produces a `Date` object, making the final data structure much more useful.
|
|
589
|
+
|
|
590
|
+
```typescript
|
|
591
|
+
import { Schema, Effect } from "effect";
|
|
592
|
+
|
|
593
|
+
// Define types for better type safety
|
|
594
|
+
type RawEvent = {
|
|
595
|
+
name: string;
|
|
596
|
+
timestamp: string;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
type ParsedEvent = {
|
|
600
|
+
name: string;
|
|
601
|
+
timestamp: Date;
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
// Define the schema for our event
|
|
605
|
+
const ApiEventSchema = Schema.Struct({
|
|
606
|
+
name: Schema.String,
|
|
607
|
+
timestamp: Schema.String,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// Example input
|
|
611
|
+
const rawInput: RawEvent = {
|
|
612
|
+
name: "User Login",
|
|
613
|
+
timestamp: "2025-06-22T20:08:42.000Z",
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
// Parse and transform
|
|
617
|
+
const program = Effect.gen(function* () {
|
|
618
|
+
const parsed = yield* Schema.decode(ApiEventSchema)(rawInput);
|
|
619
|
+
return {
|
|
620
|
+
name: parsed.name,
|
|
621
|
+
timestamp: new Date(parsed.timestamp),
|
|
622
|
+
} as ParsedEvent;
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const programWithLogging = Effect.gen(function* () {
|
|
626
|
+
try {
|
|
627
|
+
const event = yield* program;
|
|
628
|
+
yield* Effect.log(`Event year: ${event.timestamp.getFullYear()}`);
|
|
629
|
+
yield* Effect.log(`Full event: ${JSON.stringify(event, null, 2)}`);
|
|
630
|
+
return event;
|
|
631
|
+
} catch (error) {
|
|
632
|
+
yield* Effect.logError(`Failed to parse event: ${error}`);
|
|
633
|
+
throw error;
|
|
634
|
+
}
|
|
635
|
+
}).pipe(
|
|
636
|
+
Effect.catchAll((error) =>
|
|
637
|
+
Effect.gen(function* () {
|
|
638
|
+
yield* Effect.logError(`Program error: ${error}`);
|
|
639
|
+
return null;
|
|
640
|
+
})
|
|
641
|
+
)
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
Effect.runPromise(programWithLogging);
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
`transformOrFail` is perfect for creating branded types, as the validation can fail.
|
|
649
|
+
|
|
650
|
+
```typescript
|
|
651
|
+
import { Schema, Effect, Brand, Either } from "effect";
|
|
652
|
+
|
|
653
|
+
type Email = string & Brand.Brand<"Email">;
|
|
654
|
+
const Email = Schema.string.pipe(
|
|
655
|
+
Schema.transformOrFail(
|
|
656
|
+
Schema.brand<Email>("Email"),
|
|
657
|
+
(s, _, ast) =>
|
|
658
|
+
s.includes("@")
|
|
659
|
+
? Either.right(s as Email)
|
|
660
|
+
: Either.left(Schema.ParseError.create(ast, "Invalid email format")),
|
|
661
|
+
(email) => Either.right(email)
|
|
662
|
+
)
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
const result = Schema.decode(Email)("paul@example.com"); // Succeeds
|
|
666
|
+
const errorResult = Schema.decode(Email)("invalid-email"); // Fails
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
---
|
|
670
|
+
|
|
671
|
+
**Anti-Pattern:**
|
|
672
|
+
|
|
673
|
+
Performing validation and transformation in two separate steps. This is more verbose, requires creating intermediate types, and separates the validation logic from the transformation logic.
|
|
674
|
+
|
|
675
|
+
```typescript
|
|
676
|
+
import { Schema, Effect } from "effect";
|
|
677
|
+
|
|
678
|
+
// ❌ WRONG: Requires an intermediate "Raw" type.
|
|
679
|
+
const RawApiEventSchema = Schema.Struct({
|
|
680
|
+
name: Schema.String,
|
|
681
|
+
timestamp: Schema.String,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
const rawInput = { name: "User Login", timestamp: "2025-06-22T20:08:42.000Z" };
|
|
685
|
+
|
|
686
|
+
// The logic is now split into two distinct, less cohesive steps.
|
|
687
|
+
const program = Schema.decode(RawApiEventSchema)(rawInput).pipe(
|
|
688
|
+
Effect.map((rawEvent) => ({
|
|
689
|
+
...rawEvent,
|
|
690
|
+
timestamp: new Date(rawEvent.timestamp), // Manual transformation after parsing.
|
|
691
|
+
}))
|
|
692
|
+
);
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
**Rationale:**
|
|
696
|
+
|
|
697
|
+
To convert data from one type to another as part of the validation process, use `Schema.transform`. This allows you to define a schema that parses an input type (e.g., `string`) and outputs a different, richer domain type (e.g., `Date`).
|
|
698
|
+
|
|
699
|
+
---
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
Often, the data you receive from external sources (like an API) isn't in the ideal format for your application's domain model. For example, dates are sent as ISO strings, but you want to work with `Date` objects.
|
|
703
|
+
|
|
704
|
+
`Schema.transform` integrates this conversion directly into the parsing step. It takes two functions: one to `decode` the input type into the domain type, and one to `encode` it back. This makes your schema the single source of truth for both the shape and the type transformation of your data.
|
|
705
|
+
|
|
706
|
+
For transformations that can fail (like creating a branded type), you can use `Schema.transformOrFail`, which allows the decoding step to return an `Either`.
|
|
707
|
+
|
|
708
|
+
---
|
|
709
|
+
|
|
710
|
+
---
|
|
711
|
+
|
|
712
|
+
### Define Type-Safe Errors with Data.TaggedError
|
|
713
|
+
|
|
714
|
+
**Rule:** Define type-safe errors with Data.TaggedError.
|
|
715
|
+
|
|
716
|
+
**Good Example:**
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
import { Data, Effect } from "effect";
|
|
720
|
+
|
|
721
|
+
// Define our tagged error type
|
|
722
|
+
class DatabaseError extends Data.TaggedError("DatabaseError")<{
|
|
723
|
+
readonly cause: unknown;
|
|
724
|
+
}> {}
|
|
725
|
+
|
|
726
|
+
// Function that simulates a database error
|
|
727
|
+
const findUser = (
|
|
728
|
+
id: number
|
|
729
|
+
): Effect.Effect<{ id: number; name: string }, DatabaseError> =>
|
|
730
|
+
Effect.gen(function* () {
|
|
731
|
+
if (id < 0) {
|
|
732
|
+
return yield* Effect.fail(new DatabaseError({ cause: "Invalid ID" }));
|
|
733
|
+
}
|
|
734
|
+
return { id, name: `User ${id}` };
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// Create a program that demonstrates error handling
|
|
738
|
+
const program = Effect.gen(function* () {
|
|
739
|
+
// Try to find a valid user
|
|
740
|
+
yield* Effect.logInfo("Looking up user 1...");
|
|
741
|
+
yield* Effect.gen(function* () {
|
|
742
|
+
const user = yield* findUser(1);
|
|
743
|
+
yield* Effect.logInfo(`Found user: ${JSON.stringify(user)}`);
|
|
744
|
+
}).pipe(
|
|
745
|
+
Effect.catchAll((error) =>
|
|
746
|
+
Effect.logInfo(`Error finding user: ${error._tag} - ${error.cause}`)
|
|
747
|
+
)
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
// Try to find an invalid user
|
|
751
|
+
yield* Effect.logInfo("\nLooking up user -1...");
|
|
752
|
+
yield* Effect.gen(function* () {
|
|
753
|
+
const user = yield* findUser(-1);
|
|
754
|
+
yield* Effect.logInfo(`Found user: ${JSON.stringify(user)}`);
|
|
755
|
+
}).pipe(
|
|
756
|
+
Effect.catchTag("DatabaseError", (error) =>
|
|
757
|
+
Effect.logInfo(`Database error: ${error._tag} - ${error.cause}`)
|
|
758
|
+
)
|
|
759
|
+
);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// Run the program
|
|
763
|
+
Effect.runPromise(program);
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
**Explanation:**
|
|
767
|
+
Tagged errors allow you to handle errors in a type-safe, self-documenting way.
|
|
768
|
+
|
|
769
|
+
**Anti-Pattern:**
|
|
770
|
+
|
|
771
|
+
Using generic `Error` objects or strings in the error channel. This loses all
|
|
772
|
+
type information, forcing consumers to use `catchAll` and perform unsafe
|
|
773
|
+
checks.
|
|
774
|
+
|
|
775
|
+
**Rationale:**
|
|
776
|
+
|
|
777
|
+
For any distinct failure mode in your application, define a custom error class
|
|
778
|
+
that extends `Data.TaggedError`.
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
This gives each error a unique, literal `_tag` that Effect can use for type
|
|
782
|
+
discrimination with `Effect.catchTag`, making your error handling fully
|
|
783
|
+
type-safe.
|
|
784
|
+
|
|
785
|
+
---
|
|
786
|
+
|
|
787
|
+
### Define Contracts Upfront with Schema
|
|
788
|
+
|
|
789
|
+
**Rule:** Define contracts upfront with schema.
|
|
790
|
+
|
|
791
|
+
**Good Example:**
|
|
792
|
+
|
|
793
|
+
```typescript
|
|
794
|
+
import { Schema, Effect, Data } from "effect";
|
|
795
|
+
|
|
796
|
+
// Define User schema and type
|
|
797
|
+
const UserSchema = Schema.Struct({
|
|
798
|
+
id: Schema.Number,
|
|
799
|
+
name: Schema.String,
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
type User = Schema.Schema.Type<typeof UserSchema>;
|
|
803
|
+
|
|
804
|
+
// Define error type
|
|
805
|
+
class UserNotFound extends Data.TaggedError("UserNotFound")<{
|
|
806
|
+
readonly id: number;
|
|
807
|
+
}> {}
|
|
808
|
+
|
|
809
|
+
// Create database service implementation
|
|
810
|
+
export class Database extends Effect.Service<Database>()("Database", {
|
|
811
|
+
sync: () => ({
|
|
812
|
+
getUser: (id: number) =>
|
|
813
|
+
id === 1
|
|
814
|
+
? Effect.succeed({ id: 1, name: "John" })
|
|
815
|
+
: Effect.fail(new UserNotFound({ id })),
|
|
816
|
+
}),
|
|
817
|
+
}) {}
|
|
818
|
+
|
|
819
|
+
// Create a program that demonstrates schema and error handling
|
|
820
|
+
const program = Effect.gen(function* () {
|
|
821
|
+
const db = yield* Database;
|
|
822
|
+
|
|
823
|
+
// Try to get an existing user
|
|
824
|
+
yield* Effect.logInfo("Looking up user 1...");
|
|
825
|
+
const user1 = yield* db.getUser(1);
|
|
826
|
+
yield* Effect.logInfo(`Found user: ${JSON.stringify(user1)}`);
|
|
827
|
+
|
|
828
|
+
// Try to get a non-existent user
|
|
829
|
+
yield* Effect.logInfo("\nLooking up user 999...");
|
|
830
|
+
yield* Effect.logInfo("Attempting to get user 999...");
|
|
831
|
+
yield* Effect.gen(function* () {
|
|
832
|
+
const user = yield* db.getUser(999);
|
|
833
|
+
yield* Effect.logInfo(`Found user: ${JSON.stringify(user)}`);
|
|
834
|
+
}).pipe(
|
|
835
|
+
Effect.catchAll((error) => {
|
|
836
|
+
if (error instanceof UserNotFound) {
|
|
837
|
+
return Effect.logInfo(`Error: User with id ${error.id} not found`);
|
|
838
|
+
}
|
|
839
|
+
return Effect.logInfo(`Unexpected error: ${error}`);
|
|
840
|
+
})
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
// Try to decode invalid data
|
|
844
|
+
yield* Effect.logInfo("\nTrying to decode invalid user data...");
|
|
845
|
+
const invalidUser = { id: "not-a-number", name: 123 } as any;
|
|
846
|
+
yield* Effect.gen(function* () {
|
|
847
|
+
const user = yield* Schema.decode(UserSchema)(invalidUser);
|
|
848
|
+
yield* Effect.logInfo(`Decoded user: ${JSON.stringify(user)}`);
|
|
849
|
+
}).pipe(
|
|
850
|
+
Effect.catchAll((error) =>
|
|
851
|
+
Effect.logInfo(`Validation failed:\n${JSON.stringify(error, null, 2)}`)
|
|
852
|
+
)
|
|
853
|
+
);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// Run the program
|
|
857
|
+
Effect.runPromise(Effect.provide(program, Database.Default));
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
**Explanation:**
|
|
861
|
+
Defining schemas upfront clarifies your contracts and ensures both type safety
|
|
862
|
+
and runtime validation.
|
|
863
|
+
|
|
864
|
+
**Anti-Pattern:**
|
|
865
|
+
|
|
866
|
+
Defining logic with implicit `any` types first and adding validation later as
|
|
867
|
+
an afterthought. This leads to brittle code that lacks a clear contract.
|
|
868
|
+
|
|
869
|
+
**Rationale:**
|
|
870
|
+
|
|
871
|
+
Before writing implementation logic, define the shape of your data models and
|
|
872
|
+
function signatures using `Effect/Schema`.
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
This "schema-first" approach separates the "what" (the data shape) from the
|
|
876
|
+
"how" (the implementation). It provides a single source of truth for both
|
|
877
|
+
compile-time static types and runtime validation.
|
|
878
|
+
|
|
879
|
+
---
|
|
880
|
+
|
|
881
|
+
### Modeling Validated Domain Types with Brand
|
|
882
|
+
|
|
883
|
+
**Rule:** Use Brand to define types like Email, UserId, or PositiveInt, ensuring only valid values can be constructed and used.
|
|
884
|
+
|
|
885
|
+
**Good Example:**
|
|
886
|
+
|
|
887
|
+
```typescript
|
|
888
|
+
import { Brand } from "effect";
|
|
889
|
+
|
|
890
|
+
// Define a branded type for Email
|
|
891
|
+
type Email = string & Brand.Brand<"Email">;
|
|
892
|
+
|
|
893
|
+
// Function that only accepts Email, not any string
|
|
894
|
+
function sendWelcome(email: Email) {
|
|
895
|
+
// ...
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Constructing an Email value (unsafe, see next pattern for validation)
|
|
899
|
+
const email = "user@example.com" as Email;
|
|
900
|
+
|
|
901
|
+
sendWelcome(email); // OK
|
|
902
|
+
// sendWelcome("not-an-email"); // Type error! (commented to allow compilation)
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
**Explanation:**
|
|
906
|
+
|
|
907
|
+
- `Brand.Branded<T, Name>` creates a new type that is distinct from its base type.
|
|
908
|
+
- Only values explicitly branded as `Email` can be used where an `Email` is required.
|
|
909
|
+
- This prevents accidental mixing of domain types.
|
|
910
|
+
|
|
911
|
+
**Anti-Pattern:**
|
|
912
|
+
|
|
913
|
+
Using plain strings or numbers for domain-specific values (like emails, user IDs, or currency codes), which can lead to accidental misuse and bugs that are hard to catch.
|
|
914
|
+
|
|
915
|
+
**Rationale:**
|
|
916
|
+
|
|
917
|
+
Use the `Brand` utility to create domain-specific types from primitives like `string` or `number`.
|
|
918
|
+
This prevents accidental misuse and makes illegal states unrepresentable in your codebase.
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
Branded types add a layer of type safety, ensuring that values like `Email`, `UserId`, or `PositiveInt` are not confused with plain strings or numbers.
|
|
922
|
+
They help you catch bugs at compile time and make your code more self-documenting.
|
|
923
|
+
|
|
924
|
+
---
|
|
925
|
+
|
|
926
|
+
### Parse and Validate Data with Schema.decode
|
|
927
|
+
|
|
928
|
+
**Rule:** Parse and validate data with Schema.decode.
|
|
929
|
+
|
|
930
|
+
**Good Example:**
|
|
931
|
+
|
|
932
|
+
```typescript
|
|
933
|
+
import { Effect, Schema } from "effect";
|
|
934
|
+
|
|
935
|
+
interface User {
|
|
936
|
+
name: string;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const UserSchema = Schema.Struct({
|
|
940
|
+
name: Schema.String,
|
|
941
|
+
}) as Schema.Schema<User>;
|
|
942
|
+
|
|
943
|
+
const processUserInput = (input: unknown) =>
|
|
944
|
+
Effect.gen(function* () {
|
|
945
|
+
const user = yield* Schema.decodeUnknown(UserSchema)(input);
|
|
946
|
+
return `Welcome, ${user.name}!`;
|
|
947
|
+
}).pipe(
|
|
948
|
+
Effect.catchTag("ParseError", () => Effect.succeed("Invalid user data."))
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
// Demonstrate the schema parsing
|
|
952
|
+
const program = Effect.gen(function* () {
|
|
953
|
+
// Test with valid input
|
|
954
|
+
const validInput = { name: "Paul" };
|
|
955
|
+
const validResult = yield* processUserInput(validInput);
|
|
956
|
+
yield* Effect.logInfo(`Valid input result: ${validResult}`);
|
|
957
|
+
|
|
958
|
+
// Test with invalid input
|
|
959
|
+
const invalidInput = { age: 25 }; // Missing 'name' field
|
|
960
|
+
const invalidResult = yield* processUserInput(invalidInput);
|
|
961
|
+
yield* Effect.logInfo(`Invalid input result: ${invalidResult}`);
|
|
962
|
+
|
|
963
|
+
// Test with completely invalid input
|
|
964
|
+
const badInput = "not an object";
|
|
965
|
+
const badResult = yield* processUserInput(badInput);
|
|
966
|
+
yield* Effect.logInfo(`Bad input result: ${badResult}`);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
Effect.runPromise(program);
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
**Explanation:**
|
|
973
|
+
`Schema.decode` integrates parsing and validation into the Effect workflow,
|
|
974
|
+
making error handling composable and type-safe.
|
|
975
|
+
|
|
976
|
+
**Anti-Pattern:**
|
|
977
|
+
|
|
978
|
+
Using `Schema.parse(schema)(input)`, as it throws an exception. This forces
|
|
979
|
+
you to use `try/catch` blocks, which breaks the composability of Effect.
|
|
980
|
+
|
|
981
|
+
**Rationale:**
|
|
982
|
+
|
|
983
|
+
When you need to parse or validate data against a `Schema`, use the
|
|
984
|
+
`Schema.decode(schema)` function. It takes an `unknown` input and returns an
|
|
985
|
+
`Effect`.
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
Unlike the older `Schema.parse` which throws, `Schema.decode` is fully
|
|
989
|
+
integrated into the Effect ecosystem, allowing you to handle validation
|
|
990
|
+
failures gracefully with operators like `Effect.catchTag`.
|
|
991
|
+
|
|
992
|
+
---
|
|
993
|
+
|
|
994
|
+
### Validating and Parsing Branded Types
|
|
995
|
+
|
|
996
|
+
**Rule:** Combine Schema and Brand to validate and parse branded types, guaranteeing only valid domain values are created at runtime.
|
|
997
|
+
|
|
998
|
+
**Good Example:**
|
|
999
|
+
|
|
1000
|
+
```typescript
|
|
1001
|
+
import { Brand, Effect, Schema } from "effect";
|
|
1002
|
+
|
|
1003
|
+
// Define a branded type for Email
|
|
1004
|
+
type Email = string & Brand.Brand<"Email">;
|
|
1005
|
+
|
|
1006
|
+
// Create a Schema for Email validation
|
|
1007
|
+
const EmailSchema = Schema.String.pipe(
|
|
1008
|
+
Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/), // Simple email regex
|
|
1009
|
+
Schema.brand("Email" as const) // Attach the brand
|
|
1010
|
+
);
|
|
1011
|
+
|
|
1012
|
+
// Parse and validate an email at runtime
|
|
1013
|
+
const parseEmail = (input: string) =>
|
|
1014
|
+
Effect.try({
|
|
1015
|
+
try: () => Schema.decodeSync(EmailSchema)(input),
|
|
1016
|
+
catch: (err) => `Invalid email: ${String(err)}`,
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
// Usage
|
|
1020
|
+
parseEmail("user@example.com").pipe(
|
|
1021
|
+
Effect.match({
|
|
1022
|
+
onSuccess: (email) => console.log("Valid email:", email),
|
|
1023
|
+
onFailure: (err) => console.error(err),
|
|
1024
|
+
})
|
|
1025
|
+
);
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
**Explanation:**
|
|
1029
|
+
|
|
1030
|
+
- `Schema` is used to define validation logic for the branded type.
|
|
1031
|
+
- `Brand.schema<Email>()` attaches the brand to the schema, so only validated values can be constructed as `Email`.
|
|
1032
|
+
- This pattern ensures both compile-time and runtime safety.
|
|
1033
|
+
|
|
1034
|
+
**Anti-Pattern:**
|
|
1035
|
+
|
|
1036
|
+
Branding values without runtime validation, or accepting unvalidated user input as branded types, which can lead to invalid domain values and runtime bugs.
|
|
1037
|
+
|
|
1038
|
+
**Rationale:**
|
|
1039
|
+
|
|
1040
|
+
Use `Schema` in combination with `Brand` to validate and parse branded types at runtime.
|
|
1041
|
+
This ensures that only values passing your validation logic can be constructed as branded types, making your domain models robust and type-safe.
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
While branding types at the type level prevents accidental misuse, runtime validation is needed to ensure only valid values are constructed from user input, APIs, or external sources.
|
|
1045
|
+
|
|
1046
|
+
---
|
|
1047
|
+
|
|
1048
|
+
### Avoid Long Chains of .andThen; Use Generators Instead
|
|
1049
|
+
|
|
1050
|
+
**Rule:** Prefer generators over long chains of .andThen.
|
|
1051
|
+
|
|
1052
|
+
**Good Example:**
|
|
1053
|
+
|
|
1054
|
+
```typescript
|
|
1055
|
+
import { Effect } from "effect";
|
|
1056
|
+
|
|
1057
|
+
// Define our steps with logging
|
|
1058
|
+
const step1 = (): Effect.Effect<number> =>
|
|
1059
|
+
Effect.succeed(42).pipe(Effect.tap((n) => Effect.log(`Step 1: ${n}`)));
|
|
1060
|
+
|
|
1061
|
+
const step2 = (a: number): Effect.Effect<string> =>
|
|
1062
|
+
Effect.succeed(`Result: ${a * 2}`).pipe(
|
|
1063
|
+
Effect.tap((s) => Effect.log(`Step 2: ${s}`))
|
|
1064
|
+
);
|
|
1065
|
+
|
|
1066
|
+
// Using Effect.gen for better readability
|
|
1067
|
+
const program = Effect.gen(function* () {
|
|
1068
|
+
const a = yield* step1();
|
|
1069
|
+
const b = yield* step2(a);
|
|
1070
|
+
return b;
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// Run the program
|
|
1074
|
+
const programWithLogging = Effect.gen(function* () {
|
|
1075
|
+
const result = yield* program;
|
|
1076
|
+
yield* Effect.log(`Final result: ${result}`);
|
|
1077
|
+
return result;
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
Effect.runPromise(programWithLogging);
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
**Explanation:**
|
|
1084
|
+
Generators keep sequential logic readable and easy to maintain.
|
|
1085
|
+
|
|
1086
|
+
**Anti-Pattern:**
|
|
1087
|
+
|
|
1088
|
+
```typescript
|
|
1089
|
+
import { Effect } from "effect";
|
|
1090
|
+
declare const step1: () => Effect.Effect<any>;
|
|
1091
|
+
declare const step2: (a: any) => Effect.Effect<any>;
|
|
1092
|
+
|
|
1093
|
+
step1().pipe(Effect.flatMap((a) => step2(a))); // Or .andThen
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
Chaining many `.flatMap` or `.andThen` calls leads to deeply nested,
|
|
1097
|
+
hard-to-read code.
|
|
1098
|
+
|
|
1099
|
+
**Rationale:**
|
|
1100
|
+
|
|
1101
|
+
For sequential logic involving more than two steps, prefer `Effect.gen` over
|
|
1102
|
+
chaining multiple `.andThen` or `.flatMap` calls.
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
`Effect.gen` provides a flat, linear code structure that is easier to read and
|
|
1106
|
+
debug than deeply nested functional chains.
|
|
1107
|
+
|
|
1108
|
+
---
|
|
1109
|
+
|
|
1110
|
+
### Distinguish 'Not Found' from Errors
|
|
1111
|
+
|
|
1112
|
+
**Rule:** Use Effect<Option<A>> to distinguish between recoverable 'not found' cases and actual failures.
|
|
1113
|
+
|
|
1114
|
+
**Good Example:**
|
|
1115
|
+
|
|
1116
|
+
This function to find a user can fail if the database is down, or it can succeed but find no user. The return type `Effect.Effect<Option.Option<User>, DatabaseError>` makes this contract perfectly clear.
|
|
1117
|
+
|
|
1118
|
+
```typescript
|
|
1119
|
+
import { Effect, Option, Data } from "effect";
|
|
1120
|
+
|
|
1121
|
+
interface User {
|
|
1122
|
+
id: number;
|
|
1123
|
+
name: string;
|
|
1124
|
+
}
|
|
1125
|
+
class DatabaseError extends Data.TaggedError("DatabaseError") {}
|
|
1126
|
+
|
|
1127
|
+
// This signature is extremely honest about its possible outcomes.
|
|
1128
|
+
const findUserInDb = (
|
|
1129
|
+
id: number
|
|
1130
|
+
): Effect.Effect<Option.Option<User>, DatabaseError> =>
|
|
1131
|
+
Effect.gen(function* () {
|
|
1132
|
+
// This could fail with a DatabaseError
|
|
1133
|
+
const dbResult = yield* Effect.try({
|
|
1134
|
+
try: () => (id === 1 ? { id: 1, name: "Paul" } : null),
|
|
1135
|
+
catch: () => new DatabaseError(),
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
// We wrap the potentially null result in an Option
|
|
1139
|
+
return Option.fromNullable(dbResult);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
// The caller can now handle all three cases explicitly.
|
|
1143
|
+
const program = (id: number) =>
|
|
1144
|
+
findUserInDb(id).pipe(
|
|
1145
|
+
Effect.flatMap((maybeUser) =>
|
|
1146
|
+
Option.match(maybeUser, {
|
|
1147
|
+
onNone: () =>
|
|
1148
|
+
Effect.logInfo(`Result: User with ID ${id} was not found.`),
|
|
1149
|
+
onSome: (user) => Effect.logInfo(`Result: Found user ${user.name}.`),
|
|
1150
|
+
})
|
|
1151
|
+
),
|
|
1152
|
+
Effect.catchAll((error) =>
|
|
1153
|
+
Effect.logInfo("Error: Could not connect to the database.")
|
|
1154
|
+
)
|
|
1155
|
+
);
|
|
1156
|
+
|
|
1157
|
+
// Run the program with different IDs
|
|
1158
|
+
Effect.runPromise(
|
|
1159
|
+
Effect.gen(function* () {
|
|
1160
|
+
// Try with existing user
|
|
1161
|
+
yield* Effect.logInfo("Looking for user with ID 1...");
|
|
1162
|
+
yield* program(1);
|
|
1163
|
+
|
|
1164
|
+
// Try with non-existent user
|
|
1165
|
+
yield* Effect.logInfo("\nLooking for user with ID 2...");
|
|
1166
|
+
yield* program(2);
|
|
1167
|
+
})
|
|
1168
|
+
);
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
**Anti-Pattern:**
|
|
1172
|
+
|
|
1173
|
+
A common alternative is to create a specific NotFoundError and put it in the error channel alongside other errors.
|
|
1174
|
+
|
|
1175
|
+
```typescript
|
|
1176
|
+
class NotFoundError extends Data.TaggedError("NotFoundError") {}
|
|
1177
|
+
|
|
1178
|
+
// ❌ This signature conflates two different kinds of failure.
|
|
1179
|
+
const findUserUnsafely = (
|
|
1180
|
+
id: number
|
|
1181
|
+
): Effect.Effect<User, DatabaseError | NotFoundError> => {
|
|
1182
|
+
// ...
|
|
1183
|
+
return Effect.fail(new NotFoundError());
|
|
1184
|
+
};
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
While this works, it can be less expressive. It treats a "not found" result—which might be a normal part of your application's flow—the same as a catastrophic DatabaseError.
|
|
1188
|
+
|
|
1189
|
+
Using `Effect<Option<A>>` often leads to clearer and more precise business logic.
|
|
1190
|
+
|
|
1191
|
+
**Rationale:**
|
|
1192
|
+
|
|
1193
|
+
When a computation can fail (e.g., a network error) or succeed but find nothing, model its return type as `Effect<Option<A>>`. This separates the "hard failure" channel from the "soft failure" (or empty) channel.
|
|
1194
|
+
|
|
1195
|
+
---
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
This pattern provides a precise way to handle three distinct outcomes of an operation:
|
|
1199
|
+
|
|
1200
|
+
1. **Success with a value:** `Effect.succeed(Option.some(value))`
|
|
1201
|
+
2. **Success with no value:** `Effect.succeed(Option.none())` (e.g., user not found)
|
|
1202
|
+
3. **Failure:** `Effect.fail(new DatabaseError())` (e.g., database connection lost)
|
|
1203
|
+
|
|
1204
|
+
By using `Option` inside the success channel of an `Effect`, you keep the error channel clean for true, unexpected, or unrecoverable errors. The "not found" case is often an expected and recoverable part of your business logic, and `Option.none()` models this perfectly.
|
|
1205
|
+
|
|
1206
|
+
---
|
|
1207
|
+
|
|
1208
|
+
---
|
|
1209
|
+
|
|
1210
|
+
### Model Validated Domain Types with Brand
|
|
1211
|
+
|
|
1212
|
+
**Rule:** Model validated domain types with Brand.
|
|
1213
|
+
|
|
1214
|
+
**Good Example:**
|
|
1215
|
+
|
|
1216
|
+
```typescript
|
|
1217
|
+
import { Brand, Option } from "effect";
|
|
1218
|
+
|
|
1219
|
+
type Email = string & Brand.Brand<"Email">;
|
|
1220
|
+
|
|
1221
|
+
const makeEmail = (s: string): Option.Option<Email> =>
|
|
1222
|
+
s.includes("@") ? Option.some(s as Email) : Option.none();
|
|
1223
|
+
|
|
1224
|
+
// A function can now trust that its input is a valid email.
|
|
1225
|
+
const sendEmail = (email: Email, body: string) => {
|
|
1226
|
+
/* ... */
|
|
1227
|
+
};
|
|
1228
|
+
```
|
|
1229
|
+
|
|
1230
|
+
**Explanation:**
|
|
1231
|
+
Branding ensures that only validated values are used, reducing bugs and
|
|
1232
|
+
repetitive checks.
|
|
1233
|
+
|
|
1234
|
+
**Anti-Pattern:**
|
|
1235
|
+
|
|
1236
|
+
"Primitive obsession"—using raw primitives (`string`, `number`) and performing
|
|
1237
|
+
validation inside every function that uses them. This is repetitive and
|
|
1238
|
+
error-prone.
|
|
1239
|
+
|
|
1240
|
+
**Rationale:**
|
|
1241
|
+
|
|
1242
|
+
For domain primitives that have specific rules (e.g., a valid email), create a
|
|
1243
|
+
Branded Type. This ensures a value can only be created after passing a
|
|
1244
|
+
validation check.
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
This pattern moves validation to the boundaries of your system. Once a value
|
|
1248
|
+
has been branded, the rest of your application can trust that it is valid,
|
|
1249
|
+
eliminating repetitive checks.
|
|
1250
|
+
|
|
1251
|
+
---
|
|
1252
|
+
|
|
1253
|
+
### Accumulate Multiple Errors with Either
|
|
1254
|
+
|
|
1255
|
+
**Rule:** Use Either to accumulate multiple validation errors instead of failing on the first one.
|
|
1256
|
+
|
|
1257
|
+
**Good Example:**
|
|
1258
|
+
|
|
1259
|
+
Using `Schema.decode` with the `allErrors: true` option demonstrates this pattern perfectly. The underlying mechanism uses `Either` to collect all parsing errors into an array instead of stopping at the first one.
|
|
1260
|
+
|
|
1261
|
+
```typescript
|
|
1262
|
+
import { Effect, Schema, Data, Either } from "effect";
|
|
1263
|
+
|
|
1264
|
+
// Define validation error type
|
|
1265
|
+
class ValidationError extends Data.TaggedError("ValidationError")<{
|
|
1266
|
+
readonly field: string;
|
|
1267
|
+
readonly message: string;
|
|
1268
|
+
}> {}
|
|
1269
|
+
|
|
1270
|
+
// Define user type
|
|
1271
|
+
type User = {
|
|
1272
|
+
name: string;
|
|
1273
|
+
email: string;
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
// Define schema with custom validation
|
|
1277
|
+
const UserSchema = Schema.Struct({
|
|
1278
|
+
name: Schema.String.pipe(
|
|
1279
|
+
Schema.minLength(3),
|
|
1280
|
+
Schema.filter((name) => /^[A-Za-z\s]+$/.test(name), {
|
|
1281
|
+
message: () => "name must contain only letters and spaces",
|
|
1282
|
+
})
|
|
1283
|
+
),
|
|
1284
|
+
email: Schema.String.pipe(
|
|
1285
|
+
Schema.pattern(/@/),
|
|
1286
|
+
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {
|
|
1287
|
+
message: () => "email must be a valid email address",
|
|
1288
|
+
})
|
|
1289
|
+
),
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
// Example inputs
|
|
1293
|
+
const invalidInputs: User[] = [
|
|
1294
|
+
{
|
|
1295
|
+
name: "Al", // Too short
|
|
1296
|
+
email: "bob-no-at-sign.com", // Invalid pattern
|
|
1297
|
+
},
|
|
1298
|
+
{
|
|
1299
|
+
name: "John123", // Contains numbers
|
|
1300
|
+
email: "john@incomplete", // Invalid email
|
|
1301
|
+
},
|
|
1302
|
+
{
|
|
1303
|
+
name: "Alice Smith", // Valid
|
|
1304
|
+
email: "alice@example.com", // Valid
|
|
1305
|
+
},
|
|
1306
|
+
];
|
|
1307
|
+
|
|
1308
|
+
// Validate a single user
|
|
1309
|
+
const validateUser = (input: User) =>
|
|
1310
|
+
Effect.gen(function* () {
|
|
1311
|
+
const result = yield* Schema.decode(UserSchema)(input, { errors: "all" });
|
|
1312
|
+
return result;
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
// Process multiple users and accumulate all errors
|
|
1316
|
+
const program = Effect.gen(function* () {
|
|
1317
|
+
yield* Effect.log("Validating users...\n");
|
|
1318
|
+
|
|
1319
|
+
for (const input of invalidInputs) {
|
|
1320
|
+
const result = yield* Effect.either(validateUser(input));
|
|
1321
|
+
|
|
1322
|
+
yield* Effect.log(`Validating user: ${input.name} <${input.email}>`);
|
|
1323
|
+
|
|
1324
|
+
// Handle success and failure cases separately for clarity
|
|
1325
|
+
// Using Either.match which is the idiomatic way to handle Either values
|
|
1326
|
+
yield* Either.match(result, {
|
|
1327
|
+
onLeft: (error) =>
|
|
1328
|
+
Effect.gen(function* () {
|
|
1329
|
+
yield* Effect.log("❌ Validation failed:");
|
|
1330
|
+
yield* Effect.log(error.message);
|
|
1331
|
+
yield* Effect.log(""); // Empty line for readability
|
|
1332
|
+
}),
|
|
1333
|
+
onRight: (user) =>
|
|
1334
|
+
Effect.gen(function* () {
|
|
1335
|
+
yield* Effect.log(`✅ User is valid: ${JSON.stringify(user)}`);
|
|
1336
|
+
yield* Effect.log(""); // Empty line for readability
|
|
1337
|
+
}),
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
// Run the program
|
|
1343
|
+
Effect.runSync(program);
|
|
1344
|
+
```
|
|
1345
|
+
|
|
1346
|
+
---
|
|
1347
|
+
|
|
1348
|
+
**Anti-Pattern:**
|
|
1349
|
+
|
|
1350
|
+
Using `Effect`'s error channel for validation that requires multiple error messages. The code below will only ever report the first error it finds, because `Effect.fail` short-circuits the entire `Effect.gen` block.
|
|
1351
|
+
|
|
1352
|
+
```typescript
|
|
1353
|
+
import { Effect } from "effect";
|
|
1354
|
+
|
|
1355
|
+
const validateWithEffect = (input: { name: string; email: string }) =>
|
|
1356
|
+
Effect.gen(function* () {
|
|
1357
|
+
if (input.name.length < 3) {
|
|
1358
|
+
// The program will fail here and never check the email.
|
|
1359
|
+
return yield* Effect.fail("Name is too short.");
|
|
1360
|
+
}
|
|
1361
|
+
if (!input.email.includes("@")) {
|
|
1362
|
+
return yield* Effect.fail("Email is invalid.");
|
|
1363
|
+
}
|
|
1364
|
+
return yield* Effect.succeed(input);
|
|
1365
|
+
});
|
|
1366
|
+
```
|
|
1367
|
+
|
|
1368
|
+
**Rationale:**
|
|
1369
|
+
|
|
1370
|
+
When you need to perform multiple validation checks and collect all failures, use the `Either<E, A>` data type. `Either` represents a value that can be one of two possibilities: a `Left<E>` (typically for failure) or a `Right<A>` (typically for success).
|
|
1371
|
+
|
|
1372
|
+
---
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
The `Effect` error channel is designed to short-circuit. The moment an `Effect` fails, the entire computation stops and the error is propagated. This is perfect for handling unrecoverable errors like a lost database connection.
|
|
1376
|
+
|
|
1377
|
+
However, for tasks like validating a user's input, this is poor user experience. You want to show the user all of their mistakes at once.
|
|
1378
|
+
|
|
1379
|
+
`Either` is the solution. Since it's a pure data structure, you can run multiple checks that each return an `Either`, and then combine the results to accumulate all the `Left` (error) values. The `Effect/Schema` module uses this pattern internally to provide powerful error accumulation.
|
|
1380
|
+
|
|
1381
|
+
---
|
|
1382
|
+
|
|
1383
|
+
---
|
|
1384
|
+
|
|
1385
|
+
|