bimorph 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +57 -0
  2. package/package.json +25 -0
  3. package/src/index.ts +1007 -0
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # bimorph
2
+
3
+ A TypeScript library for **bidirectional data mapping**. Define a mapping **once**,
4
+ get both directions for free:
5
+
6
+ - `decode` — external representation → domain value
7
+ - `encode` — domain value → external representation
8
+
9
+ The interesting part is not the happy path (a `Record` already does that). It's
10
+ that **most real mappings do not invert cleanly** — duplicate values, lossy
11
+ transforms, missing keys, read-only fields, and reverses that need runtime context
12
+ the value doesn't carry. bimorph's thesis: *embrace that reality, surface it early,
13
+ and give the caller explicit, legible escape hatches* — rather than pretend every
14
+ mapping is a clean isomorphism.
15
+
16
+ ## Status
17
+
18
+ Design phase + a **typechecked prototype** proving the load-bearing type machinery.
19
+
20
+ Docs:
21
+
22
+ - [`apps/docs/`](apps/docs) — the full documentation site (Fumadocs + Next.js App
23
+ Router). Every `ts twoslash` example is type-checked against `src/index.ts` at
24
+ build time. Run it with `npm run docs`, then open http://localhost:3411.
25
+ - [`docs/DESIGN.md`](docs/DESIGN.md) — principles, contracts, and the API surface.
26
+ - [`docs/SCENARIOS.md`](docs/SCENARIOS.md) — ~28 real-world scenarios gathered to test the design against.
27
+ - [`docs/DOGFOOD.md`](docs/DOGFOOD.md) — the design written against those scenarios, and the gaps it exposed.
28
+
29
+ Prototype:
30
+
31
+ - [`src/index.ts`](src/index.ts) — minimal runtime, but the **real** types: `iso` / `lossy` /
32
+ `partial` / `Enum` (with aliases) / `Struct` / `Field` / `bind`.
33
+
34
+ ```bash
35
+ npm install
36
+ npm run typecheck # tsc --noEmit over src/
37
+ npm run check:runtime # runtime-behaviour regression gate over src/
38
+ npm run docs # run the documentation site (apps/docs) at http://localhost:3411
39
+ ```
40
+
41
+ What the prototype proves compiles (see the MDX for the assertions):
42
+
43
+ 1. **Alias narrowing** — `Enum` decode accepts the *wide* union (primaries + aliases),
44
+ `encode` returns the *narrow* canonical-only union. A migration cannot emit a legacy spelling.
45
+ 2. **Contextual codecs** — a `Ctx` third param whose trailing argument is required
46
+ (`decode(b)` without it is a type error), `.bind(ctx)` erases it back to a plain codec,
47
+ and `Struct` **intersects** the contexts of its fields into one merged bag.
48
+ 3. **`Partial` removes the throwing decode door** at the type level — you're forced to `safeDecode`.
49
+
50
+ ## The one-line motivation
51
+
52
+ ```ts
53
+ // The problem this exists to kill:
54
+ const STATUSES = { 0: "BAD", 1: "OK" };
55
+ const label = STATUSES[0]; // easy
56
+ const value = Object.entries(STATUSES).find(([, v]) => v === "OK")?.[0]; // ugh
57
+ ```
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "bimorph",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Bidirectional data mapping for TypeScript — define a mapping once, get decode and encode, with non-invertibility surfaced in the type.",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "files": ["src", "README.md"],
10
+ "workspaces": [
11
+ "apps/*"
12
+ ],
13
+ "scripts": {
14
+ "typecheck": "tsc --noEmit",
15
+ "check:runtime": "node scripts/check-runtime.mjs",
16
+ "docs": "npm run dev --workspace @bimorph/docs"
17
+ },
18
+ "devDependencies": {
19
+ "@shikijs/twoslash": "^4.3.0",
20
+ "markdown-it": "^14.3.0",
21
+ "shiki": "^4.3.0",
22
+ "twoslash": "latest",
23
+ "typescript": "latest"
24
+ }
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,1007 @@
1
+ /**
2
+ * bimorph prototype.
3
+ *
4
+ * Purpose: prove the two type-level claims from docs/DESIGN.md hold in real TS:
5
+ * 1. Enum alias-narrowing — decode input is the WIDE union (primaries + aliases),
6
+ * encode output is the NARROW union (primaries only).
7
+ * 2. contextual codecs — `Ctx` third param, optional-trailing-arg door signatures,
8
+ * `.bind()` context-erasure, and context INTERSECTION through `Struct`.
9
+ * plus: `Partial` fidelity removes the throwing door at the type level.
10
+ *
11
+ * Runtime is intentionally minimal; the types are the deliverable.
12
+ */
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Result & errors
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export type Result<A, E = DecodeError> =
19
+ | { readonly ok: true; readonly value: A }
20
+ | { readonly ok: false; readonly error: E };
21
+
22
+ export interface DecodeError {
23
+ /** Where the failure happened: `""` for a leaf, `"address.city"` or `"items[2].name"` inside a composite. */
24
+ readonly path: string;
25
+ /** What kind of failure it was — the stable code a thrown `BimorphError` carries, keyed to the Errors & pitfalls docs. */
26
+ readonly code: "miss" | "ambiguous" | "lossy" | "malformed" | "collision";
27
+ /** The value that failed to map, for diagnostics. */
28
+ readonly input: unknown;
29
+ /** A human-readable description of the failure. */
30
+ readonly message: string;
31
+ }
32
+
33
+ /**
34
+ * Accumulated decode failures, keyed by path (`"shipping.postalCode"`, `"[2].name"`).
35
+ * The `validate` door (composites only) returns this instead of failing fast — it maps
36
+ * straight onto form libraries, which key errors by field path.
37
+ */
38
+ export type ErrorTree = { readonly [path: string]: DecodeError };
39
+
40
+ export class BimorphError extends Error {
41
+ constructor(readonly detail: DecodeError) {
42
+ super(detail.message);
43
+ this.name = "BimorphError";
44
+ }
45
+ }
46
+
47
+ function toErr(e: unknown, input: unknown): DecodeError {
48
+ if (e instanceof BimorphError) return e.detail;
49
+ return {
50
+ path: "",
51
+ code: "malformed",
52
+ input,
53
+ message: e instanceof Error ? e.message : String(e),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Prefix a structural segment onto a child error's path. Object field keys and
59
+ * array indices (`"[3]"`) both flow through here so nested paths read like
60
+ * `"address.city"` and `"items[2].postalCode"`.
61
+ */
62
+ function prefixPath(seg: string, childPath: string): string {
63
+ if (childPath === "") return seg;
64
+ return childPath.startsWith("[") ? seg + childPath : seg + "." + childPath;
65
+ }
66
+
67
+ /**
68
+ * Re-home a thrown child error under `seg`, prefixing its path. Re-thrown as a
69
+ * `BimorphError` so an outer composite can prefix again — that recursion is what
70
+ * builds a full `"a.b.c"` path from leaves that only know `path: ""`.
71
+ */
72
+ function prefixError(seg: string, e: unknown, input: unknown): BimorphError {
73
+ const detail = toErr(e, input);
74
+ return new BimorphError({ ...detail, path: prefixPath(seg, detail.path) });
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Codec type — two axes: fidelity tier (F) and context requirement (Ctx)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export type Fidelity = "iso" | "lossy" | "partial";
82
+
83
+ /**
84
+ * Optional trailing context argument. Present only when `Ctx` is a real type —
85
+ * absent for `void` (no context) and for `unknown` (an all-context-free composite,
86
+ * see FieldsCtx). This is what makes `decode(b)` a *type error* on a contextual codec
87
+ * while staying `decode(b)` on a plain one.
88
+ */
89
+ export type CtxArg<Ctx> = [Ctx] extends [void]
90
+ ? []
91
+ : unknown extends Ctx
92
+ ? []
93
+ : [ctx: Ctx];
94
+
95
+ interface SafeDoors<A, BIn, BOut, Ctx, F extends Fidelity> {
96
+ readonly fidelity: F;
97
+ safeDecode(b: BIn, ...c: CtxArg<Ctx>): Result<A>;
98
+ safeEncode(a: A, ...c: CtxArg<Ctx>): Result<BOut>;
99
+ decodeOr(b: BIn, fallback: A, ...c: CtxArg<Ctx>): A;
100
+ encodeOr(a: A, fallback: BOut, ...c: CtxArg<Ctx>): BOut;
101
+ /** Erase the context, yielding an ordinary (composable) codec. */
102
+ bind(ctx: Ctx): CodecFull<A, BIn, BOut, void, F>;
103
+ }
104
+
105
+ interface DecodeThrow<A, BIn, Ctx> {
106
+ decode(b: BIn, ...c: CtxArg<Ctx>): A;
107
+ }
108
+ interface EncodeThrow<A, BOut, Ctx> {
109
+ encode(a: A, ...c: CtxArg<Ctx>): BOut;
110
+ }
111
+
112
+ /**
113
+ * The accumulation door — present only on composites (`Struct`/`List`/`Tuple`), never
114
+ * on leaves (a leaf has one thing to fail, nothing to accumulate). Decode-only: it
115
+ * collects every failing path into an `ErrorTree` instead of failing fast on the first.
116
+ */
117
+ export interface ValidateDoor<A, BIn, Ctx> {
118
+ validate(b: BIn, ...c: CtxArg<Ctx>): Result<A, ErrorTree>;
119
+ }
120
+
121
+ /**
122
+ * Full codec shape with distinct decode-input (`BIn`) and encode-output (`BOut`) —
123
+ * that split is what lets alias-narrowing be expressed. `Partial` fidelity removes
124
+ * the throwing *decode* door (decode is the guesswork direction, per DESIGN §4.1);
125
+ * the clean `encode` throwing door stays, and `safe*` always exist. `unknown` is the
126
+ * intersection identity, so the removed branch adds nothing.
127
+ */
128
+ export type CodecFull<
129
+ A,
130
+ BIn,
131
+ BOut,
132
+ Ctx = void,
133
+ F extends Fidelity = "iso",
134
+ > = SafeDoors<A, BIn, BOut, Ctx, F> &
135
+ EncodeThrow<A, BOut, Ctx> &
136
+ (F extends "partial" ? unknown : DecodeThrow<A, BIn, Ctx>);
137
+
138
+ /** The common symmetric case: decode input === encode output. */
139
+ export type Codec<A, B, Ctx = void, F extends Fidelity = "iso"> = CodecFull<
140
+ A,
141
+ B,
142
+ B,
143
+ Ctx,
144
+ F
145
+ >;
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Codec factory (runtime always carries every door; types hide what shouldn't exist)
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function makeCodec(
152
+ fidelity: Fidelity,
153
+ decodeFn: (b: any, ctx: any) => any,
154
+ encodeFn: (a: any, ctx: any) => any,
155
+ safeDecodeFn: (b: any, ctx: any) => Result<any>,
156
+ validateFn?: (b: any, ctx: any) => Result<any, ErrorTree>,
157
+ ): any {
158
+ const self: any = {
159
+ fidelity,
160
+ decode: (b: any, ctx: any) => decodeFn(b, ctx),
161
+ encode: (a: any, ctx: any) => encodeFn(a, ctx),
162
+ safeDecode: (b: any, ctx: any) => safeDecodeFn(b, ctx),
163
+ safeEncode: (a: any, ctx: any): Result<any> => {
164
+ try {
165
+ return { ok: true, value: encodeFn(a, ctx) };
166
+ } catch (e) {
167
+ return { ok: false, error: toErr(e, a) };
168
+ }
169
+ },
170
+ decodeOr: (b: any, fallback: any, ctx: any) => {
171
+ const r = safeDecodeFn(b, ctx);
172
+ return r.ok ? r.value : fallback;
173
+ },
174
+ encodeOr: (a: any, fallback: any, ctx: any) => {
175
+ try {
176
+ return encodeFn(a, ctx);
177
+ } catch {
178
+ return fallback;
179
+ }
180
+ },
181
+ bind: (ctx: any) =>
182
+ makeCodec(
183
+ fidelity,
184
+ (b: any) => decodeFn(b, ctx),
185
+ (a: any) => encodeFn(a, ctx),
186
+ (b: any) => safeDecodeFn(b, ctx),
187
+ validateFn ? (b: any) => validateFn(b, ctx) : undefined,
188
+ ),
189
+ };
190
+ if (validateFn) self.validate = (b: any, ctx: any) => validateFn(b, ctx);
191
+ return self;
192
+ }
193
+
194
+ /**
195
+ * Accumulate a child's failures into a parent `ErrorTree`, prefixing `seg` onto each
196
+ * path. If the child is itself a composite it exposes `validate` (recursive
197
+ * accumulation); otherwise `safeDecode` yields its single fail-fast error.
198
+ */
199
+ function accumulateChild(
200
+ codec: any,
201
+ value: any,
202
+ ctx: any,
203
+ seg: string,
204
+ out: Record<string, DecodeError>,
205
+ ): { ok: boolean; value?: any } {
206
+ if (typeof codec.validate === "function") {
207
+ const r = codec.validate(value, ctx) as Result<any, ErrorTree>;
208
+ if (r.ok) return { ok: true, value: r.value };
209
+ for (const [p, e] of Object.entries(r.error)) {
210
+ const np = prefixPath(seg, p);
211
+ out[np] = { ...e, path: np };
212
+ }
213
+ return { ok: false };
214
+ }
215
+ const r = codec.safeDecode(value, ctx) as Result<any>;
216
+ if (r.ok) return { ok: true, value: r.value };
217
+ const np = prefixPath(seg, r.error.path);
218
+ out[np] = { ...r.error, path: np };
219
+ return { ok: false };
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Leaf constructors: iso / lossy / partial
224
+ // ---------------------------------------------------------------------------
225
+
226
+ export function iso<A, B, Ctx = void>(spec: {
227
+ decode: (b: B, ctx: Ctx) => A;
228
+ encode: (a: A, ctx: Ctx) => B;
229
+ }): Codec<A, B, Ctx, "iso"> {
230
+ return makeCodec("iso", spec.decode, spec.encode, (b, ctx) => {
231
+ try {
232
+ return { ok: true, value: spec.decode(b, ctx) };
233
+ } catch (e) {
234
+ return { ok: false, error: toErr(e, b) };
235
+ }
236
+ }) as Codec<A, B, Ctx, "iso">;
237
+ }
238
+
239
+ export function lossy<A, B, Ctx = void>(spec: {
240
+ decode: (b: B, ctx: Ctx) => A;
241
+ encode: (a: A, ctx: Ctx) => B;
242
+ }): Codec<A, B, Ctx, "lossy"> {
243
+ return makeCodec("lossy", spec.decode, spec.encode, (b, ctx) => {
244
+ try {
245
+ return { ok: true, value: spec.decode(b, ctx) };
246
+ } catch (e) {
247
+ return { ok: false, error: toErr(e, b) };
248
+ }
249
+ }) as Codec<A, B, Ctx, "lossy">;
250
+ }
251
+
252
+ export function partial<A, B, Ctx = void>(spec: {
253
+ /** Decode may fail on otherwise-valid input, so it returns a Result. */
254
+ decode: (b: B, ctx: Ctx) => Result<A>;
255
+ encode: (a: A, ctx: Ctx) => B;
256
+ }): Codec<A, B, Ctx, "partial"> {
257
+ const throwingDecode = (b: B, ctx: Ctx): A => {
258
+ const r = spec.decode(b, ctx);
259
+ if (r.ok) return r.value;
260
+ throw new BimorphError(r.error);
261
+ };
262
+ return makeCodec(
263
+ "partial",
264
+ throwingDecode,
265
+ spec.encode,
266
+ spec.decode as (b: any, ctx: any) => Result<any>,
267
+ ) as Codec<A, B, Ctx, "partial">;
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Resolver — the recovery primitive behind `recover` (reconcile presets desugar to it)
272
+ // ---------------------------------------------------------------------------
273
+
274
+ /** What a resolver receives to recover a failed mapping — or re-raise the standard error. */
275
+ export interface ResolveContext<In, Out, Ctx = void> {
276
+ readonly direction: "decode" | "encode";
277
+ readonly input: In;
278
+ readonly reason: "miss" | "ambiguous" | "lossy";
279
+ /** Populated when the failure is ambiguity (more than one candidate). */
280
+ readonly candidates?: readonly Out[];
281
+ /** Runtime context, if the codec is contextual; otherwise `undefined`. */
282
+ readonly ctx: Ctx;
283
+ /** Give up and fail with the standard error. */
284
+ raise(): never;
285
+ }
286
+
287
+ /** The primitive all recovery presets desugar to: per-value, typed to the target. */
288
+ export type Resolver<In, Out, Ctx = void> = (c: ResolveContext<In, Out, Ctx>) => Out;
289
+
290
+ /**
291
+ * Creation-time policy electing the canonical wire when two primary entries share a
292
+ * domain value (ambiguous encode). `first-wins`/`last-wins` bake the choice now; a
293
+ * function picks per collision; `throw` (default) keeps the diagnostic loud. The losing
294
+ * wire still *decodes* to the domain value — it becomes an implicit alias.
295
+ *
296
+ * Miss recovery is not here: it splits into a static `default` value and a `recover`
297
+ * slot (`"throw"` shorthand or a resolver). Omit both for the closed-union default,
298
+ * which rejects an out-of-map value at compile time.
299
+ */
300
+ export type Reconcile<Domain> =
301
+ | "throw"
302
+ | "first-wins"
303
+ | "last-wins"
304
+ | ((domain: Domain, wires: readonly PropertyKey[]) => PropertyKey);
305
+
306
+ /** Widen a union of literal keys to their primitive base(s). */
307
+ export type WidenWire<T> =
308
+ | (T extends string ? string : never)
309
+ | (T extends number ? number : never)
310
+ | (T extends symbol ? symbol : never);
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Enum — discrete map: alias-narrowing, default/recover on miss, reconcile on collision
314
+ // ---------------------------------------------------------------------------
315
+
316
+ // Overload A — no recovery: decode input is the CLOSED union (primary | alias wire),
317
+ // so an unmapped value is a compile error.
318
+ export function Enum<
319
+ const E extends readonly (readonly [PropertyKey, unknown])[],
320
+ const AL extends readonly (readonly [PropertyKey, E[number][1]])[] = readonly [],
321
+ >(
322
+ entries: E,
323
+ opts?: {
324
+ readonly aliases?: AL;
325
+ readonly reconcile?: Reconcile<E[number][1]>;
326
+ },
327
+ ): CodecFull<
328
+ E[number][1],
329
+ E[number][0] | AL[number][0],
330
+ E[number][0],
331
+ void,
332
+ "iso"
333
+ >;
334
+ // Overload B — a static `default`: decode input WIDENS to the wire's primitive base,
335
+ // since the point of a default is to accept values not in the map (they become it).
336
+ export function Enum<
337
+ const E extends readonly (readonly [PropertyKey, unknown])[],
338
+ const AL extends readonly (readonly [PropertyKey, E[number][1]])[] = readonly [],
339
+ >(
340
+ entries: E,
341
+ opts: {
342
+ readonly aliases?: AL;
343
+ readonly default: E[number][1];
344
+ readonly reconcile?: Reconcile<E[number][1]>;
345
+ },
346
+ ): CodecFull<
347
+ E[number][1],
348
+ WidenWire<E[number][0] | AL[number][0]>,
349
+ E[number][0],
350
+ void,
351
+ "iso"
352
+ >;
353
+ // Overload C — `recover`: widened input, either the `"throw"` shorthand (re-raise) or a
354
+ // resolver, optionally contextual (a resolver typed with `ctx` makes decode require it).
355
+ // The resolver is a bare type so its return literal stays contextually typed to the domain.
356
+ export function Enum<
357
+ const E extends readonly (readonly [PropertyKey, unknown])[],
358
+ const AL extends readonly (readonly [PropertyKey, E[number][1]])[] = readonly [],
359
+ Ctx = void,
360
+ >(
361
+ entries: E,
362
+ opts: {
363
+ readonly aliases?: AL;
364
+ readonly recover: "throw" | Resolver<WidenWire<E[number][0] | AL[number][0]>, E[number][1], Ctx>;
365
+ readonly reconcile?: Reconcile<E[number][1]>;
366
+ },
367
+ ): CodecFull<
368
+ E[number][1],
369
+ WidenWire<E[number][0] | AL[number][0]>,
370
+ E[number][0],
371
+ Ctx,
372
+ "iso"
373
+ >;
374
+ export function Enum(
375
+ entries: readonly (readonly [PropertyKey, unknown])[],
376
+ opts?: {
377
+ readonly aliases?: readonly (readonly [PropertyKey, unknown])[];
378
+ readonly default?: unknown;
379
+ readonly recover?: "throw" | Resolver<any, any, any>;
380
+ readonly reconcile?: Reconcile<any>;
381
+ },
382
+ ): any {
383
+ const decodeMap = new Map<PropertyKey, unknown>();
384
+ const encodeMap = new Map<unknown, PropertyKey>();
385
+ const reconcile = opts?.reconcile ?? "throw";
386
+
387
+ const ambiguousDecode = (wire: PropertyKey): BimorphError =>
388
+ new BimorphError({
389
+ path: "",
390
+ code: "ambiguous",
391
+ input: wire,
392
+ message: `wire ${String(wire)} decodes to two different domain values`,
393
+ });
394
+
395
+ for (const [wire, domain] of entries) {
396
+ if (decodeMap.has(wire) && decodeMap.get(wire) !== domain) throw ambiguousDecode(wire);
397
+ decodeMap.set(wire, domain);
398
+
399
+ if (!encodeMap.has(domain)) {
400
+ encodeMap.set(domain, wire);
401
+ } else if (reconcile === "throw") {
402
+ throw new BimorphError({
403
+ path: "",
404
+ code: "collision",
405
+ input: domain,
406
+ message: `duplicate domain value ${String(domain)} — set reconcile, or declare one wire as an alias`,
407
+ });
408
+ } else if (reconcile === "last-wins") {
409
+ encodeMap.set(domain, wire);
410
+ } else if (typeof reconcile === "function") {
411
+ encodeMap.set(domain, reconcile(domain as any, [encodeMap.get(domain)!, wire]));
412
+ }
413
+ // "first-wins": keep the existing encode target; the new wire still decodes.
414
+ }
415
+
416
+ for (const [wire, domain] of opts?.aliases ?? []) {
417
+ if (decodeMap.has(wire) && decodeMap.get(wire) !== domain) throw ambiguousDecode(wire);
418
+ // decode-only: aliases populate the decode map but NOT the encode map.
419
+ decodeMap.set(wire, domain);
420
+ }
421
+
422
+ const recover = opts?.recover;
423
+ const hasDefault = !!opts && "default" in opts;
424
+ const defaultValue = opts?.default;
425
+ const missError = (input: unknown, direction: "decode" | "encode"): DecodeError => ({
426
+ path: "",
427
+ code: "miss",
428
+ input,
429
+ message:
430
+ direction === "decode"
431
+ ? `no mapping for wire ${String(input)}`
432
+ : `no canonical wire for domain value ${String(input)}`,
433
+ });
434
+
435
+ const decodeFn = (b: PropertyKey, ctx: any): unknown => {
436
+ if (decodeMap.has(b)) return decodeMap.get(b);
437
+ if (recover === "throw") throw new BimorphError(missError(b, "decode"));
438
+ if (typeof recover === "function") {
439
+ return recover({
440
+ direction: "decode",
441
+ input: b as any,
442
+ reason: "miss",
443
+ ctx,
444
+ raise() {
445
+ throw new BimorphError(missError(b, "decode"));
446
+ },
447
+ });
448
+ }
449
+ if (hasDefault) return defaultValue;
450
+ throw new BimorphError(missError(b, "decode"));
451
+ };
452
+ const encodeFn = (a: unknown): PropertyKey => {
453
+ if (!encodeMap.has(a)) throw new BimorphError(missError(a, "encode"));
454
+ return encodeMap.get(a)!;
455
+ };
456
+
457
+ return makeCodec("iso", decodeFn, encodeFn, (b, ctx) => {
458
+ try {
459
+ return { ok: true, value: decodeFn(b, ctx) };
460
+ } catch (e) {
461
+ return { ok: false, error: toErr(e, b) };
462
+ }
463
+ });
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Struct / Field — struct mapping with rename, encode-omit, and Ctx intersection
468
+ // ---------------------------------------------------------------------------
469
+
470
+ /** How a field participates in encode output. */
471
+ export type OmitMode = "no" | "always" | "if";
472
+
473
+ export interface Field<
474
+ WK extends string,
475
+ A,
476
+ BIn,
477
+ BOut,
478
+ Ctx,
479
+ F extends Fidelity,
480
+ OmitEnc extends OmitMode,
481
+ > {
482
+ readonly wireKey: WK;
483
+ readonly codec: CodecFull<A, BIn, BOut, Ctx, F>;
484
+ readonly omit: OmitEnc;
485
+ /** Predicate for `omit: "if"` — encode drops the field when it returns true. */
486
+ readonly omitWhen?: (a: A) => boolean;
487
+ }
488
+
489
+ type AnyField = Field<string, any, any, any, any, Fidelity, OmitMode>;
490
+
491
+ /**
492
+ * N wire fields ↔ 1 domain field. The inner codec maps the *sub-object* of the grouped
493
+ * wire keys ↔ the single domain value (e.g. three booleans `{public,private,unlisted}`
494
+ * ↔ a `visibility` enum). Placed in `Struct` like a field, but contributes all its wire
495
+ * keys to the composite wire shape.
496
+ */
497
+ export interface GroupField<WK extends string, A, BSub, Ctx, F extends Fidelity> {
498
+ readonly _group: true;
499
+ readonly wireKeys: readonly WK[];
500
+ readonly codec: CodecFull<A, BSub, BSub, Ctx, F>;
501
+ }
502
+
503
+ type AnyGroupField = GroupField<string, any, any, any, Fidelity>;
504
+ type AnyFieldLike = AnyField | AnyGroupField;
505
+
506
+ /** Flatten an intersection of object types into a single object type (nicer hovers). */
507
+ type Simplify<T> = { [K in keyof T]: T[K] };
508
+
509
+ /**
510
+ * `encode: "omit"` drops the field unconditionally (read-only wire fields), so it
511
+ * vanishes from the wire-out type. `encode: "omit-if"` drops it only when `when(value)`
512
+ * holds — e.g. drop `page=1` defaults from a URL — so the field stays *optional* in the
513
+ * wire-out type. Overloaded so `when`'s parameter is contextually typed to the field's
514
+ * domain value `A`.
515
+ */
516
+ export function Field<WK extends string, A, BIn, BOut, Ctx, F extends Fidelity>(
517
+ wireKey: WK,
518
+ codec: CodecFull<A, BIn, BOut, Ctx, F>,
519
+ ): Field<WK, A, BIn, BOut, Ctx, F, "no">;
520
+ export function Field<WK extends string, A, BIn, BOut, Ctx, F extends Fidelity>(
521
+ wireKey: WK,
522
+ codec: CodecFull<A, BIn, BOut, Ctx, F>,
523
+ opts: { readonly encode: "omit" },
524
+ ): Field<WK, A, BIn, BOut, Ctx, F, "always">;
525
+ export function Field<WK extends string, A, BIn, BOut, Ctx, F extends Fidelity>(
526
+ wireKey: WK,
527
+ codec: CodecFull<A, BIn, BOut, Ctx, F>,
528
+ opts: { readonly encode: "omit-if"; readonly when: (a: A) => boolean },
529
+ ): Field<WK, A, BIn, BOut, Ctx, F, "if">;
530
+ export function Field(
531
+ wireKey: string,
532
+ codec: CodecFull<any, any, any, any, Fidelity>,
533
+ opts?: { readonly encode?: "omit" | "omit-if"; readonly when?: (a: any) => boolean },
534
+ ): AnyField {
535
+ const mode: OmitMode =
536
+ opts?.encode === "omit-if" ? "if" : opts?.encode === "omit" ? "always" : "no";
537
+ return { wireKey, codec, omit: mode, omitWhen: opts?.when };
538
+ }
539
+
540
+ /**
541
+ * Collapse N wire fields into one domain field (Gap 5 / scenario B4). The inner codec
542
+ * maps the sub-object of the grouped wire keys ↔ the single domain value; invalid Side-B
543
+ * states (all-false, two-true) that no creation diagnostic can catch are the codec's job
544
+ * (a resolver or `partial` decode).
545
+ */
546
+ export function Group<
547
+ const WK extends readonly string[],
548
+ A,
549
+ BSub extends Record<WK[number], unknown>,
550
+ Ctx = void,
551
+ F extends Fidelity = "iso",
552
+ >(
553
+ wireKeys: WK,
554
+ codec: CodecFull<A, BSub, BSub, Ctx, F>,
555
+ ): GroupField<WK[number], A, BSub, Ctx, F> {
556
+ return { _group: true, wireKeys, codec };
557
+ }
558
+
559
+ type UnionToIntersection<U> = (
560
+ U extends any ? (k: U) => void : never
561
+ ) extends (k: infer I) => void
562
+ ? I
563
+ : never;
564
+
565
+ // --- Per-entry extraction: each helper handles a Field OR a GroupField. -------
566
+
567
+ type EntryDomain<Fdef> = Fdef extends Field<any, infer A, any, any, any, any, any>
568
+ ? A
569
+ : Fdef extends GroupField<any, infer A, any, any, any>
570
+ ? A
571
+ : never;
572
+
573
+ type EntryCtx<Fdef> = Fdef extends Field<any, any, any, any, infer C, any, any>
574
+ ? [C] extends [void]
575
+ ? never
576
+ : C
577
+ : Fdef extends GroupField<any, any, any, infer C, any>
578
+ ? [C] extends [void]
579
+ ? never
580
+ : C
581
+ : never;
582
+
583
+ type EntryFidelity<Fdef> = Fdef extends Field<any, any, any, any, any, infer F, any>
584
+ ? F
585
+ : Fdef extends GroupField<any, any, any, any, infer F>
586
+ ? F
587
+ : never;
588
+
589
+ /** Wire-in contribution: one key for a Field, the whole sub-object for a GroupField. */
590
+ type EntryWireIn<Fdef> = Fdef extends Field<infer WK, any, infer BIn, any, any, any, any>
591
+ ? { [P in WK]: BIn }
592
+ : Fdef extends GroupField<any, any, infer BSub, any, any>
593
+ ? BSub
594
+ : {};
595
+
596
+ /** Required wire-out contribution: `omit:"no"` fields and groups; `{}` otherwise. */
597
+ type EntryWireOutReq<Fdef> = Fdef extends Field<
598
+ infer WK,
599
+ any,
600
+ any,
601
+ infer BOut,
602
+ any,
603
+ any,
604
+ infer O
605
+ >
606
+ ? O extends "no"
607
+ ? { [P in WK]: BOut }
608
+ : {}
609
+ : Fdef extends GroupField<any, any, infer BSub, any, any>
610
+ ? BSub
611
+ : {};
612
+
613
+ /** Optional wire-out contribution: `omit:"if"` fields become optional keys. */
614
+ type EntryWireOutOpt<Fdef> = Fdef extends Field<
615
+ infer WK,
616
+ any,
617
+ any,
618
+ infer BOut,
619
+ any,
620
+ any,
621
+ infer O
622
+ >
623
+ ? O extends "if"
624
+ ? { [P in WK]?: BOut }
625
+ : {}
626
+ : {};
627
+
628
+ type DomainOf<Fields extends Record<string, AnyFieldLike>> = {
629
+ [K in keyof Fields]: EntryDomain<Fields[K]>;
630
+ };
631
+
632
+ /** Intersection of every non-void field/group context; `unknown` when there are none. */
633
+ type FieldsCtx<Fields extends Record<string, AnyFieldLike>> = UnionToIntersection<
634
+ { [K in keyof Fields]: EntryCtx<Fields[K]> }[keyof Fields]
635
+ >;
636
+
637
+ /** Wire shape for decode: merge of every entry's wire-in contribution. */
638
+ type WireIn<Fields extends Record<string, AnyFieldLike>> = Simplify<
639
+ UnionToIntersection<{ [K in keyof Fields]: EntryWireIn<Fields[K]> }[keyof Fields]>
640
+ >;
641
+
642
+ /**
643
+ * Wire shape for encode: merge of every entry's contribution. `omit:"always"` fields
644
+ * contribute nothing; `omit:"if"` fields contribute an *optional* key; groups contribute
645
+ * their whole sub-object.
646
+ */
647
+ type WireOut<Fields extends Record<string, AnyFieldLike>> = Simplify<
648
+ UnionToIntersection<{ [K in keyof Fields]: EntryWireOutReq<Fields[K]> }[keyof Fields]> &
649
+ UnionToIntersection<{ [K in keyof Fields]: EntryWireOutOpt<Fields[K]> }[keyof Fields]>
650
+ >;
651
+
652
+ type FieldsFidelity<Fields extends Record<string, AnyFieldLike>> = {
653
+ [K in keyof Fields]: EntryFidelity<Fields[K]>;
654
+ }[keyof Fields] extends infer U
655
+ ? "partial" extends U
656
+ ? "partial"
657
+ : "lossy" extends U
658
+ ? "lossy"
659
+ : "iso"
660
+ : never;
661
+
662
+ const isGroup = (f: AnyFieldLike): f is AnyGroupField =>
663
+ (f as AnyGroupField)._group === true;
664
+
665
+ /** Gather a group's wire keys out of the parent wire into its sub-object. */
666
+ function pickSub(wire: any, wireKeys: readonly string[]): any {
667
+ const sub: any = {};
668
+ for (const k of wireKeys) sub[k] = wire?.[k];
669
+ return sub;
670
+ }
671
+
672
+ export function Struct<Fields extends Record<string, AnyFieldLike>>(
673
+ fields: Fields,
674
+ ): CodecFull<
675
+ DomainOf<Fields>,
676
+ WireIn<Fields>,
677
+ WireOut<Fields>,
678
+ FieldsCtx<Fields>,
679
+ FieldsFidelity<Fields>
680
+ > &
681
+ ValidateDoor<DomainOf<Fields>, WireIn<Fields>, FieldsCtx<Fields>> {
682
+ const entries = Object.entries(fields) as [string, AnyFieldLike][];
683
+
684
+ const decodeFn = (wire: any, ctx: any): any => {
685
+ const out: any = {};
686
+ for (const [domainKey, f] of entries) {
687
+ const input = isGroup(f) ? pickSub(wire, f.wireKeys) : wire?.[f.wireKey];
688
+ try {
689
+ out[domainKey] = (f.codec as any).decode(input, ctx);
690
+ } catch (e) {
691
+ throw prefixError(domainKey, e, input);
692
+ }
693
+ }
694
+ return out;
695
+ };
696
+ const encodeFn = (dom: any, ctx: any): any => {
697
+ const out: any = {};
698
+ for (const [domainKey, f] of entries) {
699
+ if (!isGroup(f)) {
700
+ if (f.omit === "always") continue;
701
+ if (f.omit === "if" && f.omitWhen?.(dom[domainKey])) continue;
702
+ }
703
+ try {
704
+ if (isGroup(f)) {
705
+ Object.assign(out, (f.codec as any).encode(dom[domainKey], ctx));
706
+ } else {
707
+ out[f.wireKey] = (f.codec as any).encode(dom[domainKey], ctx);
708
+ }
709
+ } catch (e) {
710
+ throw prefixError(domainKey, e, dom?.[domainKey]);
711
+ }
712
+ }
713
+ return out;
714
+ };
715
+ const safeDecodeFn = (wire: any, ctx: any): Result<any> => {
716
+ try {
717
+ return { ok: true, value: decodeFn(wire, ctx) };
718
+ } catch (e) {
719
+ return { ok: false, error: toErr(e, wire) };
720
+ }
721
+ };
722
+ const validateFn = (wire: any, ctx: any): Result<any, ErrorTree> => {
723
+ const out: any = {};
724
+ const errors: Record<string, DecodeError> = {};
725
+ for (const [domainKey, f] of entries) {
726
+ const input = isGroup(f) ? pickSub(wire, f.wireKeys) : wire?.[f.wireKey];
727
+ const r = accumulateChild(f.codec, input, ctx, domainKey, errors);
728
+ if (r.ok) out[domainKey] = r.value;
729
+ }
730
+ return Object.keys(errors).length
731
+ ? { ok: false, error: errors }
732
+ : { ok: true, value: out };
733
+ };
734
+
735
+ const fid: Fidelity = entries.some(([, f]) => f.codec.fidelity === "partial")
736
+ ? "partial"
737
+ : entries.some(([, f]) => f.codec.fidelity === "lossy")
738
+ ? "lossy"
739
+ : "iso";
740
+
741
+ return makeCodec(fid, decodeFn, encodeFn, safeDecodeFn, validateFn);
742
+ }
743
+
744
+ // ---------------------------------------------------------------------------
745
+ // pipe — compose two codecs, weakening fidelity and intersecting context
746
+ // ---------------------------------------------------------------------------
747
+
748
+ type Weakest<F1 extends Fidelity, F2 extends Fidelity> = "partial" extends
749
+ | F1
750
+ | F2
751
+ ? "partial"
752
+ : "lossy" extends F1 | F2
753
+ ? "lossy"
754
+ : "iso";
755
+
756
+ type MergeCtx<C1, C2> = [C1] extends [void]
757
+ ? C2
758
+ : [C2] extends [void]
759
+ ? C1
760
+ : C1 & C2;
761
+
762
+ /**
763
+ * pipe(a, b): `a` maps wire `BIn`/`BOut` ↔ mid `M`, `b` maps mid `M` ↔ domain `A`.
764
+ * decode flows BIn → M → A; encode flows A → M → BOut.
765
+ * Result fidelity is the *weaker* of the two; result context is the *intersection*.
766
+ *
767
+ * `a` (the wire-facing codec) may be **asymmetric** — a wide decode input and a narrow
768
+ * encode output, as produced by an aliased `Enum` — and that split propagates to the
769
+ * composed codec's `BIn`/`BOut` instead of collapsing to one type. The mid `M` is `a`'s
770
+ * domain and `b`'s wire, kept symmetric; piping *into* a wire-asymmetric `b` (e.g. an
771
+ * `encode:"omit"` Struct) is intentionally a type error rather than a silent collapse.
772
+ */
773
+ export function pipe<
774
+ A,
775
+ M,
776
+ BIn,
777
+ BOut,
778
+ C1,
779
+ C2,
780
+ F1 extends Fidelity,
781
+ F2 extends Fidelity,
782
+ >(
783
+ a: CodecFull<M, BIn, BOut, C1, F1>,
784
+ b: CodecFull<A, M, M, C2, F2>,
785
+ ): CodecFull<A, BIn, BOut, MergeCtx<C1, C2>, Weakest<F1, F2>> {
786
+ const av = a as any;
787
+ const bv = b as any;
788
+ const fid: Fidelity =
789
+ av.fidelity === "partial" || bv.fidelity === "partial"
790
+ ? "partial"
791
+ : av.fidelity === "lossy" || bv.fidelity === "lossy"
792
+ ? "lossy"
793
+ : "iso";
794
+
795
+ const decodeFn = (x: any, ctx: any) => bv.decode(av.decode(x, ctx), ctx);
796
+ const encodeFn = (x: any, ctx: any) => av.encode(bv.encode(x, ctx), ctx);
797
+ const safeDecodeFn = (x: any, ctx: any): Result<any> => {
798
+ const r1 = av.safeDecode(x, ctx);
799
+ if (!r1.ok) return r1;
800
+ return bv.safeDecode(r1.value, ctx);
801
+ };
802
+
803
+ return makeCodec(fid, decodeFn, encodeFn, safeDecodeFn) as CodecFull<
804
+ A,
805
+ BIn,
806
+ BOut,
807
+ MergeCtx<C1, C2>,
808
+ Weakest<F1, F2>
809
+ >;
810
+ }
811
+
812
+ // ---------------------------------------------------------------------------
813
+ // Nullable — null/absent <-> undefined normalization
814
+ // ---------------------------------------------------------------------------
815
+
816
+ /**
817
+ * Wrap a codec so a `null` or absent wire value decodes to `undefined`, and an
818
+ * `undefined` domain value encodes back to `null`. Fidelity and context pass through.
819
+ */
820
+ export function Nullable<A, BIn, BOut, Ctx, F extends Fidelity>(
821
+ codec: CodecFull<A, BIn, BOut, Ctx, F>,
822
+ ): CodecFull<A | undefined, BIn | null | undefined, BOut | null, Ctx, F> {
823
+ const c = codec as any;
824
+ const empty = (b: any) => b === null || b === undefined;
825
+ const decodeFn = (b: any, ctx: any) => (empty(b) ? undefined : c.decode(b, ctx));
826
+ const encodeFn = (a: any, ctx: any) => (a === undefined ? null : c.encode(a, ctx));
827
+ const safeDecodeFn = (b: any, ctx: any): Result<any> =>
828
+ empty(b) ? { ok: true, value: undefined } : c.safeDecode(b, ctx);
829
+ return makeCodec(c.fidelity, decodeFn, encodeFn, safeDecodeFn) as any;
830
+ }
831
+
832
+ // ---------------------------------------------------------------------------
833
+ // List / Tuple — collections with per-index path segments
834
+ // ---------------------------------------------------------------------------
835
+
836
+ /** Homogeneous list: each element runs through `codec`; errors carry a `[i]` segment. */
837
+ export function List<A, BIn, BOut, Ctx, F extends Fidelity>(
838
+ codec: CodecFull<A, BIn, BOut, Ctx, F>,
839
+ ): CodecFull<A[], BIn[], BOut[], Ctx, F> & ValidateDoor<A[], BIn[], Ctx> {
840
+ const c = codec as any;
841
+ const decodeFn = (arr: any[], ctx: any) =>
842
+ arr.map((el, i) => {
843
+ try {
844
+ return c.decode(el, ctx);
845
+ } catch (e) {
846
+ throw prefixError(`[${i}]`, e, el);
847
+ }
848
+ });
849
+ const encodeFn = (arr: any[], ctx: any) =>
850
+ arr.map((el, i) => {
851
+ try {
852
+ return c.encode(el, ctx);
853
+ } catch (e) {
854
+ throw prefixError(`[${i}]`, e, el);
855
+ }
856
+ });
857
+ const safeDecodeFn = (arr: any, ctx: any): Result<any> => {
858
+ try {
859
+ return { ok: true, value: decodeFn(arr, ctx) };
860
+ } catch (e) {
861
+ return { ok: false, error: toErr(e, arr) };
862
+ }
863
+ };
864
+ const validateFn = (arr: any[], ctx: any): Result<any, ErrorTree> => {
865
+ const out: any[] = [];
866
+ const errors: Record<string, DecodeError> = {};
867
+ arr.forEach((el, i) => {
868
+ const r = accumulateChild(c, el, ctx, `[${i}]`, errors);
869
+ if (r.ok) out[i] = r.value;
870
+ });
871
+ return Object.keys(errors).length
872
+ ? { ok: false, error: errors }
873
+ : { ok: true, value: out };
874
+ };
875
+ return makeCodec(c.fidelity, decodeFn, encodeFn, safeDecodeFn, validateFn) as any;
876
+ }
877
+
878
+ type AnyCodec = CodecFull<any, any, any, any, Fidelity>;
879
+
880
+ type DomainTuple<C extends readonly AnyCodec[]> = {
881
+ [K in keyof C]: C[K] extends CodecFull<infer A, any, any, any, any> ? A : never;
882
+ };
883
+ type WireInTuple<C extends readonly AnyCodec[]> = {
884
+ [K in keyof C]: C[K] extends CodecFull<any, infer BIn, any, any, any> ? BIn : never;
885
+ };
886
+ type WireOutTuple<C extends readonly AnyCodec[]> = {
887
+ [K in keyof C]: C[K] extends CodecFull<any, any, infer BOut, any, any> ? BOut : never;
888
+ };
889
+ type CtxTuple<C extends readonly AnyCodec[]> = UnionToIntersection<
890
+ {
891
+ [K in keyof C]: C[K] extends CodecFull<any, any, any, infer Ctx, any>
892
+ ? [Ctx] extends [void]
893
+ ? never
894
+ : Ctx
895
+ : never;
896
+ }[number]
897
+ >;
898
+ type FidelityTuple<C extends readonly AnyCodec[]> = {
899
+ [K in keyof C]: C[K] extends CodecFull<any, any, any, any, infer F> ? F : never;
900
+ }[number] extends infer U
901
+ ? "partial" extends U
902
+ ? "partial"
903
+ : "lossy" extends U
904
+ ? "lossy"
905
+ : "iso"
906
+ : never;
907
+
908
+ /** Fixed-length heterogeneous list: element `i` runs through `codecs[i]`. */
909
+ export function Tuple<const C extends readonly AnyCodec[]>(
910
+ ...codecs: C
911
+ ): CodecFull<
912
+ DomainTuple<C>,
913
+ WireInTuple<C>,
914
+ WireOutTuple<C>,
915
+ CtxTuple<C>,
916
+ FidelityTuple<C>
917
+ > &
918
+ ValidateDoor<DomainTuple<C>, WireInTuple<C>, CtxTuple<C>> {
919
+ const cs = codecs as readonly any[];
920
+ const decodeFn = (arr: any[], ctx: any) =>
921
+ cs.map((c, i) => {
922
+ try {
923
+ return c.decode(arr[i], ctx);
924
+ } catch (e) {
925
+ throw prefixError(`[${i}]`, e, arr?.[i]);
926
+ }
927
+ });
928
+ const encodeFn = (arr: any[], ctx: any) =>
929
+ cs.map((c, i) => {
930
+ try {
931
+ return c.encode(arr[i], ctx);
932
+ } catch (e) {
933
+ throw prefixError(`[${i}]`, e, arr?.[i]);
934
+ }
935
+ });
936
+ const safeDecodeFn = (arr: any, ctx: any): Result<any> => {
937
+ try {
938
+ return { ok: true, value: decodeFn(arr, ctx) };
939
+ } catch (e) {
940
+ return { ok: false, error: toErr(e, arr) };
941
+ }
942
+ };
943
+ const validateFn = (arr: any[], ctx: any): Result<any, ErrorTree> => {
944
+ const out: any[] = [];
945
+ const errors: Record<string, DecodeError> = {};
946
+ cs.forEach((c, i) => {
947
+ const r = accumulateChild(c, arr?.[i], ctx, `[${i}]`, errors);
948
+ if (r.ok) out[i] = r.value;
949
+ });
950
+ return Object.keys(errors).length
951
+ ? { ok: false, error: errors }
952
+ : { ok: true, value: out };
953
+ };
954
+ const fid: Fidelity = cs.some((c) => c.fidelity === "partial")
955
+ ? "partial"
956
+ : cs.some((c) => c.fidelity === "lossy")
957
+ ? "lossy"
958
+ : "iso";
959
+ return makeCodec(fid, decodeFn, encodeFn, safeDecodeFn, validateFn) as any;
960
+ }
961
+
962
+ // ---------------------------------------------------------------------------
963
+ // assertRoundTrip — test helper: the teeth behind the `lossy` tier
964
+ // ---------------------------------------------------------------------------
965
+
966
+ function deepEqual(a: unknown, b: unknown): boolean {
967
+ if (Object.is(a, b)) return true;
968
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
969
+ return false;
970
+ if (Array.isArray(a) || Array.isArray(b)) {
971
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
972
+ return a.every((x, i) => deepEqual(x, b[i]));
973
+ }
974
+ const ka = Object.keys(a as object);
975
+ const kb = Object.keys(b as object);
976
+ if (ka.length !== kb.length) return false;
977
+ return ka.every(
978
+ (k) =>
979
+ Object.prototype.hasOwnProperty.call(b, k) &&
980
+ deepEqual((a as any)[k], (b as any)[k]),
981
+ );
982
+ }
983
+
984
+ /**
985
+ * Round-trip each wire sample through `decode` then `encode` and assert the result
986
+ * is structurally equal to the input. This is the *only* enforcement behind the
987
+ * `lossy` tier — precision loss is data-dependent and can't be caught at creation,
988
+ * so it belongs in your test suite. Throws a `BimorphError` (code `"lossy"`) on drift.
989
+ */
990
+ export function assertRoundTrip<A, BIn, BOut extends BIn, Ctx, F extends Fidelity>(
991
+ codec: CodecFull<A, BIn, BOut, Ctx, F>,
992
+ samples: readonly BIn[],
993
+ ...ctx: CtxArg<Ctx>
994
+ ): void {
995
+ const c = codec as any;
996
+ for (const sample of samples) {
997
+ const roundTripped = c.encode(c.decode(sample, ctx[0]), ctx[0]);
998
+ if (!deepEqual(sample, roundTripped)) {
999
+ throw new BimorphError({
1000
+ path: "",
1001
+ code: "lossy",
1002
+ input: sample,
1003
+ message: `round-trip drift: ${JSON.stringify(sample)} → ${JSON.stringify(roundTripped)}`,
1004
+ });
1005
+ }
1006
+ }
1007
+ }