selaws 0.0.0-stage → 0.1.0

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 (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +355 -2
  3. package/dist/evidence.d.ts +45 -0
  4. package/dist/evidence.d.ts.map +1 -0
  5. package/dist/evidence.js +22 -0
  6. package/dist/evidence.js.map +1 -0
  7. package/dist/identity.d.ts +52 -0
  8. package/dist/identity.d.ts.map +1 -0
  9. package/dist/identity.js +22 -0
  10. package/dist/identity.js.map +1 -0
  11. package/dist/index.d.ts +35 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +18 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/internal/callback.d.ts +13 -0
  16. package/dist/internal/callback.d.ts.map +1 -0
  17. package/dist/internal/callback.js +2 -0
  18. package/dist/internal/callback.js.map +1 -0
  19. package/dist/internal/promise-like.d.ts +8 -0
  20. package/dist/internal/promise-like.d.ts.map +1 -0
  21. package/dist/internal/promise-like.js +12 -0
  22. package/dist/internal/promise-like.js.map +1 -0
  23. package/dist/internal/scalar.d.ts +18 -0
  24. package/dist/internal/scalar.d.ts.map +1 -0
  25. package/dist/internal/scalar.js +6 -0
  26. package/dist/internal/scalar.js.map +1 -0
  27. package/dist/option.d.ts +90 -0
  28. package/dist/option.d.ts.map +1 -0
  29. package/dist/option.js +96 -0
  30. package/dist/option.js.map +1 -0
  31. package/dist/protocol.d.ts +42 -0
  32. package/dist/protocol.d.ts.map +1 -0
  33. package/dist/protocol.js +67 -0
  34. package/dist/protocol.js.map +1 -0
  35. package/dist/result/capture.d.ts +42 -0
  36. package/dist/result/capture.d.ts.map +1 -0
  37. package/dist/result/capture.js +41 -0
  38. package/dist/result/capture.js.map +1 -0
  39. package/dist/result/core.d.ts +98 -0
  40. package/dist/result/core.d.ts.map +1 -0
  41. package/dist/result/core.js +103 -0
  42. package/dist/result/core.js.map +1 -0
  43. package/dist/result/index.d.ts +4 -0
  44. package/dist/result/index.d.ts.map +1 -0
  45. package/dist/result/index.js +4 -0
  46. package/dist/result/index.js.map +1 -0
  47. package/dist/result/throw.d.ts +4 -0
  48. package/dist/result/throw.d.ts.map +1 -0
  49. package/dist/result/throw.js +8 -0
  50. package/dist/result/throw.js.map +1 -0
  51. package/dist/validation.d.ts +126 -0
  52. package/dist/validation.d.ts.map +1 -0
  53. package/dist/validation.js +240 -0
  54. package/dist/validation.js.map +1 -0
  55. package/dist/variant.d.ts +97 -0
  56. package/dist/variant.d.ts.map +1 -0
  57. package/dist/variant.js +102 -0
  58. package/dist/variant.js.map +1 -0
  59. package/docs/API.md +543 -0
  60. package/docs/GUIDE.md +739 -0
  61. package/docs/SEMANTICS.md +319 -0
  62. package/docs/laws/evidence.md +113 -0
  63. package/docs/laws/identity.md +126 -0
  64. package/docs/laws/match.md +163 -0
  65. package/docs/laws/option.md +100 -0
  66. package/docs/laws/protocol.md +152 -0
  67. package/docs/laws/result.md +124 -0
  68. package/docs/laws/validation.md +111 -0
  69. package/docs/laws/variant.md +251 -0
  70. package/package.json +87 -3
  71. package/src/evidence.ts +120 -0
  72. package/src/identity.ts +129 -0
  73. package/src/index.ts +54 -0
  74. package/src/internal/callback.ts +54 -0
  75. package/src/internal/promise-like.ts +32 -0
  76. package/src/internal/scalar.ts +43 -0
  77. package/src/option.ts +214 -0
  78. package/src/protocol.ts +174 -0
  79. package/src/result/capture.ts +209 -0
  80. package/src/result/core.ts +229 -0
  81. package/src/result/index.ts +3 -0
  82. package/src/result/throw.ts +13 -0
  83. package/src/validation.ts +491 -0
  84. package/src/variant.ts +363 -0
package/docs/GUIDE.md ADDED
@@ -0,0 +1,739 @@
1
+ # Selaws guide
2
+
3
+ Selaws works best when code names the semantic question before choosing the
4
+ data shape. Several owners use unions or phantom types internally, but those
5
+ similar representations do not make their meanings interchangeable.
6
+
7
+ ## 1. Choose the owner from the question
8
+
9
+ Start with the question the code must answer.
10
+
11
+ | Question | Owner | Typical representation |
12
+ | --- | --- | --- |
13
+ | What scalar value is this? | Identity | branded scalar |
14
+ | What stable fact is established about this scalar? | Evidence | additional scalar fact |
15
+ | Which transition is admissible? | Protocol | labeled relation |
16
+ | Which member of a closed family is this? | Variant | tagged object |
17
+ | Is a value present? | Option | Some / None |
18
+ | Which independent checks have issues? | Validation | Valid / non-empty Invalid |
19
+ | Did a recoverable computation succeed? | Result | Ok / Err |
20
+
21
+ This usually gives a smaller design than starting with “I need a union type” or
22
+ “I need a state machine”.
23
+
24
+ ### Shared Match law: one elimination model, owner-scoped syntax
25
+
26
+ Option, Result, Validation, and Variant are different semantic owners, but all
27
+ four expose the same kind of total elimination:
28
+
29
+ ```ts
30
+ Option.match(maybeValue, {
31
+ some: (value) => use(value),
32
+ none: () => fallback(),
33
+ });
34
+
35
+ Result.match(result, {
36
+ ok: (value) => use(value),
37
+ err: (error) => recover(error),
38
+ });
39
+
40
+ Validation.match(validation, {
41
+ valid: (value) => use(value),
42
+ invalid: (errors) => report(errors),
43
+ });
44
+
45
+ Message.match(message, {
46
+ quit: () => stop(),
47
+ write: (text) => write(text),
48
+ });
49
+ ```
50
+
51
+ The common meaning is Match: handle the complete branch universe owned by the
52
+ carrier, invoke exactly one selected handler, preserve that branch's payload,
53
+ and let ordinary JavaScript return, throw, or Promise completion pass through.
54
+
55
+ Match is a shared law rather than a new owner or API namespace. There is no
56
+ `Match(...)` dispatcher. Keeping the syntax on `Option`, `Result`,
57
+ `Validation`, or the Variant family keeps branch ownership visible and
58
+ preserves TypeScript's owner-specific inference.
59
+
60
+ Use Match when an operation conceptually handles the whole sum. Ordinary
61
+ TypeScript narrowing remains appropriate when local control flow already owns
62
+ one branch.
63
+
64
+ ## 2. Give scalar values domain identity
65
+
66
+ Use Identity when two runtime-identical scalar values must remain distinct in
67
+ TypeScript.
68
+
69
+ ```ts
70
+ import { identity } from "selaws/identity";
71
+
72
+ export const UserId = identity.string("UserId");
73
+ export type UserId = identity.Value<typeof UserId>;
74
+
75
+ export const OrderId = identity.string("OrderId");
76
+ export type OrderId = identity.Value<typeof OrderId>;
77
+
78
+ function findUser(id: UserId) {}
79
+
80
+ findUser(UserId("u_1"));
81
+ // findUser(OrderId("o_1")); // type error
82
+ ```
83
+
84
+ The runtime value stays a string. Identity changes the type-level meaning, not
85
+ the JavaScript representation.
86
+
87
+ ### Checked formation
88
+
89
+ Use an Identity predicate when the same owner can decide whether an incoming
90
+ scalar is admissible.
91
+
92
+ ```ts
93
+ const Port = identity.number(
94
+ "Port",
95
+ (value) =>
96
+ Number.isInteger(value) &&
97
+ value >= 0 &&
98
+ value <= 65_535,
99
+ );
100
+
101
+ type Port = identity.Value<typeof Port>;
102
+
103
+ const port = Port(rawPort);
104
+
105
+ if (port === undefined) {
106
+ // rawPort did not satisfy the Port formation condition.
107
+ }
108
+ ```
109
+
110
+ The predicate is a local formation condition, not a general schema-decoding
111
+ framework.
112
+
113
+ ## 3. Establish stable scalar facts with Evidence
114
+
115
+ Use Evidence when a value already has the right identity, but another stable
116
+ fact must be established independently.
117
+
118
+ ```ts
119
+ import { evidence } from "selaws/evidence";
120
+
121
+ const NonEmpty = evidence.string(
122
+ "NonEmpty",
123
+ (value) => value.length > 0,
124
+ );
125
+
126
+ declare const userId: UserId;
127
+
128
+ const checked = NonEmpty(userId);
129
+
130
+ if (checked !== undefined) {
131
+ const stillUserId: UserId = checked;
132
+ }
133
+ ```
134
+
135
+ Identity and Evidence accumulate on the same scalar. Evidence does not replace
136
+ the domain identity that was already present.
137
+
138
+ If an operation produces a new scalar, establish the relevant fact again:
139
+
140
+ ```ts
141
+ const trimmed = userId.trim();
142
+ const checkedTrimmed = NonEmpty(trimmed);
143
+ ```
144
+
145
+ Evidence applies to the scalar value that was actually checked.
146
+
147
+ ## 4. Use Option only when absence has no reason
148
+
149
+ ```ts
150
+ import { Option } from "selaws/option";
151
+
152
+ const nickname = Option.fromUndefined(row.nickname);
153
+
154
+ const label = Option.match(nickname, {
155
+ some: (value) => value,
156
+ none: () => "Anonymous",
157
+ });
158
+ ```
159
+
160
+ Option is appropriate for lookups, optional fields, and other cases where the
161
+ empty branch carries no diagnostic information.
162
+
163
+ Presence is explicit:
164
+
165
+ ```ts
166
+ Option.some(undefined);
167
+ // { some: true, value: undefined }
168
+
169
+ Option.fromUndefined(undefined);
170
+ // { some: false }
171
+ ```
172
+
173
+ If callers need to know why a value is missing, use Result or a domain-specific
174
+ Variant instead of putting an implicit reason behind None.
175
+
176
+ ### Transform and combine Options
177
+
178
+ ```ts
179
+ const normalized = Option.map(nickname, (value) =>
180
+ value.trim(),
181
+ );
182
+
183
+ const pair = Option.all([
184
+ maybeFirstName,
185
+ maybeLastName,
186
+ ] as const);
187
+ ```
188
+
189
+ `Option.all` succeeds only when every input is Some and preserves tuple
190
+ positions.
191
+
192
+ ## 5. Use Validation for independent checks
193
+
194
+ Validation is for checks that can all run from already-available inputs.
195
+
196
+ ```ts
197
+ import {
198
+ Validation,
199
+ type Validation as ValidationValue,
200
+ } from "selaws/validation";
201
+
202
+ type Issue =
203
+ | "name-required"
204
+ | "email-invalid";
205
+
206
+ const validateName = (
207
+ value: string,
208
+ ): ValidationValue<string, Issue> =>
209
+ value.length > 0
210
+ ? Validation.valid(value)
211
+ : Validation.invalid("name-required");
212
+
213
+ const validateEmail = (
214
+ value: string,
215
+ ): ValidationValue<string, Issue> =>
216
+ value.includes("@")
217
+ ? Validation.valid(value)
218
+ : Validation.invalid("email-invalid");
219
+
220
+ const form = Validation.struct([
221
+ ["name", validateName(raw.name)],
222
+ ["email", validateEmail(raw.email)],
223
+ ]);
224
+ ```
225
+
226
+ If both fields are invalid, `form.errors` contains both issues in deterministic
227
+ input order.
228
+
229
+ Use `Validation.all` for positional tuples and `Validation.struct` for finite keyed products.
230
+
231
+ Do not use Validation to model a sequence where the second operation cannot run
232
+ until the first succeeds. That is Result-shaped work.
233
+
234
+ ## 6. Use Result for dependent recoverable work
235
+
236
+ ```ts
237
+ import {
238
+ Result,
239
+ type Result as ResultValue,
240
+ } from "selaws/result";
241
+
242
+ const parsePort = (
243
+ text: string,
244
+ ): ResultValue<number, "invalid-port"> => {
245
+ const value = Number(text);
246
+
247
+ return Number.isInteger(value) && value > 0 && value <= 65_535
248
+ ? Result.ok(value)
249
+ : Result.err("invalid-port");
250
+ };
251
+ ```
252
+
253
+ Dependent steps can stay explicit:
254
+
255
+ ```ts
256
+ const user = await loadUser(userId);
257
+
258
+ if (!user.ok) {
259
+ return user;
260
+ }
261
+
262
+ const account = await loadAccount(user.value.accountId);
263
+
264
+ if (!account.ok) {
265
+ return account;
266
+ }
267
+
268
+ return saveAccount(account.value);
269
+ ```
270
+
271
+ This is ordinary JavaScript control flow over Result data. Selaws does not add
272
+ an implicit early-return runtime.
273
+
274
+ ### Functional composition
275
+
276
+ For local data pipelines, the focused helpers remain available:
277
+
278
+ ```ts
279
+ import {
280
+ andThen,
281
+ map,
282
+ type Result,
283
+ } from "selaws/result";
284
+
285
+ const parsed = parsePort(text);
286
+
287
+ const endpoint = map(
288
+ parsed,
289
+ (port) => ({ host: "localhost", port }),
290
+ );
291
+ ```
292
+
293
+ Use `andThen` when the next function itself returns Result.
294
+
295
+ ## 7. Accumulate first, then fail fast
296
+
297
+ Form validation often has two phases:
298
+
299
+ 1. inspect independent inputs and report every issue;
300
+ 2. after valid input exists, perform dependent work that can fail.
301
+
302
+ The owner boundary can remain explicit.
303
+
304
+ ```ts
305
+ const input = Validation.struct([
306
+ ["userId", validateUserId(raw.userId)],
307
+ ["email", validateEmail(raw.email)],
308
+ ]);
309
+
310
+ const ready = Result.fromValidation(input);
311
+
312
+ if (!ready.ok) {
313
+ return ready;
314
+ }
315
+
316
+ const current = await loadUser(ready.value.userId);
317
+
318
+ if (!current.ok) {
319
+ return current;
320
+ }
321
+
322
+ return saveUser(
323
+ current.value,
324
+ ready.value.email,
325
+ );
326
+ ```
327
+
328
+ `Result.fromValidation` carries the complete non-empty Validation issue
329
+ collection as one Result error value. It does not flatten that collection into
330
+ separate Result errors.
331
+
332
+ ## 8. Use Variant for a closed domain vocabulary
333
+
334
+ Variant is useful when a domain has a finite set of alternatives and each
335
+ alternative owns its payload shape.
336
+
337
+ ```ts
338
+ import { Variant } from "selaws/variant";
339
+
340
+ const LoadUserError = Variant.define("LoadUserError", [
341
+ ["notFound", Variant.payload<Readonly<{ id: UserId }>>()],
342
+ ["forbidden", Variant.unit],
343
+ ["storage", Variant.payload<Readonly<{ cause: unknown }>>()],
344
+ ]);
345
+
346
+ type LoadUserError =
347
+ Variant.Value<typeof LoadUserError>;
348
+ ```
349
+
350
+ Constructors are generated from the declaration:
351
+
352
+ ```ts
353
+ const missing =
354
+ LoadUserError.make.notFound({ id: userId });
355
+
356
+ const denied =
357
+ LoadUserError.make.forbidden();
358
+ ```
359
+
360
+ Each constructor preserves the tag/payload correlation. A wrong payload is a
361
+ type error.
362
+
363
+ ### Eliminate the complete family
364
+
365
+ ```ts
366
+ const message = LoadUserError.match(error, {
367
+ notFound: ({ id }) =>
368
+ `User ${id} was not found`,
369
+ forbidden: () =>
370
+ "Access is forbidden",
371
+ storage: ({ cause }) =>
372
+ `Storage failed: ${String(cause)}`,
373
+ });
374
+ ```
375
+
376
+ Typed `match` is exhaustive over the declared family. If a new case is added,
377
+ family-level matches must account for it.
378
+
379
+ Normal TypeScript narrowing also works:
380
+
381
+ ```ts
382
+ if (error.tag === "notFound") {
383
+ error.value.id;
384
+ // UserId
385
+ }
386
+ ```
387
+
388
+ Use `match` when the operation conceptually handles the family as a whole.
389
+ Use ordinary narrowing when local control flow already owns the branch.
390
+
391
+ ### Generic Variant families
392
+
393
+ ```ts
394
+ const Remote = <T>() =>
395
+ Variant.define("Remote", [
396
+ ["idle", Variant.unit],
397
+ ["success", Variant.payload<T>()],
398
+ ["failure", Variant.payload<Error>()],
399
+ ]);
400
+
401
+ type Remote<T> =
402
+ Variant.Value<ReturnType<typeof Remote<T>>>;
403
+
404
+ const RemoteUser = Remote<User>();
405
+
406
+ const loaded: Remote<User> =
407
+ RemoteUser.make.success(user);
408
+ ```
409
+
410
+ ### Recursive Variant families
411
+
412
+ Put the recursive reference through an ordinary object or interface boundary:
413
+
414
+ ```ts
415
+ interface AddPayload {
416
+ readonly left: Expr;
417
+ readonly right: Expr;
418
+ }
419
+
420
+ const Expr = Variant.define("Expr", [
421
+ ["literal", Variant.payload<number>()],
422
+ ["add", Variant.payload<AddPayload>()],
423
+ ]);
424
+
425
+ type Expr = Variant.Value<typeof Expr>;
426
+ ```
427
+
428
+ ## 9. Let Variant own an error vocabulary and Result transport it
429
+
430
+ A closed domain error family composes naturally with Result.
431
+
432
+ ```ts
433
+ type LoadUserResult =
434
+ ResultValue<User, LoadUserError>;
435
+
436
+ const loadUser = async (
437
+ id: UserId,
438
+ ): Promise<LoadUserResult> => {
439
+ const row = await repository.find(id);
440
+
441
+ if (row === undefined) {
442
+ return Result.err(
443
+ LoadUserError.make.notFound({ id }),
444
+ );
445
+ }
446
+
447
+ return Result.ok(row);
448
+ };
449
+ ```
450
+
451
+ Result answers whether the operation succeeded. Variant answers which
452
+ domain-specific error alternative exists. The two owners remain independent.
453
+
454
+ The caller can inspect the Result first and then eliminate the Variant:
455
+
456
+ ```ts
457
+ const loaded = await loadUser(userId);
458
+
459
+ if (!loaded.ok) {
460
+ return LoadUserError.match(loaded.error, {
461
+ notFound: ({ id }) =>
462
+ `missing: ${id}`,
463
+ forbidden: () =>
464
+ "forbidden",
465
+ storage: () =>
466
+ "storage unavailable",
467
+ });
468
+ }
469
+
470
+ return loaded.value;
471
+ ```
472
+
473
+ ## 10. Use Protocol for admissible transitions
474
+
475
+ Protocol describes a labeled transition relation. It is deliberately smaller
476
+ than a state-machine runtime.
477
+
478
+ ```ts
479
+ import {
480
+ Protocol,
481
+ type Next,
482
+ } from "selaws/protocol";
483
+
484
+ const orderTransitions = [
485
+ ["pending", "pay", "paid"],
486
+ ["pending", "cancel", "cancelled"],
487
+ ["paid", "ship", "shipped"],
488
+ ] as const;
489
+
490
+ const OrderProtocol =
491
+ Protocol.define(orderTransitions);
492
+
493
+ type AfterPayment =
494
+ Next<
495
+ typeof orderTransitions,
496
+ "pending",
497
+ "pay"
498
+ >;
499
+ // "paid"
500
+ ```
501
+
502
+ At runtime, ask whether one concrete triple belongs to the declaration:
503
+
504
+ ```ts
505
+ OrderProtocol.allows(
506
+ "pending",
507
+ "pay",
508
+ "paid",
509
+ );
510
+ // true
511
+ ```
512
+
513
+ The application still owns the current state and the mutation:
514
+
515
+ ```ts
516
+ if (
517
+ OrderProtocol.allows(
518
+ currentState,
519
+ "pay",
520
+ nextState,
521
+ )
522
+ ) {
523
+ await persistTransition(
524
+ currentState,
525
+ nextState,
526
+ );
527
+ }
528
+ ```
529
+
530
+ `allows` establishes relation membership. It does not prove that
531
+ `currentState` is still current at commit time. The storage/concurrency layer
532
+ owns that fact.
533
+
534
+ ### Nondeterministic relations are allowed
535
+
536
+ ```ts
537
+ const routing = Protocol.define([
538
+ ["open", "advance", "left"],
539
+ ["open", "advance", "right"],
540
+ ] as const);
541
+ ```
542
+
543
+ Protocol does not choose between `left` and `right`. It only states that both
544
+ triples are admissible.
545
+
546
+ ## 11. Compose Protocol and Variant without merging them
547
+
548
+ Variant can own an event vocabulary while Protocol owns the relation between
549
+ scalar state identifiers and event labels.
550
+
551
+ ```ts
552
+ const OrderEvent = Variant.define("OrderEvent", [
553
+ ["pay", Variant.payload<Readonly<{
554
+ transactionId: string;
555
+ }>>()],
556
+ ["cancel", Variant.unit],
557
+ ]);
558
+
559
+ type OrderEvent =
560
+ Variant.Value<typeof OrderEvent>;
561
+
562
+ const transitions = [
563
+ ["pending", "pay", "paid"],
564
+ ["pending", "cancel", "cancelled"],
565
+ ] as const;
566
+
567
+ const OrderProtocol =
568
+ Protocol.define(transitions);
569
+ ```
570
+
571
+ The Variant payload carries event data. The Protocol label is the scalar
572
+ `event.tag` used to ask about admissibility. Protocol does not need to import
573
+ or understand the Variant payload.
574
+
575
+ ```ts
576
+ const event =
577
+ OrderEvent.make.pay({
578
+ transactionId: "tx_1",
579
+ });
580
+
581
+ const target = "paid" as const;
582
+
583
+ if (
584
+ OrderProtocol.allows(
585
+ "pending",
586
+ event.tag,
587
+ target,
588
+ )
589
+ ) {
590
+ // Application code performs the payment
591
+ // and owns the state update.
592
+ }
593
+ ```
594
+
595
+ This separation keeps “what event is this?” distinct from “is this transition
596
+ allowed?”.
597
+
598
+ ## 12. Translate throwing APIs at explicit Result boundaries
599
+
600
+ Use `attempt` for a synchronous API that may throw:
601
+
602
+ ```ts
603
+ import { attempt } from "selaws/result";
604
+
605
+ const parsed = attempt(
606
+ () => JSON.parse(text) as unknown,
607
+ (cause) => ({
608
+ kind: "invalid-json" as const,
609
+ cause,
610
+ }),
611
+ );
612
+ ```
613
+
614
+ Use `attemptAsync` for a Promise-returning boundary whose invocation or
615
+ rejection should become recoverable Result data.
616
+
617
+ `wrap` and `wrapAsync` apply those boundaries to reusable functions while
618
+ preserving arguments and `this`.
619
+
620
+ Use `orThrow` when an application boundary intentionally converts Err back to
621
+ an abrupt JavaScript completion.
622
+
623
+ ## 13. Keep Promise as the async owner
624
+
625
+ Selaws does not define `AsyncOption`, `AsyncValidation`, or an asynchronous
626
+ Result carrier. Use Promise directly.
627
+
628
+ ```ts
629
+ Promise<ResultValue<T, E>>
630
+ Promise<Option<T>>
631
+ Promise<ValidationValue<T, E>>
632
+ ```
633
+
634
+ Independent asynchronous work can be scheduled first and then combined:
635
+
636
+ ```ts
637
+ const [name, email] = await Promise.all([
638
+ validateNameAsync(raw.name),
639
+ validateEmailAsync(raw.email),
640
+ ]);
641
+
642
+ const input = Validation.struct([
643
+ ["name", name],
644
+ ["email", email],
645
+ ]);
646
+ ```
647
+
648
+ Scheduling belongs to Promise. Accumulation belongs to Validation.
649
+
650
+ ## 14. Choose named or declaration-owned identity deliberately
651
+
652
+ Identity, Evidence, and Variant support two identity modes.
653
+
654
+ A string literal is a shared structural name:
655
+
656
+ ```ts
657
+ const UserId = identity.string("UserId");
658
+ ```
659
+
660
+ Compatible producers that use the same owner, name, and declaration meaning can
661
+ interoperate across duplicate Selaws installations.
662
+
663
+ A bound unique symbol makes one declaration own identity:
664
+
665
+ ```ts
666
+ const UserIdKey: unique symbol =
667
+ Symbol("UserId");
668
+
669
+ const StrictUserId =
670
+ identity.string(UserIdKey);
671
+ ```
672
+
673
+ Variant uses the same choice:
674
+
675
+ ```ts
676
+ const EventKey: unique symbol =
677
+ Symbol("Event");
678
+
679
+ const Event = Variant.define(EventKey, [
680
+ ["started", Variant.unit],
681
+ ["stopped", Variant.unit],
682
+ ]);
683
+ ```
684
+
685
+ Use a symbol when declaration identity itself matters. Use a string name when a
686
+ shared structural identity is the intended compatibility contract.
687
+
688
+ ## 15. Keep boundary work with the application
689
+
690
+ Selaws deliberately leaves these concerns with the layer that can actually
691
+ establish them:
692
+
693
+ - decoding unknown JSON or wire data;
694
+ - authorization and policy decisions;
695
+ - mutable freshness and optimistic/transactional concurrency;
696
+ - persistence and event dispatch;
697
+ - retry, scheduling, and timers;
698
+ - resource limits and cancellation;
699
+ - protocol-version negotiation.
700
+
701
+ For example, `Variant.payload<User>()` says that typed construction expects a
702
+ `User`. It does not validate an unknown JSON object as User at runtime.
703
+
704
+ Likewise, `Protocol.allows(a, label, b)` says the triple was declared. It does
705
+ not authorize the action or prove that persisted state is still `a`.
706
+
707
+ ## 16. Choose the import surface for the file
708
+
709
+ Focused imports work well when one owner dominates the module:
710
+
711
+ ```ts
712
+ import {
713
+ andThen,
714
+ map,
715
+ ok,
716
+ type Result,
717
+ } from "selaws/result";
718
+ ```
719
+
720
+ Root facades make mixed-owner application code readable:
721
+
722
+ ```ts
723
+ import {
724
+ Option,
725
+ Protocol,
726
+ Result,
727
+ Validation,
728
+ Variant,
729
+ } from "selaws";
730
+
731
+ Protocol.define(...);
732
+ Variant.define(...);
733
+ Option.map(...);
734
+ Validation.map(...);
735
+ Result.map(...);
736
+ ```
737
+
738
+ The facade name keeps the semantic owner visible even when several primitives
739
+ appear in the same function.