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.
- package/LICENSE +21 -0
- package/README.md +355 -2
- package/dist/evidence.d.ts +45 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +22 -0
- package/dist/evidence.js.map +1 -0
- package/dist/identity.d.ts +52 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +22 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/callback.d.ts +13 -0
- package/dist/internal/callback.d.ts.map +1 -0
- package/dist/internal/callback.js +2 -0
- package/dist/internal/callback.js.map +1 -0
- package/dist/internal/promise-like.d.ts +8 -0
- package/dist/internal/promise-like.d.ts.map +1 -0
- package/dist/internal/promise-like.js +12 -0
- package/dist/internal/promise-like.js.map +1 -0
- package/dist/internal/scalar.d.ts +18 -0
- package/dist/internal/scalar.d.ts.map +1 -0
- package/dist/internal/scalar.js +6 -0
- package/dist/internal/scalar.js.map +1 -0
- package/dist/option.d.ts +90 -0
- package/dist/option.d.ts.map +1 -0
- package/dist/option.js +96 -0
- package/dist/option.js.map +1 -0
- package/dist/protocol.d.ts +42 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +67 -0
- package/dist/protocol.js.map +1 -0
- package/dist/result/capture.d.ts +42 -0
- package/dist/result/capture.d.ts.map +1 -0
- package/dist/result/capture.js +41 -0
- package/dist/result/capture.js.map +1 -0
- package/dist/result/core.d.ts +98 -0
- package/dist/result/core.d.ts.map +1 -0
- package/dist/result/core.js +103 -0
- package/dist/result/core.js.map +1 -0
- package/dist/result/index.d.ts +4 -0
- package/dist/result/index.d.ts.map +1 -0
- package/dist/result/index.js +4 -0
- package/dist/result/index.js.map +1 -0
- package/dist/result/throw.d.ts +4 -0
- package/dist/result/throw.d.ts.map +1 -0
- package/dist/result/throw.js +8 -0
- package/dist/result/throw.js.map +1 -0
- package/dist/validation.d.ts +126 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +240 -0
- package/dist/validation.js.map +1 -0
- package/dist/variant.d.ts +97 -0
- package/dist/variant.d.ts.map +1 -0
- package/dist/variant.js +102 -0
- package/dist/variant.js.map +1 -0
- package/docs/API.md +543 -0
- package/docs/GUIDE.md +739 -0
- package/docs/SEMANTICS.md +319 -0
- package/docs/laws/evidence.md +113 -0
- package/docs/laws/identity.md +126 -0
- package/docs/laws/match.md +163 -0
- package/docs/laws/option.md +100 -0
- package/docs/laws/protocol.md +152 -0
- package/docs/laws/result.md +124 -0
- package/docs/laws/validation.md +111 -0
- package/docs/laws/variant.md +251 -0
- package/package.json +87 -3
- package/src/evidence.ts +120 -0
- package/src/identity.ts +129 -0
- package/src/index.ts +54 -0
- package/src/internal/callback.ts +54 -0
- package/src/internal/promise-like.ts +32 -0
- package/src/internal/scalar.ts +43 -0
- package/src/option.ts +214 -0
- package/src/protocol.ts +174 -0
- package/src/result/capture.ts +209 -0
- package/src/result/core.ts +229 -0
- package/src/result/index.ts +3 -0
- package/src/result/throw.ts +13 -0
- package/src/validation.ts +491 -0
- 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.
|