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.
- package/README.md +57 -0
- package/package.json +25 -0
- 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
|
+
}
|