opencode-effect-enforcer 0.2.2 → 0.2.4
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/README.md +38 -10
- package/docs/effect-4.0.0-rc.112.md +316 -0
- package/guidance/effect-first-development.md +30 -17
- package/guidance/progressive-disclosure-guidance.md +13 -0
- package/package.json +3 -2
- package/patterns/avoid-direct-tag-checks.md +8 -2
- package/patterns/avoid-react-hooks.md +18 -37
- package/patterns/effect-run-in-body.md +1 -1
- package/patterns/require-effect-concurrency.md +11 -0
- package/patterns/use-console-service.md +6 -1
- package/skills/effect-ai-language-model/SKILL.md +10 -16
- package/skills/effect-ai-prompt/SKILL.md +36 -2
- package/skills/effect-ai-provider/SKILL.md +13 -0
- package/skills/effect-ai-streaming/SKILL.md +81 -108
- package/skills/effect-ai-tool/SKILL.md +50 -87
- package/skills/effect-atom-rpc/SKILL.md +9 -2
- package/skills/effect-atom-state/SKILL.md +5 -0
- package/skills/effect-cache/SKILL.md +32 -0
- package/skills/effect-cli/SKILL.md +22 -3
- package/skills/effect-concurrency-testing/SKILL.md +7 -9
- package/skills/effect-domain-modeling/SKILL.md +208 -1169
- package/skills/effect-domain-predicates/SKILL.md +5 -6
- package/skills/effect-error-handling/SKILL.md +5 -4
- package/skills/effect-http-api/SKILL.md +12 -1
- package/skills/effect-http-client/SKILL.md +1 -1
- package/skills/effect-http-server/SKILL.md +14 -3
- package/skills/effect-layer-design/SKILL.md +22 -56
- package/skills/effect-mcp-server/SKILL.md +1 -1
- package/skills/effect-pattern-matching/SKILL.md +44 -11
- package/skills/effect-platform-abstraction/SKILL.md +1 -1
- package/skills/effect-platform-layers/SKILL.md +1 -1
- package/skills/effect-rpc-api/SKILL.md +8 -1
- package/skills/effect-rpc-client/SKILL.md +20 -6
- package/skills/effect-rpc-cluster/SKILL.md +44 -14
- package/skills/effect-rpc-server/SKILL.md +32 -5
- package/skills/effect-scheduling/SKILL.md +1 -1
- package/skills/effect-schema-composition/SKILL.md +69 -15
- package/skills/effect-schema-v4/SKILL.md +43 -1
- package/skills/effect-scope/SKILL.md +30 -0
- package/skills/effect-service-implementation/SKILL.md +10 -4
- package/skills/effect-socket/SKILL.md +5 -5
- package/skills/effect-sql/SKILL.md +22 -0
- package/skills/effect-stream/SKILL.md +32 -1
- package/skills/effect-testing/SKILL.md +39 -31
- package/skills/effect-workflow/SKILL.md +6 -0
- package/patterns/vm-in-wrong-file.md +0 -51
- package/skills/effect-react-vm/SKILL.md +0 -675
|
@@ -1,1212 +1,251 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: effect-domain-modeling
|
|
3
|
-
description:
|
|
3
|
+
description: Build schema-first Effect domain models with Schema.Class variants, tagged unions, branded values, legal state transitions, predicates, equivalence, and orders. Use when modeling domain entities, value objects, or discriminated unions.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Effect Domain Modeling
|
|
6
|
+
# Effect Domain Modeling
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Model the data representation the domain needs, parse into it at the boundary,
|
|
9
|
+
and preserve those guarantees in every transition. Prefer `Schema.Class` for
|
|
10
|
+
decoded shapes and union members. Load `effect-schema-v4` and
|
|
11
|
+
`effect-schema-composition` alongside this skill; use `effect-domain-predicates`
|
|
12
|
+
and `effect-typeclass-design` for reusable predicate/order APIs.
|
|
9
13
|
|
|
10
|
-
##
|
|
14
|
+
## Source Reference
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
Baseline: **Effect 4.0.0-rc.112**. In the Effect source reference, consult
|
|
17
|
+
`packages/effect/SCHEMA.md` and `packages/effect/src/{Schema,Match,DateTime,Order}.ts`.
|
|
18
|
+
Verify the installed version and source tag before applying newer APIs.
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
## Constructors Are Not Decoders
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
| Input / purpose | API | Failure |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| Already typed constructor fields | `new Pending(fields)` / `Pending.make(fields)` | synchronous validation may throw |
|
|
25
|
+
| Effectful constructor validation/defaults | `Pending.makeEffect(fields)` | `SchemaIssue.Issue` |
|
|
26
|
+
| Unknown boundary data | `Schema.decodeUnknownEffect(Pending)(input)` | `Schema.SchemaError` |
|
|
27
|
+
| Typed encoded data | `Schema.decodeEffect(Pending)(encoded)` | `Schema.SchemaError` |
|
|
28
|
+
| Explicit synchronous boundary | `Schema.decodeUnknownSync(Pending)(input)` | throws `Schema.SchemaError` |
|
|
21
29
|
|
|
22
|
-
|
|
30
|
+
`Schema.tag` supplies a **constructor** default. A decoder does not synthesize
|
|
31
|
+
missing wire tags merely because the schema is tagged. `decodeSync` takes the
|
|
32
|
+
encoded type; it is not a replacement for `.make` or `new`. Add decoding defaults
|
|
33
|
+
only when the wire contract explicitly allows omission. Do not copy constructor
|
|
34
|
+
fields to a decoder and assume they have the same shape.
|
|
23
35
|
|
|
24
|
-
|
|
36
|
+
`Schema.Schema<T>` describes the decoded view. Use `Schema.Codec<T, E, RD, RE>`
|
|
37
|
+
when encoded values or service requirements matter; do not erase them with `any`.
|
|
25
38
|
|
|
26
|
-
|
|
27
|
-
2. **Schema.decodeSync** - Type-safe constructors with validation
|
|
39
|
+
## Complete Model: Task Lifecycle
|
|
28
40
|
|
|
29
|
-
|
|
41
|
+
This module demonstrates brands, class variants, exhaustive/partial matching,
|
|
42
|
+
schema guards, equivalence, orders, dual helpers, and legal transitions.
|
|
30
43
|
|
|
44
|
+
<!-- typecheck -->
|
|
31
45
|
```typescript
|
|
32
|
-
import {
|
|
46
|
+
import { Effect } from 'effect';
|
|
47
|
+
import * as Arr from 'effect/Array';
|
|
48
|
+
import * as DateTime from 'effect/DateTime';
|
|
49
|
+
import { dual } from 'effect/Function';
|
|
50
|
+
import * as Order from 'effect/Order';
|
|
51
|
+
import * as Schema from 'effect/Schema';
|
|
52
|
+
|
|
53
|
+
/** Stable task identity, distinct from unrelated string identifiers. */
|
|
54
|
+
export const TaskId = Schema.NonEmptyString.pipe(Schema.brand('TaskId'));
|
|
55
|
+
export type TaskId = typeof TaskId.Type;
|
|
33
56
|
|
|
34
|
-
|
|
35
|
-
export
|
|
36
|
-
|
|
57
|
+
/** A task that can be started. */
|
|
58
|
+
export class Pending extends Schema.Class<Pending>('Pending')({
|
|
59
|
+
kind: Schema.tag('pending'),
|
|
60
|
+
id: TaskId,
|
|
61
|
+
title: Schema.NonEmptyString,
|
|
37
62
|
createdAt: Schema.DateTimeUtc
|
|
38
|
-
})
|
|
63
|
+
}) {}
|
|
39
64
|
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
/** A task with a recorded start time that can be completed. */
|
|
66
|
+
export class Active extends Schema.Class<Active>('Active')({
|
|
67
|
+
kind: Schema.tag('active'),
|
|
68
|
+
id: TaskId,
|
|
69
|
+
title: Schema.NonEmptyString,
|
|
42
70
|
createdAt: Schema.DateTimeUtc,
|
|
43
71
|
startedAt: Schema.DateTimeUtc
|
|
44
|
-
})
|
|
72
|
+
}) {}
|
|
45
73
|
|
|
46
|
-
|
|
47
|
-
|
|
74
|
+
/** A terminal task retaining its start and completion times. */
|
|
75
|
+
export class Completed extends Schema.Class<Completed>('Completed')({
|
|
76
|
+
kind: Schema.tag('completed'),
|
|
77
|
+
id: TaskId,
|
|
78
|
+
title: Schema.NonEmptyString,
|
|
48
79
|
createdAt: Schema.DateTimeUtc,
|
|
80
|
+
startedAt: Schema.DateTimeUtc,
|
|
49
81
|
completedAt: Schema.DateTimeUtc
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
// Union type
|
|
53
|
-
export const Task = Schema.Union([Pending, Active, Completed]);
|
|
54
|
-
|
|
55
|
-
export type Task = Schema.Schema.Type<typeof Task>;
|
|
56
|
-
|
|
57
|
-
// Export member types for refinements
|
|
58
|
-
export type Pending = Schema.Schema.Type<typeof Pending>;
|
|
59
|
-
export type Active = Schema.Schema.Type<typeof Active>;
|
|
60
|
-
export type Completed = Schema.Schema.Type<typeof Completed>;
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
Use `Schema.annotate(...)` selectively for public or widely reused schemas when the metadata is actually consumed. Local or still-evolving domain schemas can stay unannotated.
|
|
64
|
-
|
|
65
|
-
## Why This Pattern?
|
|
66
|
-
|
|
67
|
-
**Schema.TaggedStruct Benefits:**
|
|
68
|
-
|
|
69
|
-
- Automatically adds `_tag` discriminator (no manual `Schema.Literal`)
|
|
70
|
-
- The `_tag` is applied automatically in constructors
|
|
71
|
-
- Cleaner than `Schema.Struct` with manual tag fields
|
|
72
|
-
- Enables exhaustive pattern matching
|
|
73
|
-
|
|
74
|
-
**Deep Structural Equality (v4):**
|
|
75
|
-
|
|
76
|
-
- `Equal.equals` performs deep structural comparison by default in v4
|
|
77
|
-
- No wrapping or special setup needed — just compare any two values
|
|
78
|
-
- Works correctly with nested structures
|
|
79
|
-
|
|
80
|
-
**When Schema.annotate Helps:**
|
|
81
|
-
|
|
82
|
-
- Public or widely reused schemas that benefit from extra identifier, title, or description metadata
|
|
83
|
-
- Better error messages in validation failures when the metadata is actually surfaced
|
|
84
|
-
- Schema introspection or generated docs that read annotations
|
|
85
|
-
- Local or provisional schemas do not need annotations by default
|
|
86
|
-
|
|
87
|
-
## Mandatory Module Exports
|
|
88
|
-
|
|
89
|
-
Every domain model module MUST include:
|
|
90
|
-
|
|
91
|
-
### 1. Type Definition with Schemas
|
|
92
|
-
|
|
93
|
-
```typescript
|
|
94
|
-
import { Schema } from 'effect';
|
|
95
|
-
|
|
96
|
-
// Export both schema and type for each variant
|
|
97
|
-
export const Admin = Schema.TaggedStruct('Admin', {
|
|
98
|
-
id: Schema.String,
|
|
99
|
-
name: Schema.String,
|
|
100
|
-
permissions: Schema.Array(Schema.String)
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
export type Admin = Schema.Schema.Type<typeof Admin>;
|
|
104
|
-
|
|
105
|
-
export const Customer = Schema.TaggedStruct('Customer', {
|
|
106
|
-
id: Schema.String,
|
|
107
|
-
name: Schema.String,
|
|
108
|
-
tier: Schema.Literals(['free', 'premium'])
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
export type Customer = Schema.Schema.Type<typeof Customer>;
|
|
112
|
-
|
|
113
|
-
// Union schema
|
|
114
|
-
export const User = Schema.Union([Admin, Customer]);
|
|
115
|
-
|
|
116
|
-
export type User = Schema.Schema.Type<typeof User>;
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
### 2. Constructors Using Schema.decodeSync
|
|
120
|
-
|
|
121
|
-
```typescript
|
|
122
|
-
import { Schema } from 'effect';
|
|
123
|
-
import * as DateTime from 'effect/DateTime';
|
|
124
|
-
|
|
125
|
-
// Assume we have these schemas from previous section
|
|
126
|
-
declare const Pending: Schema.Schema<any, any, never>;
|
|
127
|
-
declare const Active: Schema.Schema<any, any, never>;
|
|
128
|
-
declare const Completed: Schema.Schema<any, any, never>;
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Create a pending task.
|
|
132
|
-
*
|
|
133
|
-
* Note: _tag is automatically applied by TaggedStruct.
|
|
134
|
-
*
|
|
135
|
-
* @category Constructors
|
|
136
|
-
* @since 0.1.0
|
|
137
|
-
* @example
|
|
138
|
-
* import * as Task from "@/schemas/Task"
|
|
139
|
-
* import * as DateTime from "effect/DateTime"
|
|
140
|
-
*
|
|
141
|
-
* const task = Task.makePending({
|
|
142
|
-
* id: "task-123",
|
|
143
|
-
* createdAt: DateTime.unsafeNow()
|
|
144
|
-
* })
|
|
145
|
-
* // Result: { _tag: "pending", id: "task-123", createdAt: ... }
|
|
146
|
-
*
|
|
147
|
-
* // Deep structural equality (automatic in v4):
|
|
148
|
-
* const another = Task.makePending({
|
|
149
|
-
* id: "task-123",
|
|
150
|
-
* createdAt: DateTime.unsafeNow()
|
|
151
|
-
* })
|
|
152
|
-
* Equal.equals(task, another) // true if all fields match
|
|
153
|
-
*/
|
|
154
|
-
export const makePending = Schema.decodeSync(Pending);
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Create an active task.
|
|
158
|
-
*
|
|
159
|
-
* @category Constructors
|
|
160
|
-
* @since 0.1.0
|
|
161
|
-
*/
|
|
162
|
-
export const makeActive = Schema.decodeSync(Active);
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Create a completed task.
|
|
166
|
-
*
|
|
167
|
-
* @category Constructors
|
|
168
|
-
* @since 0.1.0
|
|
169
|
-
*/
|
|
170
|
-
export const makeCompleted = Schema.decodeSync(Completed);
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
**Why decodeSync?**
|
|
174
|
-
|
|
175
|
-
- `decodeSync` creates a validated constructor
|
|
176
|
-
- Automatically applies the `_tag` discriminator
|
|
177
|
-
- Throws on invalid input (use `decodeUnknownSync` for unknown data)
|
|
178
|
-
|
|
179
|
-
### 3. Guards and Type Predicates
|
|
180
|
-
|
|
181
|
-
```typescript
|
|
182
|
-
import { Schema } from 'effect';
|
|
183
|
-
|
|
184
|
-
// Assume Task type from previous section
|
|
185
|
-
declare type Task =
|
|
186
|
-
| { readonly _tag: 'pending'; readonly id: string; readonly createdAt: any }
|
|
187
|
-
| {
|
|
188
|
-
readonly _tag: 'active';
|
|
189
|
-
readonly id: string;
|
|
190
|
-
readonly createdAt: any;
|
|
191
|
-
readonly startedAt: any;
|
|
192
|
-
}
|
|
193
|
-
| {
|
|
194
|
-
readonly _tag: 'completed';
|
|
195
|
-
readonly id: string;
|
|
196
|
-
readonly createdAt: any;
|
|
197
|
-
readonly completedAt: any;
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
declare type Pending = Extract<Task, { readonly _tag: 'pending' }>;
|
|
201
|
-
declare type Active = Extract<Task, { readonly _tag: 'active' }>;
|
|
202
|
-
declare type Completed = Extract<Task, { readonly _tag: 'completed' }>;
|
|
203
|
-
|
|
204
|
-
declare const Task: Schema.Schema<Task, any, never>;
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Type guard for Task union.
|
|
208
|
-
*
|
|
209
|
-
* @category Guards
|
|
210
|
-
* @since 0.1.0
|
|
211
|
-
* @example
|
|
212
|
-
* import * as Task from "@/schemas/Task"
|
|
213
|
-
*
|
|
214
|
-
* if (Task.isTask(value)) {
|
|
215
|
-
* // value is Task
|
|
216
|
-
* }
|
|
217
|
-
*/
|
|
218
|
-
export const isTask = Schema.is(Task);
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Refine to Pending variant.
|
|
222
|
-
*
|
|
223
|
-
* Uses `Schema.is` with a `Schema.Literal` guard per EF-35 — prefer
|
|
224
|
-
* schema-backed guards over manual `_tag` checks.
|
|
225
|
-
*
|
|
226
|
-
* @category Guards
|
|
227
|
-
* @since 0.1.0
|
|
228
|
-
* @example
|
|
229
|
-
* import * as Task from "@/schemas/Task"
|
|
230
|
-
*
|
|
231
|
-
* if (Task.isPending(task)) {
|
|
232
|
-
* // task is Pending, access startedAt safely
|
|
233
|
-
* }
|
|
234
|
-
*/
|
|
235
|
-
export const isPending: (self: Task) => self is Pending = Schema.is(Pending);
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Refine to Active variant.
|
|
239
|
-
*
|
|
240
|
-
* @category Guards
|
|
241
|
-
* @since 0.1.0
|
|
242
|
-
*/
|
|
243
|
-
export const isActive: (self: Task) => self is Active = Schema.is(Active);
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Refine to Completed variant.
|
|
247
|
-
*
|
|
248
|
-
* @category Guards
|
|
249
|
-
* @since 0.1.0
|
|
250
|
-
*/
|
|
251
|
-
export const isCompleted: (self: Task) => self is Completed =
|
|
252
|
-
Schema.is(Completed);
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
### 4. Match Function (Pattern Matching)
|
|
256
|
-
|
|
257
|
-
```typescript
|
|
258
|
-
import * as Match from 'effect/Match';
|
|
259
|
-
|
|
260
|
-
// Assume Task type from previous section
|
|
261
|
-
declare type Task =
|
|
262
|
-
| { readonly _tag: 'pending'; readonly id: string; readonly createdAt: any }
|
|
263
|
-
| {
|
|
264
|
-
readonly _tag: 'active';
|
|
265
|
-
readonly id: string;
|
|
266
|
-
readonly createdAt: any;
|
|
267
|
-
readonly startedAt: any;
|
|
268
|
-
}
|
|
269
|
-
| {
|
|
270
|
-
readonly _tag: 'completed';
|
|
271
|
-
readonly id: string;
|
|
272
|
-
readonly createdAt: any;
|
|
273
|
-
readonly completedAt: any;
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Pattern match on Task using Match.typeTags.
|
|
278
|
-
*
|
|
279
|
-
* @category Pattern Matching
|
|
280
|
-
* @since 0.1.0
|
|
281
|
-
* @example
|
|
282
|
-
* import * as Task from "@/schemas/Task"
|
|
283
|
-
*
|
|
284
|
-
* const status = Task.match({
|
|
285
|
-
* pending: (t) => `Pending: ${t.id}`,
|
|
286
|
-
* active: (t) => `Active since ${t.startedAt}`,
|
|
287
|
-
* completed: (t) => `Completed at ${t.completedAt}`
|
|
288
|
-
* })
|
|
289
|
-
*
|
|
290
|
-
* const result = status(task)
|
|
291
|
-
*/
|
|
292
|
-
export const match = Match.typeTags<Task>();
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
**Match.typeTags Usage:**
|
|
82
|
+
}) {}
|
|
296
83
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
### 5. Equivalence
|
|
302
|
-
|
|
303
|
-
```typescript
|
|
304
|
-
import { Schema } from 'effect';
|
|
305
|
-
import * as Equal from 'effect/Equal';
|
|
306
|
-
import * as Equivalence from 'effect/Equivalence';
|
|
307
|
-
|
|
308
|
-
// Assume Task type and schema from previous section
|
|
309
|
-
declare type Task =
|
|
310
|
-
| { readonly _tag: 'pending'; readonly id: string; readonly createdAt: any }
|
|
311
|
-
| {
|
|
312
|
-
readonly _tag: 'active';
|
|
313
|
-
readonly id: string;
|
|
314
|
-
readonly createdAt: any;
|
|
315
|
-
readonly startedAt: any;
|
|
316
|
-
}
|
|
317
|
-
| {
|
|
318
|
-
readonly _tag: 'completed';
|
|
319
|
-
readonly id: string;
|
|
320
|
-
readonly createdAt: any;
|
|
321
|
-
readonly completedAt: any;
|
|
322
|
-
};
|
|
323
|
-
|
|
324
|
-
declare const Task: Schema.Schema<Task, any, never>;
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Primary approach: Use Equal.equals() — deep structural comparison is automatic in v4.
|
|
328
|
-
*
|
|
329
|
-
* @example
|
|
330
|
-
* import * as Equal from "effect/Equal"
|
|
331
|
-
*
|
|
332
|
-
* const task1 = Task.makePending({ ... })
|
|
333
|
-
* const task2 = Task.makePending({ ... })
|
|
334
|
-
*
|
|
335
|
-
* // Deep structural equality (automatic in v4)
|
|
336
|
-
* if (Equal.equals(task1, task2)) {
|
|
337
|
-
* // Tasks are structurally equal
|
|
338
|
-
* }
|
|
339
|
-
*/
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Field-based equivalence using Equivalence.mapInput
|
|
343
|
-
*
|
|
344
|
-
* Compare by specific fields when structural equality isn't appropriate.
|
|
345
|
-
*
|
|
346
|
-
* @category Equivalence
|
|
347
|
-
* @since 0.1.0
|
|
348
|
-
* @example
|
|
349
|
-
* import * as Task from "@/schemas/Task"
|
|
350
|
-
*
|
|
351
|
-
* // Compare by ID only
|
|
352
|
-
* const areTasksSame = Task.EquivalenceById(task1, task2)
|
|
353
|
-
*/
|
|
354
|
-
export const EquivalenceById = Equivalence.mapInput(
|
|
355
|
-
Equivalence.String,
|
|
356
|
-
(task: Task) => task.id
|
|
84
|
+
/** Every supported lifecycle variant, discriminated by kind. */
|
|
85
|
+
export const Task = Schema.Union([Pending, Active, Completed]).pipe(
|
|
86
|
+
Schema.toTaggedUnion('kind')
|
|
357
87
|
);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
**When to Export Custom Equivalence:**
|
|
361
|
-
|
|
362
|
-
- You need multiple comparison strategies (by ID, by group, etc.)
|
|
363
|
-
- Field-based equality is semantically meaningful
|
|
364
|
-
- Business logic requires custom equality checks
|
|
365
|
-
|
|
366
|
-
**When NOT to Export Custom Equivalence:**
|
|
367
|
-
|
|
368
|
-
- You only need structural equality (use `Equal.equals()` directly)
|
|
369
|
-
- No custom comparison logic is needed
|
|
370
|
-
|
|
371
|
-
## Conditional Module Exports
|
|
372
|
-
|
|
373
|
-
Include these when semantically appropriate:
|
|
374
|
-
|
|
375
|
-
### Identity Values
|
|
376
|
-
|
|
377
|
-
When the type has a natural "zero" or "empty" value:
|
|
378
|
-
|
|
379
|
-
```typescript
|
|
380
|
-
// Assume Cents and List types exist
|
|
381
|
-
declare type Cents = bigint;
|
|
382
|
-
declare function make(value: bigint): Cents;
|
|
383
|
-
declare type List<T> = ReadonlyArray<T>;
|
|
384
|
-
declare function makeEmpty<T>(): List<T>;
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Zero value for monetary amounts.
|
|
388
|
-
*
|
|
389
|
-
* @category Identity
|
|
390
|
-
* @since 0.1.0
|
|
391
|
-
*/
|
|
392
|
-
export const zero: Cents = make(0n);
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Empty list.
|
|
396
|
-
*
|
|
397
|
-
* @category Identity
|
|
398
|
-
* @since 0.1.0
|
|
399
|
-
*/
|
|
400
|
-
export const empty: List<never> = makeEmpty();
|
|
401
|
-
```
|
|
402
|
-
|
|
403
|
-
### Combinators
|
|
404
|
-
|
|
405
|
-
Functions that combine or transform values:
|
|
406
|
-
|
|
407
|
-
```typescript
|
|
408
|
-
import { dual } from 'effect/Function';
|
|
409
|
-
|
|
410
|
-
// Assume Cents type exists
|
|
411
|
-
declare type Cents = bigint;
|
|
412
|
-
declare function make(value: bigint): Cents;
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Add two monetary values.
|
|
416
|
-
*
|
|
417
|
-
* @category Combinators
|
|
418
|
-
* @since 0.1.0
|
|
419
|
-
* @example
|
|
420
|
-
* import * as Cents from "@/schemas/Cents"
|
|
421
|
-
* import { pipe } from "effect/Function"
|
|
422
|
-
*
|
|
423
|
-
* const total = pipe(price, Cents.add(tax))
|
|
424
|
-
*/
|
|
425
|
-
export const add: {
|
|
426
|
-
(that: Cents): (self: Cents) => Cents;
|
|
427
|
-
(self: Cents, that: Cents): Cents;
|
|
428
|
-
} = dual(2, (self: Cents, that: Cents): Cents => make(self + that));
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Get minimum of two values.
|
|
432
|
-
*
|
|
433
|
-
* @category Combinators
|
|
434
|
-
* @since 0.1.0
|
|
435
|
-
*/
|
|
436
|
-
export const min = (a: Cents, b: Cents): Cents => (a < b ? a : b);
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* Get maximum of two values.
|
|
440
|
-
*
|
|
441
|
-
* @category Combinators
|
|
442
|
-
* @since 0.1.0
|
|
443
|
-
*/
|
|
444
|
-
export const max = (a: Cents, b: Cents): Cents => (a > b ? a : b);
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
### Order Instances
|
|
88
|
+
export type Task = typeof Task.Type;
|
|
448
89
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
readonly id: string;
|
|
471
|
-
readonly createdAt: DateTime.DateTime.Utc;
|
|
472
|
-
readonly completedAt: DateTime.DateTime.Utc;
|
|
473
|
-
};
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* Order by tag (pending < active < completed).
|
|
477
|
-
*
|
|
478
|
-
* Uses Order.mapInput to compose from Order.Number.
|
|
479
|
-
*
|
|
480
|
-
* @category Orders
|
|
481
|
-
* @since 0.1.0
|
|
482
|
-
* @example
|
|
483
|
-
* import * as Task from "@/schemas/Task"
|
|
484
|
-
* import * as Array from "effect/Array"
|
|
485
|
-
* import { pipe } from "effect/Function"
|
|
486
|
-
*
|
|
487
|
-
* const sorted = pipe(tasks, Array.sort(Task.OrderByTag))
|
|
488
|
-
*/
|
|
489
|
-
export const OrderByTag: Order.Order<Task> = Order.mapInput(
|
|
490
|
-
Order.Number,
|
|
491
|
-
(task) => {
|
|
492
|
-
const priorities = { pending: 0, active: 1, completed: 2 };
|
|
493
|
-
return priorities[task._tag];
|
|
494
|
-
}
|
|
90
|
+
/** Check the decoded model, not an unparsed JSON representation. */
|
|
91
|
+
export const isTask = Schema.is(Task);
|
|
92
|
+
/** Narrow a decoded task to the active variant. */
|
|
93
|
+
export const isActive = Task.guards.active;
|
|
94
|
+
/** Exhaustive matching with both data-first and data-last forms. */
|
|
95
|
+
export const match = Task.match;
|
|
96
|
+
/** Full domain-value equivalence, derived from the schema. */
|
|
97
|
+
export const equivalence = Schema.toEquivalence(Task);
|
|
98
|
+
const idEquivalence = Schema.toEquivalence(TaskId);
|
|
99
|
+
/** Identity comparison when full value equivalence is not intended. */
|
|
100
|
+
export const sameId = (left: Task, right: Task) => idEquivalence(left.id, right.id);
|
|
101
|
+
|
|
102
|
+
const phaseRank = Task.match({
|
|
103
|
+
pending: () => 0,
|
|
104
|
+
active: () => 1,
|
|
105
|
+
completed: () => 2
|
|
106
|
+
});
|
|
107
|
+
/** Lifecycle order, then creation time. */
|
|
108
|
+
export const order = Order.combine(
|
|
109
|
+
Order.mapInput(Order.Number, phaseRank),
|
|
110
|
+
Order.mapInput(DateTime.Order, (task: Task) => task.createdAt)
|
|
495
111
|
);
|
|
496
112
|
|
|
497
|
-
/**
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
113
|
+
/** Start only a pending task, retaining identity and creation time. */
|
|
114
|
+
export const start: {
|
|
115
|
+
(at: DateTime.Utc): (self: Pending) => Active;
|
|
116
|
+
(self: Pending, at: DateTime.Utc): Active;
|
|
117
|
+
} = dual(2, (self: Pending, at: DateTime.Utc) => new Active({
|
|
118
|
+
id: self.id, title: self.title, createdAt: self.createdAt, startedAt: at
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
/** Complete only an active task; do not mutate a discriminator in place. */
|
|
122
|
+
export const complete: {
|
|
123
|
+
(at: DateTime.Utc): (self: Active) => Completed;
|
|
124
|
+
(self: Active, at: DateTime.Utc): Completed;
|
|
125
|
+
} = dual(2, (self: Active, at: DateTime.Utc) => new Completed({
|
|
126
|
+
id: self.id, title: self.title, createdAt: self.createdAt,
|
|
127
|
+
startedAt: self.startedAt, completedAt: at
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
/** Acquire runtime time at the effectful edge of a pure transition. */
|
|
131
|
+
export const startNow = Effect.fn('Task.startNow')(function* (self: Pending) {
|
|
132
|
+
return start(self, yield* DateTime.now);
|
|
133
|
+
});
|
|
507
134
|
|
|
508
|
-
/**
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
* @category Orders
|
|
512
|
-
* @since 0.1.0
|
|
513
|
-
*/
|
|
514
|
-
export const OrderByCreatedAt: Order.Order<Task> = Order.mapInput(
|
|
515
|
-
DateTime.Order,
|
|
516
|
-
(task) => task.createdAt
|
|
135
|
+
/** JSON boundary, including JSON codecs for DateTime fields. */
|
|
136
|
+
export const decodeTaskJson = Schema.decodeUnknownEffect(
|
|
137
|
+
Schema.fromJsonString(Schema.toCodecJson(Task))
|
|
517
138
|
);
|
|
518
139
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
* Sorts by tag first, then by creation date.
|
|
523
|
-
*
|
|
524
|
-
* @category Orders
|
|
525
|
-
* @since 0.1.0
|
|
526
|
-
* @example
|
|
527
|
-
* import * as Task from "@/schemas/Task"
|
|
528
|
-
* import * as Array from "effect/Array"
|
|
529
|
-
*
|
|
530
|
-
* const sorted = Array.sort(tasks, Task.OrderByTagThenDate)
|
|
531
|
-
*/
|
|
532
|
-
export const OrderByTagThenDate: Order.Order<Task> = Order.combine(
|
|
533
|
-
OrderByTag,
|
|
534
|
-
OrderByCreatedAt
|
|
140
|
+
const summarize = Task.matchOrElse(
|
|
141
|
+
{ completed: (task) => `Completed: ${task.title}` },
|
|
142
|
+
(task) => `Waiting on ${task.kind}` // Pending | Active for toTaggedUnion
|
|
535
143
|
);
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
*
|
|
579
|
-
* @category Destructors
|
|
580
|
-
* @since 0.1.0
|
|
581
|
-
* @example
|
|
582
|
-
* import * as Task from "@/schemas/Task"
|
|
583
|
-
*
|
|
584
|
-
* const id = Task.getId(task) // Works for any variant
|
|
585
|
-
*/
|
|
586
|
-
export const getId = (self: Task): string => self.id;
|
|
587
|
-
|
|
588
|
-
/**
|
|
589
|
-
* Get creation date.
|
|
590
|
-
*
|
|
591
|
-
* @category Destructors
|
|
592
|
-
* @since 0.1.0
|
|
593
|
-
*/
|
|
594
|
-
export const getCreatedAt = (self: Task): DateTime.DateTime.Utc =>
|
|
595
|
-
self.createdAt;
|
|
596
|
-
```
|
|
597
|
-
|
|
598
|
-
### Setters (Immutable Updates)
|
|
599
|
-
|
|
600
|
-
```typescript
|
|
601
|
-
import { dual } from 'effect/Function';
|
|
602
|
-
|
|
603
|
-
// Assume Task type from previous section
|
|
604
|
-
declare type Task =
|
|
605
|
-
| { readonly _tag: 'pending'; readonly id: string; readonly createdAt: any }
|
|
606
|
-
| {
|
|
607
|
-
readonly _tag: 'active';
|
|
608
|
-
readonly id: string;
|
|
609
|
-
readonly createdAt: any;
|
|
610
|
-
readonly startedAt: any;
|
|
611
|
-
}
|
|
612
|
-
| {
|
|
613
|
-
readonly _tag: 'completed';
|
|
614
|
-
readonly id: string;
|
|
615
|
-
readonly createdAt: any;
|
|
616
|
-
readonly completedAt: any;
|
|
617
|
-
};
|
|
618
|
-
|
|
619
|
-
/**
|
|
620
|
-
* Update a field immutably.
|
|
621
|
-
*
|
|
622
|
-
* @category Setters
|
|
623
|
-
* @since 0.1.0
|
|
624
|
-
* @example
|
|
625
|
-
* import * as Task from "@/schemas/Task"
|
|
626
|
-
* import { pipe } from "effect/Function"
|
|
627
|
-
*
|
|
628
|
-
* const updated = pipe(task, Task.setId("new-id"))
|
|
629
|
-
*/
|
|
630
|
-
export const setId: {
|
|
631
|
-
(id: string): (self: Task) => Task;
|
|
632
|
-
(self: Task, id: string): Task;
|
|
633
|
-
} = dual(2, (self: Task, id: string): Task => ({ ...self, id }));
|
|
634
|
-
```
|
|
635
|
-
|
|
636
|
-
## Advanced Patterns
|
|
637
|
-
|
|
638
|
-
### Recursive Schemas with Schema.suspend
|
|
639
|
-
|
|
640
|
-
Use for self-referencing types (trees, graphs, nested structures):
|
|
641
|
-
|
|
642
|
-
```typescript
|
|
643
|
-
import { Schema } from 'effect';
|
|
644
|
-
|
|
645
|
-
/**
|
|
646
|
-
* Recursive domain type: Category with subcategories.
|
|
647
|
-
*/
|
|
648
|
-
|
|
649
|
-
// Separate base fields from recursive field
|
|
650
|
-
const baseFields = {
|
|
651
|
-
id: Schema.String,
|
|
652
|
-
name: Schema.String
|
|
653
|
-
};
|
|
654
|
-
|
|
655
|
-
// Define the recursive type
|
|
656
|
-
interface Category extends Schema.Struct.Type<typeof baseFields> {
|
|
657
|
-
readonly subcategories: ReadonlyArray<Category>;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// Create schema with Schema.suspend for recursion
|
|
661
|
-
export const Category = Schema.Struct({
|
|
662
|
-
...baseFields,
|
|
663
|
-
subcategories: Schema.Array(
|
|
664
|
-
Schema.suspend((): Schema.Codec<Category> => Category)
|
|
144
|
+
const sorted = Arr.sort([], order);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Transitions above encode lifecycle prerequisites, not a timestamp ordering law.
|
|
148
|
+
If the domain requires `completedAt >= startedAt >= createdAt`, express that as
|
|
149
|
+
a reusable schema check or a typed transition failure, with a meaningful decode
|
|
150
|
+
message. Never claim the invariant is enforced solely because fields use
|
|
151
|
+
`DateTime.Utc`.
|
|
152
|
+
|
|
153
|
+
## Tagged Union Choices and Partial Matching
|
|
154
|
+
|
|
155
|
+
- Class variants plus `Schema.Union([...]).pipe(Schema.toTaggedUnion('kind'))`
|
|
156
|
+
support arbitrary discriminator keys and preserve class identity.
|
|
157
|
+
- `Schema.TaggedUnion({ Created: {...}, Deleted: {...} })` builds canonical
|
|
158
|
+
`_tag` object variants with `cases`, `guards`, `isAnyOf`, `match`, and
|
|
159
|
+
`matchOrElse`. Use it when plain internal object variants are intentional.
|
|
160
|
+
- `Data.TaggedEnum` is useful for trusted, non-schema types. It does not decode
|
|
161
|
+
unknown input; do not duplicate a schema model with a parallel Data union.
|
|
162
|
+
- Use `.match` for exhaustiveness. Use rc.112 `.matchOrElse(cases, fallback)` or
|
|
163
|
+
`.matchOrElse(value, cases, fallback)` when one fallback truthfully handles all
|
|
164
|
+
other cases. The fallback from `toTaggedUnion` is narrowed to unmatched
|
|
165
|
+
variants; direct `Schema.TaggedUnion.matchOrElse` types it as the full union.
|
|
166
|
+
- TypeScript does narrow literal discriminator checks. The preference for schema
|
|
167
|
+
matching is about exhaustiveness and reuse, not a compiler limitation.
|
|
168
|
+
|
|
169
|
+
## Guards, Optionality, and Defaults
|
|
170
|
+
|
|
171
|
+
`Schema.is` checks the decoded side and does not perform transformations. Decode
|
|
172
|
+
wire data first. Use `Schema.OptionFromOptionalKey`, `OptionFromNullOr`, or
|
|
173
|
+
`OptionFromNullishOr` to preserve absence as `Option`.
|
|
174
|
+
|
|
175
|
+
<!-- typecheck -->
|
|
176
|
+
```typescript
|
|
177
|
+
import { Effect } from 'effect';
|
|
178
|
+
import * as Schema from 'effect/Schema';
|
|
179
|
+
|
|
180
|
+
class Profile extends Schema.Class<Profile>('Profile')({
|
|
181
|
+
name: Schema.NonEmptyString,
|
|
182
|
+
bio: Schema.OptionFromOptionalKey(Schema.String),
|
|
183
|
+
enabled: Schema.Boolean.pipe(
|
|
184
|
+
Schema.withDecodingDefault(Effect.succeed(true)),
|
|
185
|
+
Schema.withConstructorDefault(Effect.succeed(true))
|
|
665
186
|
)
|
|
666
|
-
})
|
|
667
|
-
Schema.annotate({
|
|
668
|
-
identifier: 'Category',
|
|
669
|
-
title: 'Category',
|
|
670
|
-
description: 'A category that can contain nested subcategories'
|
|
671
|
-
})
|
|
672
|
-
);
|
|
673
|
-
|
|
674
|
-
export type Category = Schema.Schema.Type<typeof Category>;
|
|
675
|
-
export const make = Schema.decodeSync(Category);
|
|
187
|
+
}) {}
|
|
676
188
|
|
|
677
|
-
|
|
678
|
-
* Example usage:
|
|
679
|
-
*
|
|
680
|
-
* const root = Category.make({
|
|
681
|
-
* id: "1",
|
|
682
|
-
* name: "Electronics",
|
|
683
|
-
* subcategories: [
|
|
684
|
-
* Category.make({ id: "2", name: "Phones", subcategories: [] }),
|
|
685
|
-
* Category.make({ id: "3", name: "Laptops", subcategories: [] })
|
|
686
|
-
* ]
|
|
687
|
-
* })
|
|
688
|
-
*/
|
|
189
|
+
const decodeProfile = Schema.decodeUnknownEffect(Profile);
|
|
689
190
|
```
|
|
690
191
|
|
|
691
|
-
|
|
192
|
+
Prefer built-in checks and brands over a separate validator returning `void`.
|
|
193
|
+
Reusable checks carry `identifier`, `title`, and `description`; annotate the
|
|
194
|
+
schema itself when it improves public documentation or errors. Do not add a
|
|
195
|
+
`Schema` suffix to schema values; export matching type aliases for non-classes.
|
|
692
196
|
|
|
693
|
-
|
|
694
|
-
- Separate base fields for clarity
|
|
695
|
-
- Define interface first, then schema with `Schema.suspend`
|
|
197
|
+
## Recursive Models
|
|
696
198
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
For types that need additional runtime guarantees:
|
|
199
|
+
Give the suspended schema an explicit codec return type to break inference
|
|
200
|
+
recursion. Keep decoded and encoded recursion distinct for transforming fields.
|
|
700
201
|
|
|
202
|
+
<!-- typecheck -->
|
|
701
203
|
```typescript
|
|
702
|
-
import * as
|
|
703
|
-
import { Schema } from 'effect';
|
|
704
|
-
|
|
705
|
-
/**
|
|
706
|
-
* Email branded type with validation.
|
|
707
|
-
*
|
|
708
|
-
* In v4, `Brand.refined` and `Brand.error` were removed.
|
|
709
|
-
* Use `Brand.make` instead — it takes a filter function that returns
|
|
710
|
-
* `undefined | boolean | string | Issue` (string = error message).
|
|
711
|
-
*/
|
|
712
|
-
export type Email = Brand.Branded<string, 'Email'>;
|
|
713
|
-
|
|
714
|
-
export const Email = Brand.make<Email>(
|
|
715
|
-
(s) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) || `"${s}" is not a valid email`
|
|
716
|
-
);
|
|
204
|
+
import * as Schema from 'effect/Schema';
|
|
717
205
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
* In v4, `Schema.fromBrand` takes two arguments: an identifier string
|
|
722
|
-
* and the Brand.Constructor.
|
|
723
|
-
*/
|
|
724
|
-
export const EmailSchema = Schema.String.pipe(Schema.fromBrand('Email', Email));
|
|
725
|
-
|
|
726
|
-
/**
|
|
727
|
-
* @example
|
|
728
|
-
* const email = Email("user@example.com")
|
|
729
|
-
* const decoded = Schema.decodeSync(EmailSchema)("user@example.com")
|
|
730
|
-
*/
|
|
731
|
-
```
|
|
732
|
-
|
|
733
|
-
### Typeclass Instances
|
|
734
|
-
|
|
735
|
-
**Only implement typeclasses that are semantically appropriate.**
|
|
736
|
-
|
|
737
|
-
Check the project's `@/typeclass/` directory for available typeclasses:
|
|
738
|
-
|
|
739
|
-
```typescript
|
|
740
|
-
// Assume Schedulable typeclass exists
|
|
741
|
-
declare namespace Schedulable$ {
|
|
742
|
-
function make<A>(
|
|
743
|
-
get: (self: A) => any,
|
|
744
|
-
set: (self: A, date: any) => A
|
|
745
|
-
): any;
|
|
746
|
-
function isScheduledBefore(instance: any): (a: any, b: any) => boolean;
|
|
747
|
-
function OrderByScheduledDate(instance: any): any;
|
|
206
|
+
interface CategoryEncoded {
|
|
207
|
+
readonly name: string;
|
|
208
|
+
readonly children: ReadonlyArray<CategoryEncoded>;
|
|
748
209
|
}
|
|
749
210
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
-
|
|
788
|
-
-
|
|
789
|
-
-
|
|
790
|
-
-
|
|
791
|
-
|
|
792
|
-
## Import Patterns
|
|
793
|
-
|
|
794
|
-
**CRITICAL**: Always use namespace imports:
|
|
795
|
-
|
|
796
|
-
```typescript
|
|
797
|
-
// CORRECT
|
|
798
|
-
import * as Task from '@/schemas/Task';
|
|
799
|
-
import * as DateTime from 'effect/DateTime';
|
|
800
|
-
import * as Array from 'effect/Array';
|
|
801
|
-
import * as Order from 'effect/Order';
|
|
802
|
-
import * as Equal from 'effect/Equal';
|
|
803
|
-
|
|
804
|
-
declare const tasks: ReadonlyArray<Task.Task>;
|
|
805
|
-
declare const task1: Task.Task;
|
|
806
|
-
declare const task2: Task.Task;
|
|
807
|
-
|
|
808
|
-
const task = Task.makePending({
|
|
809
|
-
id: '123',
|
|
810
|
-
createdAt: DateTime.unsafeNow()
|
|
811
|
-
});
|
|
812
|
-
const isPending = Task.isPending(task);
|
|
813
|
-
const sorted = Array.sort(tasks, Task.OrderByTag);
|
|
814
|
-
const areEqual = Equal.equals(task1, task2);
|
|
815
|
-
```
|
|
816
|
-
|
|
817
|
-
**NEVER** do this:
|
|
818
|
-
|
|
819
|
-
```typescript
|
|
820
|
-
// WRONG - loses context, causes name clashes
|
|
821
|
-
import { makePending, isPending } from '@/schemas/Task';
|
|
822
|
-
```
|
|
823
|
-
|
|
824
|
-
**Namespace Import Benefits:**
|
|
825
|
-
|
|
826
|
-
- Clear context for all functions
|
|
827
|
-
- Prevents name clashes
|
|
828
|
-
- Enables `Task.Pending`, `Task.Active` schema access
|
|
829
|
-
- Natural organization: `Task.makePending`, `Task.isPending`
|
|
830
|
-
|
|
831
|
-
## Temporal Data
|
|
832
|
-
|
|
833
|
-
Always use DateTime and Duration, never Date or number:
|
|
834
|
-
|
|
835
|
-
```typescript
|
|
836
|
-
// CORRECT
|
|
837
|
-
import { Schema } from 'effect';
|
|
838
|
-
import * as DateTime from 'effect/DateTime';
|
|
839
|
-
import * as Duration from 'effect/Duration';
|
|
840
|
-
|
|
841
|
-
export const Task = Schema.TaggedStruct('task', {
|
|
842
|
-
createdAt: Schema.DateTimeUtc, // UTC datetime
|
|
843
|
-
duration: Schema.Duration // Duration type
|
|
844
|
-
});
|
|
845
|
-
|
|
846
|
-
// WRONG
|
|
847
|
-
export const TaskBad = Schema.TaggedStruct('task', {
|
|
848
|
-
createdAt: Schema.Date, // Native Date
|
|
849
|
-
duration: Schema.Number // Number milliseconds
|
|
850
|
-
});
|
|
851
|
-
```
|
|
852
|
-
|
|
853
|
-
## Immutability
|
|
854
|
-
|
|
855
|
-
Use plain object spreads for immutable updates. In v4, `Data.struct` was removed — deep structural equality is the default, so no special wrapping is needed:
|
|
856
|
-
|
|
857
|
-
```typescript
|
|
858
|
-
// Assume Task type from previous section
|
|
859
|
-
declare type Task =
|
|
860
|
-
| { readonly _tag: 'pending'; readonly id: string; readonly createdAt: any }
|
|
861
|
-
| {
|
|
862
|
-
readonly _tag: 'active';
|
|
863
|
-
readonly id: string;
|
|
864
|
-
readonly createdAt: any;
|
|
865
|
-
readonly startedAt: any;
|
|
866
|
-
}
|
|
867
|
-
| {
|
|
868
|
-
readonly _tag: 'completed';
|
|
869
|
-
readonly id: string;
|
|
870
|
-
readonly createdAt: any;
|
|
871
|
-
readonly completedAt: any;
|
|
872
|
-
};
|
|
873
|
-
|
|
874
|
-
/**
|
|
875
|
-
* Immutable update.
|
|
876
|
-
*
|
|
877
|
-
* @category Setters
|
|
878
|
-
* @since 0.1.0
|
|
879
|
-
*/
|
|
880
|
-
export const updateStatus = (self: Task, newTag: Task['_tag']): Task => ({
|
|
881
|
-
...self,
|
|
882
|
-
_tag: newTag
|
|
883
|
-
});
|
|
884
|
-
```
|
|
885
|
-
|
|
886
|
-
## Documentation Standards
|
|
887
|
-
|
|
888
|
-
Every exported member MUST have:
|
|
889
|
-
|
|
890
|
-
- JSDoc with description
|
|
891
|
-
- `@category` tag (Constructors, Guards, Pattern Matching, Orders, etc.)
|
|
892
|
-
- `@since` tag (version number)
|
|
893
|
-
- `@example` with fully working code including all imports
|
|
894
|
-
|
|
895
|
-
```typescript
|
|
896
|
-
import { Schema } from 'effect';
|
|
897
|
-
import * as DateTime from 'effect/DateTime';
|
|
898
|
-
|
|
899
|
-
declare const Pending: Schema.Schema<any, any, never>;
|
|
900
|
-
|
|
901
|
-
/**
|
|
902
|
-
* Create a pending task.
|
|
903
|
-
*
|
|
904
|
-
* Note: _tag is automatically applied by TaggedStruct.
|
|
905
|
-
*
|
|
906
|
-
* @category Constructors
|
|
907
|
-
* @since 0.1.0
|
|
908
|
-
* @example
|
|
909
|
-
* import * as Task from "@/schemas/Task"
|
|
910
|
-
* import * as DateTime from "effect/DateTime"
|
|
911
|
-
*
|
|
912
|
-
* const task = Task.makePending({
|
|
913
|
-
* id: "task-123",
|
|
914
|
-
* createdAt: DateTime.unsafeNow()
|
|
915
|
-
* })
|
|
916
|
-
*/
|
|
917
|
-
export const makePending = Schema.decodeSync(Pending);
|
|
918
|
-
```
|
|
919
|
-
|
|
920
|
-
## Quality Checklist
|
|
921
|
-
|
|
922
|
-
### Mandatory - Every Domain Model
|
|
923
|
-
|
|
924
|
-
- [ ] Type definition using `Schema.TaggedStruct` for each variant
|
|
925
|
-
- [ ] Add schema annotations only where they materially improve docs, errors, or introspection
|
|
926
|
-
- [ ] Constructor functions using `Schema.decodeSync`
|
|
927
|
-
- [ ] Type guard using `Schema.is` for union
|
|
928
|
-
- [ ] Refinement predicates for each variant (e.g., `isPending`)
|
|
929
|
-
- [ ] Match function using `Match.typeTags`
|
|
930
|
-
- [ ] Export all union member schemas and types
|
|
931
|
-
- [ ] All exports use namespace pattern (`import * as`)
|
|
932
|
-
- [ ] Full JSDoc with @category, @since, @example
|
|
933
|
-
- [ ] DateTime/Duration for temporal data (not Date/number)
|
|
934
|
-
- [ ] Plain object spreads for immutability (Data.struct removed in v4)
|
|
935
|
-
- [ ] Examples compile and run
|
|
936
|
-
- [ ] Format and typecheck pass
|
|
937
|
-
|
|
938
|
-
### Conditional - Include When Appropriate
|
|
939
|
-
|
|
940
|
-
- [ ] Identity values (`zero`, `empty`, `unit`)
|
|
941
|
-
- [ ] Combinators (`add`, `min`, `max`, `combine`)
|
|
942
|
-
- [ ] Order instances using `Order.mapInput` for common sorting needs
|
|
943
|
-
- [ ] `Order.combine` for multi-criteria sorting
|
|
944
|
-
- [ ] Custom Equivalence via `Schema.toEquivalence()` or `Equivalence.mapInput`
|
|
945
|
-
- [ ] Destructors (getters for common fields)
|
|
946
|
-
- [ ] Setters (immutable update helpers)
|
|
947
|
-
- [ ] Recursive schemas with `Schema.suspend` (for self-referencing types)
|
|
948
|
-
- [ ] Branded types for validation constraints
|
|
949
|
-
- [ ] Typeclass instances (check `@/typeclass/` directory first)
|
|
950
|
-
- [ ] Derived predicates from typeclasses
|
|
951
|
-
- [ ] Derived orders from typeclasses
|
|
952
|
-
|
|
953
|
-
## Complete Example
|
|
954
|
-
|
|
955
|
-
```typescript
|
|
956
|
-
/**
|
|
957
|
-
* User domain model demonstrating all patterns.
|
|
958
|
-
*
|
|
959
|
-
* @since 0.1.0
|
|
960
|
-
*/
|
|
961
|
-
import { Schema, Equal, Match } from 'effect';
|
|
962
|
-
import * as DateTime from 'effect/DateTime';
|
|
963
|
-
import * as Order from 'effect/Order';
|
|
964
|
-
import * as Equivalence from 'effect/Equivalence';
|
|
965
|
-
import { dual } from 'effect/Function';
|
|
966
|
-
|
|
967
|
-
// =============================================================================
|
|
968
|
-
// Models
|
|
969
|
-
// =============================================================================
|
|
970
|
-
|
|
971
|
-
export const Admin = Schema.TaggedStruct('Admin', {
|
|
972
|
-
id: Schema.String,
|
|
973
|
-
name: Schema.String,
|
|
974
|
-
createdAt: Schema.DateTimeUtc,
|
|
975
|
-
permissions: Schema.Array(Schema.String)
|
|
976
|
-
}).pipe(
|
|
977
|
-
Schema.annotate({
|
|
978
|
-
identifier: 'Admin',
|
|
979
|
-
title: 'Administrator',
|
|
980
|
-
description: 'A user with administrative privileges'
|
|
981
|
-
})
|
|
982
|
-
);
|
|
983
|
-
|
|
984
|
-
export type Admin = Schema.Schema.Type<typeof Admin>;
|
|
985
|
-
|
|
986
|
-
export const Customer = Schema.TaggedStruct('Customer', {
|
|
987
|
-
id: Schema.String,
|
|
988
|
-
name: Schema.String,
|
|
989
|
-
createdAt: Schema.DateTimeUtc,
|
|
990
|
-
tier: Schema.Literals(['free', 'premium'])
|
|
991
|
-
}).pipe(
|
|
992
|
-
Schema.annotate({
|
|
993
|
-
identifier: 'Customer',
|
|
994
|
-
title: 'Customer',
|
|
995
|
-
description: 'A customer user'
|
|
996
|
-
})
|
|
997
|
-
);
|
|
998
|
-
|
|
999
|
-
export type Customer = Schema.Schema.Type<typeof Customer>;
|
|
1000
|
-
|
|
1001
|
-
export const User = Schema.Union([Admin, Customer]).pipe(
|
|
1002
|
-
Schema.annotate({
|
|
1003
|
-
identifier: 'User',
|
|
1004
|
-
title: 'User',
|
|
1005
|
-
description: 'A user can be an admin or a customer'
|
|
1006
|
-
})
|
|
1007
|
-
);
|
|
1008
|
-
|
|
1009
|
-
export type User = Schema.Schema.Type<typeof User>;
|
|
1010
|
-
|
|
1011
|
-
// =============================================================================
|
|
1012
|
-
// Constructors
|
|
1013
|
-
// =============================================================================
|
|
1014
|
-
|
|
1015
|
-
/**
|
|
1016
|
-
* Create an admin user.
|
|
1017
|
-
*
|
|
1018
|
-
* @category Constructors
|
|
1019
|
-
* @since 0.1.0
|
|
1020
|
-
* @example
|
|
1021
|
-
* import * as User from "@/schemas/User"
|
|
1022
|
-
* import * as DateTime from "effect/DateTime"
|
|
1023
|
-
*
|
|
1024
|
-
* const admin = User.makeAdmin({
|
|
1025
|
-
* id: "admin-1",
|
|
1026
|
-
* name: "Alice",
|
|
1027
|
-
* createdAt: DateTime.unsafeNow(),
|
|
1028
|
-
* permissions: ["read", "write"]
|
|
1029
|
-
* })
|
|
1030
|
-
*/
|
|
1031
|
-
export const makeAdmin = Schema.decodeSync(Admin);
|
|
1032
|
-
|
|
1033
|
-
/**
|
|
1034
|
-
* Create a customer user.
|
|
1035
|
-
*
|
|
1036
|
-
* @category Constructors
|
|
1037
|
-
* @since 0.1.0
|
|
1038
|
-
*/
|
|
1039
|
-
export const makeCustomer = Schema.decodeSync(Customer);
|
|
1040
|
-
|
|
1041
|
-
// =============================================================================
|
|
1042
|
-
// Guards
|
|
1043
|
-
// =============================================================================
|
|
1044
|
-
|
|
1045
|
-
/**
|
|
1046
|
-
* Type guard for User.
|
|
1047
|
-
*
|
|
1048
|
-
* @category Guards
|
|
1049
|
-
* @since 0.1.0
|
|
1050
|
-
*/
|
|
1051
|
-
export const isUser = Schema.is(User);
|
|
1052
|
-
|
|
1053
|
-
/**
|
|
1054
|
-
* Refine to Admin.
|
|
1055
|
-
*
|
|
1056
|
-
* Uses `Schema.is` with the schema class per EF-35 — prefer
|
|
1057
|
-
* schema-backed guards over manual `_tag` checks.
|
|
1058
|
-
*
|
|
1059
|
-
* @category Guards
|
|
1060
|
-
* @since 0.1.0
|
|
1061
|
-
*/
|
|
1062
|
-
export const isAdmin: (self: User) => self is Admin = Schema.is(Admin);
|
|
1063
|
-
|
|
1064
|
-
/**
|
|
1065
|
-
* Refine to Customer.
|
|
1066
|
-
*
|
|
1067
|
-
* @category Guards
|
|
1068
|
-
* @since 0.1.0
|
|
1069
|
-
*/
|
|
1070
|
-
export const isCustomer: (self: User) => self is Customer = Schema.is(Customer);
|
|
1071
|
-
|
|
1072
|
-
// =============================================================================
|
|
1073
|
-
// Pattern Matching
|
|
1074
|
-
// =============================================================================
|
|
1075
|
-
|
|
1076
|
-
/**
|
|
1077
|
-
* Pattern match on User.
|
|
1078
|
-
*
|
|
1079
|
-
* @category Pattern Matching
|
|
1080
|
-
* @since 0.1.0
|
|
1081
|
-
* @example
|
|
1082
|
-
* import * as User from "@/schemas/User"
|
|
1083
|
-
*
|
|
1084
|
-
* const greeting = User.match({
|
|
1085
|
-
* Admin: (u) => `Hello Admin ${u.name}`,
|
|
1086
|
-
* Customer: (u) => `Hello ${u.tier} customer ${u.name}`
|
|
1087
|
-
* })
|
|
1088
|
-
*
|
|
1089
|
-
* const message = greeting(user)
|
|
1090
|
-
*/
|
|
1091
|
-
export const match = Match.typeTags<User>();
|
|
1092
|
-
|
|
1093
|
-
// =============================================================================
|
|
1094
|
-
// Equivalence
|
|
1095
|
-
// =============================================================================
|
|
1096
|
-
|
|
1097
|
-
/**
|
|
1098
|
-
* Compare users by ID only.
|
|
1099
|
-
*
|
|
1100
|
-
* @category Equivalence
|
|
1101
|
-
* @since 0.1.0
|
|
1102
|
-
*/
|
|
1103
|
-
export const EquivalenceById = Equivalence.mapInput(
|
|
1104
|
-
Equivalence.String,
|
|
1105
|
-
(user: User) => user.id
|
|
1106
|
-
);
|
|
1107
|
-
|
|
1108
|
-
// =============================================================================
|
|
1109
|
-
// Orders
|
|
1110
|
-
// =============================================================================
|
|
1111
|
-
|
|
1112
|
-
/**
|
|
1113
|
-
* Order by name.
|
|
1114
|
-
*
|
|
1115
|
-
* @category Orders
|
|
1116
|
-
* @since 0.1.0
|
|
1117
|
-
*/
|
|
1118
|
-
export const OrderByName: Order.Order<User> = Order.mapInput(
|
|
1119
|
-
Order.String,
|
|
1120
|
-
(user) => user.name
|
|
1121
|
-
);
|
|
1122
|
-
|
|
1123
|
-
/**
|
|
1124
|
-
* Order by creation date.
|
|
1125
|
-
*
|
|
1126
|
-
* @category Orders
|
|
1127
|
-
* @since 0.1.0
|
|
1128
|
-
*/
|
|
1129
|
-
export const OrderByCreatedAt: Order.Order<User> = Order.mapInput(
|
|
1130
|
-
DateTime.Order,
|
|
1131
|
-
(user) => user.createdAt
|
|
1132
|
-
);
|
|
1133
|
-
|
|
1134
|
-
/**
|
|
1135
|
-
* Order by tag (Admin < Customer).
|
|
1136
|
-
*
|
|
1137
|
-
* @category Orders
|
|
1138
|
-
* @since 0.1.0
|
|
1139
|
-
*/
|
|
1140
|
-
export const OrderByTag: Order.Order<User> = Order.mapInput(
|
|
1141
|
-
Order.Number,
|
|
1142
|
-
(user) => (isAdmin(user) ? 0 : 1)
|
|
1143
|
-
);
|
|
1144
|
-
|
|
1145
|
-
// =============================================================================
|
|
1146
|
-
// Destructors
|
|
1147
|
-
// =============================================================================
|
|
1148
|
-
|
|
1149
|
-
/**
|
|
1150
|
-
* Get user ID.
|
|
1151
|
-
*
|
|
1152
|
-
* @category Destructors
|
|
1153
|
-
* @since 0.1.0
|
|
1154
|
-
*/
|
|
1155
|
-
export const getId = (self: User): string => self.id;
|
|
1156
|
-
|
|
1157
|
-
/**
|
|
1158
|
-
* Get user name.
|
|
1159
|
-
*
|
|
1160
|
-
* @category Destructors
|
|
1161
|
-
* @since 0.1.0
|
|
1162
|
-
*/
|
|
1163
|
-
export const getName = (self: User): string => self.name;
|
|
1164
|
-
|
|
1165
|
-
/**
|
|
1166
|
-
* Get creation date.
|
|
1167
|
-
*
|
|
1168
|
-
* @category Destructors
|
|
1169
|
-
* @since 0.1.0
|
|
1170
|
-
*/
|
|
1171
|
-
export const getCreatedAt = (self: User): DateTime.DateTime.Utc =>
|
|
1172
|
-
self.createdAt;
|
|
1173
|
-
|
|
1174
|
-
// =============================================================================
|
|
1175
|
-
// Setters
|
|
1176
|
-
// =============================================================================
|
|
1177
|
-
|
|
1178
|
-
/**
|
|
1179
|
-
* Update user name immutably.
|
|
1180
|
-
*
|
|
1181
|
-
* @category Setters
|
|
1182
|
-
* @since 0.1.0
|
|
1183
|
-
*/
|
|
1184
|
-
export const setName: {
|
|
1185
|
-
(name: string): (self: User) => User;
|
|
1186
|
-
(self: User, name: string): User;
|
|
1187
|
-
} = dual(2, (self: User, name: string): User => ({ ...self, name }));
|
|
1188
|
-
```
|
|
1189
|
-
|
|
1190
|
-
## When to Use This Skill
|
|
1191
|
-
|
|
1192
|
-
- Creating domain entities (User, Product, Order)
|
|
1193
|
-
- Modeling value objects (Email, Money, Address)
|
|
1194
|
-
- Defining discriminated unions (states, events, commands)
|
|
1195
|
-
- Implementing ADTs (algebraic data types)
|
|
1196
|
-
- Building type-safe domain models with validation
|
|
1197
|
-
- Ensuring structural equality with automatic Equal
|
|
1198
|
-
- Creating self-documenting schemas
|
|
1199
|
-
|
|
1200
|
-
## Key Principles Summary
|
|
1201
|
-
|
|
1202
|
-
1. **Schema.TaggedStruct** - Use for all tagged union variants
|
|
1203
|
-
2. **Schema.decodeSync** - Create type-safe constructors
|
|
1204
|
-
3. **Schema.annotate** - Use selectively for public or reusable schemas
|
|
1205
|
-
4. **Order.mapInput** - Compose orders from base orders
|
|
1206
|
-
5. **Match.typeTags** - Pattern match on discriminated unions
|
|
1207
|
-
6. **Schema.suspend** - Handle recursive types
|
|
1208
|
-
7. **Namespace imports** - Always use `import * as`
|
|
1209
|
-
8. **DateTime/Duration** - Never use Date/number for temporal data
|
|
1210
|
-
9. **Equal.equals()** - Primary equality check (deep structural comparison in v4)
|
|
1211
|
-
|
|
1212
|
-
Your domain models should be production-ready, type-safe, and provide excellent developer experience.
|
|
211
|
+
class Category extends Schema.Class<Category>('Category')({
|
|
212
|
+
name: Schema.NonEmptyString,
|
|
213
|
+
children: Schema.Array(Schema.suspend((): Schema.Codec<Category, CategoryEncoded> => Category))
|
|
214
|
+
}) {}
|
|
215
|
+
|
|
216
|
+
const root = new Category({ name: 'Electronics', children: [] });
|
|
217
|
+
const decodeCategory = Schema.decodeUnknownEffect(Category);
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Do not declare both a recursive interface and a duplicate type alias with the
|
|
221
|
+
same name. For complex recursion, use an explicit encoded interface where needed;
|
|
222
|
+
see `effect-schema-composition` for transformation and recursion details.
|
|
223
|
+
|
|
224
|
+
## Public API Design
|
|
225
|
+
|
|
226
|
+
- Export the model, its boundary decoder, useful guards, and meaningful domain
|
|
227
|
+
operations. Avoid boilerplate exports that add no domain vocabulary.
|
|
228
|
+
- Use `Schema.toEquivalence` for model comparisons. `Eq.equals` provides general
|
|
229
|
+
structural equality in v4, but schema equivalence expresses model intent.
|
|
230
|
+
- Compose `Order.mapInput` / `Order.combine` and sort with `Arr.sort`. Finite
|
|
231
|
+
`Arr.groupBy` / `Iterable.groupBy` keys remain finite in rc.112, with optional
|
|
232
|
+
properties: a particular group may not exist. Handle that absence explicitly.
|
|
233
|
+
- Use `DateTime` for instants, `Duration` for intervals, and `DateTime.now` for
|
|
234
|
+
effectful current time. Deterministic fixtures may use `DateTime.makeUnsafe`
|
|
235
|
+
with a known-valid constant; do not hide a live clock in pure constructors.
|
|
236
|
+
- Reconstruct the appropriate class on immutable updates; object spreading
|
|
237
|
+
loses the prototype. Never turn a Pending into Active by changing only its tag.
|
|
238
|
+
- Export `zero`, `empty`, getters, or typeclass instances only when their laws
|
|
239
|
+
are meaningful for that model. Preserve project namespace and JSDoc conventions.
|
|
240
|
+
- Reusable data-transforming combinators should support data-first and data-last
|
|
241
|
+
calls with `dual`. Effect-returning operations use `Effect.fn`.
|
|
242
|
+
|
|
243
|
+
## Completion Checklist
|
|
244
|
+
|
|
245
|
+
- Boundary decoding yields refined values; no unchecked assertions or `any`.
|
|
246
|
+
- Schema variants and transitions make illegal lifecycle states unrepresentable.
|
|
247
|
+
- Constructor defaults are not confused with wire decoding defaults.
|
|
248
|
+
- Matching is exhaustive or has a truthful fallback; absence is represented.
|
|
249
|
+
- Equality, order, and temporal semantics are intentional.
|
|
250
|
+
- Public exports have JSDoc; runnable examples compile against the target version.
|
|
251
|
+
- Run the consuming project's required checks and tests.
|