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.
Files changed (47) hide show
  1. package/README.md +38 -10
  2. package/docs/effect-4.0.0-rc.112.md +316 -0
  3. package/guidance/effect-first-development.md +30 -17
  4. package/guidance/progressive-disclosure-guidance.md +13 -0
  5. package/package.json +3 -2
  6. package/patterns/avoid-direct-tag-checks.md +8 -2
  7. package/patterns/avoid-react-hooks.md +18 -37
  8. package/patterns/effect-run-in-body.md +1 -1
  9. package/patterns/require-effect-concurrency.md +11 -0
  10. package/patterns/use-console-service.md +6 -1
  11. package/skills/effect-ai-language-model/SKILL.md +10 -16
  12. package/skills/effect-ai-prompt/SKILL.md +36 -2
  13. package/skills/effect-ai-provider/SKILL.md +13 -0
  14. package/skills/effect-ai-streaming/SKILL.md +81 -108
  15. package/skills/effect-ai-tool/SKILL.md +50 -87
  16. package/skills/effect-atom-rpc/SKILL.md +9 -2
  17. package/skills/effect-atom-state/SKILL.md +5 -0
  18. package/skills/effect-cache/SKILL.md +32 -0
  19. package/skills/effect-cli/SKILL.md +22 -3
  20. package/skills/effect-concurrency-testing/SKILL.md +7 -9
  21. package/skills/effect-domain-modeling/SKILL.md +208 -1169
  22. package/skills/effect-domain-predicates/SKILL.md +5 -6
  23. package/skills/effect-error-handling/SKILL.md +5 -4
  24. package/skills/effect-http-api/SKILL.md +12 -1
  25. package/skills/effect-http-client/SKILL.md +1 -1
  26. package/skills/effect-http-server/SKILL.md +14 -3
  27. package/skills/effect-layer-design/SKILL.md +22 -56
  28. package/skills/effect-mcp-server/SKILL.md +1 -1
  29. package/skills/effect-pattern-matching/SKILL.md +44 -11
  30. package/skills/effect-platform-abstraction/SKILL.md +1 -1
  31. package/skills/effect-platform-layers/SKILL.md +1 -1
  32. package/skills/effect-rpc-api/SKILL.md +8 -1
  33. package/skills/effect-rpc-client/SKILL.md +20 -6
  34. package/skills/effect-rpc-cluster/SKILL.md +44 -14
  35. package/skills/effect-rpc-server/SKILL.md +32 -5
  36. package/skills/effect-scheduling/SKILL.md +1 -1
  37. package/skills/effect-schema-composition/SKILL.md +69 -15
  38. package/skills/effect-schema-v4/SKILL.md +43 -1
  39. package/skills/effect-scope/SKILL.md +30 -0
  40. package/skills/effect-service-implementation/SKILL.md +10 -4
  41. package/skills/effect-socket/SKILL.md +5 -5
  42. package/skills/effect-sql/SKILL.md +22 -0
  43. package/skills/effect-stream/SKILL.md +32 -1
  44. package/skills/effect-testing/SKILL.md +39 -31
  45. package/skills/effect-workflow/SKILL.md +6 -0
  46. package/patterns/vm-in-wrong-file.md +0 -51
  47. package/skills/effect-react-vm/SKILL.md +0 -675
@@ -1,1212 +1,251 @@
1
1
  ---
2
2
  name: effect-domain-modeling
3
- description: Create production-ready Effect domain models using Schema.TaggedStruct for ADTs, with comprehensive predicates, orders, guards, and match functions. Use when modeling domain entities, value objects, or any discriminated union types.
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 Skill
6
+ # Effect Domain Modeling
7
7
 
8
- Use this skill when creating domain models, entities, value objects, or any types that represent core business concepts. This skill covers the complete lifecycle from type definition to runtime utilities.
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
- ## Effect Source Reference
14
+ ## Source Reference
11
15
 
12
- The Effect v4 source is available at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`.
13
- Browse and read files there directly to look up APIs, types, and implementations.
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
- Reference this for:
20
+ ## Constructors Are Not Decoders
16
21
 
17
- - Full Schema API: `packages/effect/SCHEMA.md`
18
- - Match source: `packages/effect/src/Match.ts`
19
- - Migration guide: `MIGRATION.md`
20
- - Effect source: `packages/effect/src/`
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
- ## Core Pattern: Schema.TaggedStruct
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
- The foundation of Effect domain modeling combines two key features:
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
- 1. **Schema.TaggedStruct** - Automatic `_tag` discriminator for union types
27
- 2. **Schema.decodeSync** - Type-safe constructors with validation
39
+ ## Complete Model: Task Lifecycle
28
40
 
29
- In v4, `Equal.equals` performs deep structural comparison by default, so no special wrapping (like the removed `Schema.Data`) is needed.
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 { Schema, Equal } from 'effect';
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
- // Define each variant with TaggedStruct
35
- export const Pending = Schema.TaggedStruct('pending', {
36
- id: Schema.String,
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
- export const Active = Schema.TaggedStruct('active', {
41
- id: Schema.String,
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
- export const Completed = Schema.TaggedStruct('completed', {
47
- id: Schema.String,
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
- - Primary pattern for discriminated unions
298
- - Type-safe and exhaustive
299
- - Works with any `_tag` discriminator
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
- Provide sorting capabilities using `Order.mapInput`:
450
-
451
- ```typescript
452
- import * as Order from 'effect/Order';
453
- import * as DateTime from 'effect/DateTime';
454
-
455
- // Assume Task type from previous section
456
- declare type Task =
457
- | {
458
- readonly _tag: 'pending';
459
- readonly id: string;
460
- readonly createdAt: DateTime.DateTime.Utc;
461
- }
462
- | {
463
- readonly _tag: 'active';
464
- readonly id: string;
465
- readonly createdAt: DateTime.DateTime.Utc;
466
- readonly startedAt: DateTime.DateTime.Utc;
467
- }
468
- | {
469
- readonly _tag: 'completed';
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
- * Order by ID.
499
- *
500
- * @category Orders
501
- * @since 0.1.0
502
- */
503
- export const OrderById: Order.Order<Task> = Order.mapInput(
504
- Order.String,
505
- (task) => task.id
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
- * Order by creation date.
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
- * Combine multiple orders for multi-criteria sorting.
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
- **Key Pattern: Order.mapInput**
539
-
540
- - Compose orders from simpler base orders
541
- - Map domain type to comparable value
542
- - Signature: `Order.mapInput(baseOrder, (value) => extractField)`
543
-
544
- **Key Pattern: Order.combine**
545
-
546
- - Combine multiple orders for multi-criteria sorting
547
- - First order takes precedence, then second, etc.
548
-
549
- ### Destructors (Getters)
550
-
551
- Safe extraction of inner values:
552
-
553
- ```typescript
554
- import * as DateTime from 'effect/DateTime';
555
-
556
- // Assume Task type from previous section
557
- declare type Task =
558
- | {
559
- readonly _tag: 'pending';
560
- readonly id: string;
561
- readonly createdAt: DateTime.DateTime.Utc;
562
- }
563
- | {
564
- readonly _tag: 'active';
565
- readonly id: string;
566
- readonly createdAt: DateTime.DateTime.Utc;
567
- readonly startedAt: DateTime.DateTime.Utc;
568
- }
569
- | {
570
- readonly _tag: 'completed';
571
- readonly id: string;
572
- readonly createdAt: DateTime.DateTime.Utc;
573
- readonly completedAt: DateTime.DateTime.Utc;
574
- };
575
-
576
- /**
577
- * Get the ID from any Task variant.
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
- }).pipe(
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
- **Key Pattern: Schema.suspend**
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
- - Use for self-referencing types
694
- - Separate base fields for clarity
695
- - Define interface first, then schema with `Schema.suspend`
197
+ ## Recursive Models
696
198
 
697
- ### Branded Types
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 Brand from 'effect/Brand';
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
- * Schema for Email serialization/deserialization.
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
- // Assume Task type from previous section
751
- declare type Task =
752
- | { readonly _tag: 'pending'; readonly id: string; readonly createdAt: any }
753
- | {
754
- readonly _tag: 'active';
755
- readonly id: string;
756
- readonly createdAt: any;
757
- readonly startedAt: any;
758
- }
759
- | {
760
- readonly _tag: 'completed';
761
- readonly id: string;
762
- readonly createdAt: any;
763
- readonly completedAt: any;
764
- };
765
-
766
- /**
767
- * Schedulable instance for Task.
768
- *
769
- * @category Typeclasses
770
- * @since 0.1.0
771
- */
772
- export const Schedulable = Schedulable$.make<Task>(
773
- (self) => self.createdAt,
774
- (self, date) => ({ ...self, createdAt: date })
775
- );
776
-
777
- // Re-export derived predicates
778
- export const isScheduledBefore = Schedulable$.isScheduledBefore(Schedulable);
779
-
780
- // Re-export derived orders
781
- export const OrderByScheduledDate =
782
- Schedulable$.OrderByScheduledDate(Schedulable);
783
- ```
784
-
785
- **Common typeclass examples:**
786
-
787
- - **Schedulable**: For types with date/time properties
788
- - **Durable**: For types with duration properties
789
- - **Priceable**: For types with price properties
790
- - **Identifiable**: For types with ID properties
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.