shapedef 1.0.21 → 1.0.23

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 (40) hide show
  1. package/dist/cjs/inputs/json-schema.d.ts.map +1 -1
  2. package/dist/cjs/inputs/json-schema.js +8 -0
  3. package/dist/cjs/inputs/json-schema.js.map +1 -1
  4. package/dist/cjs/shape.d.ts +5 -1
  5. package/dist/cjs/shape.d.ts.map +1 -1
  6. package/dist/cjs/shape.js +122 -0
  7. package/dist/cjs/shape.js.map +1 -1
  8. package/dist/cjs/translations/postgres.d.ts.map +1 -1
  9. package/dist/cjs/translations/postgres.js +2 -0
  10. package/dist/cjs/translations/postgres.js.map +1 -1
  11. package/dist/cjs/translations/typescript.d.ts.map +1 -1
  12. package/dist/cjs/translations/typescript.js +2 -0
  13. package/dist/cjs/translations/typescript.js.map +1 -1
  14. package/dist/cjs/validation.d.ts.map +1 -1
  15. package/dist/cjs/validation.js +2 -0
  16. package/dist/cjs/validation.js.map +1 -1
  17. package/dist/esm/inputs/json-schema.d.ts.map +1 -1
  18. package/dist/esm/inputs/json-schema.js +8 -0
  19. package/dist/esm/inputs/json-schema.js.map +1 -1
  20. package/dist/esm/shape.d.ts +5 -1
  21. package/dist/esm/shape.d.ts.map +1 -1
  22. package/dist/esm/shape.js +122 -0
  23. package/dist/esm/shape.js.map +1 -1
  24. package/dist/esm/translations/postgres.d.ts.map +1 -1
  25. package/dist/esm/translations/postgres.js +2 -0
  26. package/dist/esm/translations/postgres.js.map +1 -1
  27. package/dist/esm/translations/typescript.d.ts.map +1 -1
  28. package/dist/esm/translations/typescript.js +2 -0
  29. package/dist/esm/translations/typescript.js.map +1 -1
  30. package/dist/esm/validation.d.ts.map +1 -1
  31. package/dist/esm/validation.js +2 -0
  32. package/dist/esm/validation.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/inputs/json-schema.ts +8 -0
  35. package/src/shape.coerce.test.ts +285 -0
  36. package/src/shape.ts +123 -0
  37. package/src/translations/postgres.ts +2 -0
  38. package/src/translations/typescript.test.ts +1 -0
  39. package/src/translations/typescript.ts +2 -0
  40. package/src/validation.ts +2 -0
@@ -0,0 +1,285 @@
1
+ import { describe, expect } from "vitest";
2
+ import { annotate, shapes } from "./shape";
3
+
4
+ describe("annotate.coerce", (it) => {
5
+ // ── primitives ──────────────────────────────────────────────────────────
6
+
7
+ it("coerces str: passthrough for strings", () => {
8
+ expect(annotate.coerce("hello", shapes.str())).toBe("hello");
9
+ });
10
+
11
+ it("coerces str: converts number to string", () => {
12
+ expect(annotate.coerce(42, shapes.str())).toBe("42");
13
+ });
14
+
15
+ it("coerces str: converts boolean to string", () => {
16
+ expect(annotate.coerce(true, shapes.str())).toBe("true");
17
+ });
18
+
19
+ it("coerces str: converts null to string", () => {
20
+ expect(annotate.coerce(null, shapes.str())).toBe("null");
21
+ });
22
+
23
+ it("coerces number: passthrough for numbers", () => {
24
+ expect(annotate.coerce(3.14, shapes.number())).toBe(3.14);
25
+ expect(annotate.coerce(0, shapes.number())).toBe(0);
26
+ });
27
+
28
+ it("coerces number: converts numeric string", () => {
29
+ expect(annotate.coerce("3.14", shapes.number())).toBe(3.14);
30
+ expect(annotate.coerce("", shapes.number())).toBe(0);
31
+ });
32
+
33
+ it("coerces number: returns 0 for non-numeric input", () => {
34
+ expect(annotate.coerce(null, shapes.number())).toBe(0);
35
+ expect(annotate.coerce(false, shapes.number())).toBe(0);
36
+ });
37
+
38
+ it("coerces float: same rules as number", () => {
39
+ expect(annotate.coerce(1.5, shapes.float())).toBe(1.5);
40
+ expect(annotate.coerce("2.5", shapes.float())).toBe(2.5);
41
+ expect(annotate.coerce(null, shapes.float())).toBe(0);
42
+ });
43
+
44
+ it("coerces int: truncates floats", () => {
45
+ expect(annotate.coerce(3.9, shapes.int())).toBe(3);
46
+ expect(annotate.coerce(-3.9, shapes.int())).toBe(-3);
47
+ });
48
+
49
+ it("coerces int: converts numeric string", () => {
50
+ expect(annotate.coerce("7.8", shapes.int())).toBe(7);
51
+ expect(annotate.coerce("", shapes.int())).toBe(0);
52
+ });
53
+
54
+ it("coerces int: returns 0 for non-numeric input", () => {
55
+ expect(annotate.coerce(null, shapes.int())).toBe(0);
56
+ });
57
+
58
+ it("coerces bool: passthrough for booleans", () => {
59
+ expect(annotate.coerce(true, shapes.bool())).toBe(true);
60
+ expect(annotate.coerce(false, shapes.bool())).toBe(false);
61
+ });
62
+
63
+ it("coerces bool: truthy string values", () => {
64
+ expect(annotate.coerce("true", shapes.bool())).toBe(true);
65
+ expect(annotate.coerce("1", shapes.bool())).toBe(true);
66
+ expect(annotate.coerce("yes", shapes.bool())).toBe(true);
67
+ expect(annotate.coerce("TRUE", shapes.bool())).toBe(true);
68
+ });
69
+
70
+ it("coerces bool: falsy string values", () => {
71
+ expect(annotate.coerce("false", shapes.bool())).toBe(false);
72
+ expect(annotate.coerce("no", shapes.bool())).toBe(false);
73
+ expect(annotate.coerce("0", shapes.bool())).toBe(false);
74
+ expect(annotate.coerce("", shapes.bool())).toBe(false);
75
+ });
76
+
77
+ it("coerces bool: truthy/falsy non-string values", () => {
78
+ expect(annotate.coerce(1, shapes.bool())).toBe(true);
79
+ expect(annotate.coerce(0, shapes.bool())).toBe(false);
80
+ expect(annotate.coerce(null, shapes.bool())).toBe(false);
81
+ });
82
+
83
+ it("coerces nil: always returns null", () => {
84
+ expect(annotate.coerce("anything", shapes.nil())).toBeNull();
85
+ expect(annotate.coerce(42, shapes.nil())).toBeNull();
86
+ expect(annotate.coerce(null, shapes.nil())).toBeNull();
87
+ });
88
+
89
+ it("coerces unknown: returns value as-is", () => {
90
+ const obj = { x: 1 };
91
+ expect(annotate.coerce(obj, shapes.unknown())).toBe(obj);
92
+ expect(annotate.coerce(null, shapes.unknown())).toBeNull();
93
+ expect(annotate.coerce(42, shapes.unknown())).toBe(42);
94
+ });
95
+
96
+ it("coerces date: passthrough for Date instances", () => {
97
+ const d = new Date("2024-01-01");
98
+ expect(annotate.coerce(d, shapes.date())).toBe(d);
99
+ });
100
+
101
+ it("coerces date: parses ISO string", () => {
102
+ const result = annotate.coerce("2024-06-15", shapes.date());
103
+ expect(result).toBeInstanceOf(Date);
104
+ expect((result as Date).getFullYear()).toBe(2024);
105
+ });
106
+
107
+ it("coerces date: parses numeric timestamp", () => {
108
+ const ts = 1700000000000;
109
+ const result = annotate.coerce(ts, shapes.date());
110
+ expect(result).toBeInstanceOf(Date);
111
+ expect((result as Date).getTime()).toBe(ts);
112
+ });
113
+
114
+ it("coerces date: returns a Date for unknown input", () => {
115
+ expect(annotate.coerce(null, shapes.date())).toBeInstanceOf(Date);
116
+ });
117
+
118
+ // ── literals ─────────────────────────────────────────────────────────────
119
+
120
+ it("coerces literalStr: always returns the literal value", () => {
121
+ expect(annotate.coerce("anything", shapes.literalStr("hello"))).toBe(
122
+ "hello",
123
+ );
124
+ expect(annotate.coerce(42, shapes.literalStr("hello"))).toBe("hello");
125
+ });
126
+
127
+ it("coerces literalBool: always returns the literal value", () => {
128
+ expect(annotate.coerce("false", shapes.literalBool(true))).toBe(true);
129
+ expect(annotate.coerce(99, shapes.literalBool(false))).toBe(false);
130
+ });
131
+
132
+ it("coerces literalInt / literalFloat / literalNumber: always returns the literal value", () => {
133
+ expect(annotate.coerce("anything", shapes.literalInt(5))).toBe(5);
134
+ expect(annotate.coerce("anything", shapes.literalFloat(1.5))).toBe(1.5);
135
+ expect(annotate.coerce("anything", shapes.literalNumber(42))).toBe(42);
136
+ });
137
+
138
+ // ── bytes ────────────────────────────────────────────────────────────────
139
+
140
+ it("coerces bytes: passthrough for Uint8Array", () => {
141
+ const buf = new Uint8Array([1, 2, 3]);
142
+ expect(annotate.coerce(buf, shapes.bytes())).toBe(buf);
143
+ });
144
+
145
+ it("coerces bytes: converts number array", () => {
146
+ expect(annotate.coerce([10, 20, 30], shapes.bytes())).toEqual(
147
+ new Uint8Array([10, 20, 30]),
148
+ );
149
+ });
150
+
151
+ it("coerces bytes: parses comma-separated string", () => {
152
+ expect(annotate.coerce("1,2,3", shapes.bytes())).toEqual(
153
+ new Uint8Array([1, 2, 3]),
154
+ );
155
+ });
156
+
157
+ it("coerces bytes: returns empty Uint8Array for unknown input", () => {
158
+ expect(annotate.coerce(null, shapes.bytes())).toEqual(new Uint8Array());
159
+ });
160
+
161
+ // ── array ────────────────────────────────────────────────────────────────
162
+
163
+ it("coerces array: passthrough for arrays", () => {
164
+ expect(annotate.coerce([1, 2, 3], shapes.array(shapes.int()))).toEqual([
165
+ 1, 2, 3,
166
+ ]);
167
+ });
168
+
169
+ it("coerces array: converts each item", () => {
170
+ expect(
171
+ annotate.coerce(["1", "2", "3"], shapes.array(shapes.int())),
172
+ ).toEqual([1, 2, 3]);
173
+ });
174
+
175
+ it("coerces array: parses comma-separated string", () => {
176
+ expect(annotate.coerce("10, 20, 30", shapes.array(shapes.int()))).toEqual([
177
+ 10, 20, 30,
178
+ ]);
179
+ });
180
+
181
+ it("coerces array: returns empty array for other types", () => {
182
+ expect(annotate.coerce(null, shapes.array(shapes.str()))).toEqual([]);
183
+ });
184
+
185
+ // ── tuple ────────────────────────────────────────────────────────────────
186
+
187
+ it("coerces tuple: converts array elements per shape", () => {
188
+ expect(
189
+ annotate.coerce(["42", true], shapes.tuple(shapes.int(), shapes.bool())),
190
+ ).toEqual([42, true]);
191
+ });
192
+
193
+ it("coerces tuple: parses comma-separated string", () => {
194
+ expect(
195
+ annotate.coerce("hello,1", shapes.tuple(shapes.str(), shapes.int())),
196
+ ).toEqual(["hello", 1]);
197
+ });
198
+
199
+ it("coerces tuple: fills missing elements with coerced nulls", () => {
200
+ const result = annotate.coerce(
201
+ null,
202
+ shapes.tuple(shapes.int(), shapes.str()),
203
+ );
204
+ expect(result).toEqual([0, "null"]);
205
+ });
206
+
207
+ // ── vector ───────────────────────────────────────────────────────────────
208
+
209
+ it("coerces vector: passthrough for number array", () => {
210
+ expect(annotate.coerce([1, 2, 3], shapes.vector(3))).toEqual([1, 2, 3]);
211
+ });
212
+
213
+ it("coerces vector: parses comma-separated string", () => {
214
+ expect(annotate.coerce("1.0, 2.0, 3.0", shapes.vector(3))).toEqual([
215
+ 1, 2, 3,
216
+ ]);
217
+ });
218
+
219
+ it("coerces vector: returns empty array for unknown input", () => {
220
+ expect(annotate.coerce(null, shapes.vector(3))).toEqual([]);
221
+ });
222
+
223
+ // ── mapping ──────────────────────────────────────────────────────────────
224
+
225
+ it("coerces mapping: passthrough for plain objects", () => {
226
+ expect(
227
+ annotate.coerce(
228
+ { name: "Alice", age: 30 },
229
+ shapes.mapping({ name: shapes.str(), age: shapes.int() }),
230
+ ),
231
+ ).toEqual({ name: "Alice", age: 30 });
232
+ });
233
+
234
+ it("coerces mapping: coerces each field's type", () => {
235
+ expect(
236
+ annotate.coerce(
237
+ { name: 42, age: "30" },
238
+ shapes.mapping({ name: shapes.str(), age: shapes.int() }),
239
+ ),
240
+ ).toEqual({ name: "42", age: 30 });
241
+ });
242
+
243
+ it("coerces mapping: parses JSON string", () => {
244
+ expect(
245
+ annotate.coerce(
246
+ '{"x": "1", "y": "2"}',
247
+ shapes.mapping({ x: shapes.int(), y: shapes.int() }),
248
+ ),
249
+ ).toEqual({ x: 1, y: 2 });
250
+ });
251
+
252
+ it("coerces mapping: fills missing fields with defaults", () => {
253
+ expect(
254
+ annotate.coerce(
255
+ {},
256
+ shapes.mapping({ count: shapes.int(), label: shapes.str() }),
257
+ ),
258
+ ).toEqual({ count: 0, label: "undefined" });
259
+ });
260
+
261
+ it("coerces mapping: returns coerced defaults for non-object input", () => {
262
+ expect(
263
+ annotate.coerce(null, shapes.mapping({ active: shapes.bool() })),
264
+ ).toEqual({ active: false });
265
+ });
266
+
267
+ // ── ref ───────────────────────────────────────────────────────────────────
268
+
269
+ it("coerces ref: returns value as-is", () => {
270
+ const val = { id: 1 };
271
+ expect(annotate.coerce(val, shapes.ref("MyType"))).toBe(val);
272
+ expect(annotate.coerce("anything", shapes.ref("MyType"))).toBe("anything");
273
+ });
274
+
275
+ // ── union ─────────────────────────────────────────────────────────────────
276
+
277
+ it("coerces union: uses first member's shape", () => {
278
+ const shape = shapes.union(shapes.int(), shapes.str());
279
+ expect(annotate.coerce("42", shape)).toBe(42);
280
+ });
281
+
282
+ it("coerces union: handles single-member union", () => {
283
+ expect(annotate.coerce("hello", shapes.union(shapes.str()))).toBe("hello");
284
+ });
285
+ });
package/src/shape.ts CHANGED
@@ -107,6 +107,7 @@ export interface ShapeStr extends ShapeWithOutput<"str", string> {}
107
107
  export interface ShapeDate extends ShapeWithOutput<"date", Date> {}
108
108
  export interface ShapeNil extends ShapeWithOutput<"nil", null> {}
109
109
  export interface ShapeUnknown extends ShapeWithOutput<"unknown", unknown> {}
110
+ export interface ShapeBytes extends ShapeWithOutput<"bytes", Uint8Array> {}
110
111
 
111
112
  export interface ShapeLiteralFloat<
112
113
  Value extends number = number,
@@ -197,6 +198,7 @@ export type Shape =
197
198
  | ShapeDate
198
199
  | ShapeNil
199
200
  | ShapeUnknown
201
+ | ShapeBytes
200
202
  | ShapeLiteralFloat
201
203
  | ShapeLiteralInt
202
204
  | ShapeLiteralNumber
@@ -288,6 +290,14 @@ export namespace shapes {
288
290
  };
289
291
  }
290
292
 
293
+ export function bytes(): ShapeBytes {
294
+ return {
295
+ type: "bytes",
296
+ anno: {},
297
+ ...createPhantom(),
298
+ };
299
+ }
300
+
291
301
  export function literalFloat<Value extends number>(
292
302
  value: Value,
293
303
  ): ShapeLiteralFloat<Value> {
@@ -539,4 +549,117 @@ export namespace annotate {
539
549
  }
540
550
  return shapes.unknown();
541
551
  }
552
+
553
+ export function coerce<S extends Shape>(
554
+ x: unknown,
555
+ shape: S,
556
+ ): OutputOfShape<S> {
557
+ const convert = (x: unknown, shape: Shape): unknown => {
558
+ switch (shape.type) {
559
+ case "str":
560
+ if (typeof x === "string") return x;
561
+ return `${x}`;
562
+ case "literal-str":
563
+ case "literal-int":
564
+ case "literal-float":
565
+ case "literal-number":
566
+ case "literal-bool":
567
+ return shape.value;
568
+ case "number":
569
+ case "float":
570
+ if (typeof x === "number") return x;
571
+ if (typeof x === "string") return Number(x || 0);
572
+ return 0;
573
+ case "int":
574
+ if (typeof x === "number") return Math.trunc(x);
575
+ if (typeof x === "string") return Math.trunc(Number(x || 0));
576
+ return 0;
577
+ case "nil":
578
+ return null;
579
+ case "bool":
580
+ if (typeof x === "boolean") return x;
581
+ if (typeof x === "string")
582
+ return ["true", "1", "yes"].includes(x.trim().toLowerCase());
583
+ return !!x;
584
+ case "date":
585
+ if (x instanceof Date) return x;
586
+ if (typeof x === "string" || typeof x === "number")
587
+ return new Date(x);
588
+ return new Date();
589
+ case "unknown":
590
+ return x;
591
+ case "vector":
592
+ if (Array.isArray(x)) return x.map((v) => convert(v, shapes.float()));
593
+ if (typeof x === "string")
594
+ return x
595
+ .replaceAll(/\[|\{|\}|\]/g, "")
596
+ .split(",")
597
+ .map((v) => v.trim())
598
+ .map((v) => convert(v, shapes.float()));
599
+ return [];
600
+ case "array":
601
+ if (Array.isArray(x)) return x.map((v) => convert(v, shape.item));
602
+ if (typeof x === "string")
603
+ return x
604
+ .replaceAll(/\[|\{|\}|\]/g, "")
605
+ .split(",")
606
+ .map((v) => v.trim())
607
+ .map((v) => convert(v, shape.item));
608
+ return [];
609
+ case "tuple":
610
+ if (Array.isArray(x))
611
+ return shape.tup.map((s, i) => convert(x[i], s));
612
+ if (typeof x === "string") {
613
+ const parts = x
614
+ .replaceAll(/\[|\{|\}|\]/g, "")
615
+ .split(",")
616
+ .map((v) => v.trim());
617
+ return shape.tup.map((s, i) => convert(parts[i], s));
618
+ }
619
+ return shape.tup.map((s) => convert(null, s));
620
+ case "bytes":
621
+ if (x instanceof Uint8Array) return x;
622
+ if (Array.isArray(x))
623
+ return new Uint8Array(
624
+ x.map((v) => convert(v, shapes.int())) as number[],
625
+ );
626
+ if (typeof x === "string")
627
+ return new Uint8Array(
628
+ x
629
+ .replaceAll(/\[|\{|\}|\]/g, "")
630
+ .split(",")
631
+ .map((v) => v.trim())
632
+ .map((v) => convert(v, shapes.number())) as number[],
633
+ );
634
+ return new Uint8Array();
635
+ case "ref":
636
+ return x;
637
+ case "union":
638
+ const t = shape.ofs[0];
639
+ return convert(x, t || shapes.unknown());
640
+ case "mapping":
641
+ let obj: Record<string, unknown> = {};
642
+ if (isPlainObject(x)) {
643
+ obj = x;
644
+ } else if (typeof x === "string") {
645
+ const trimmed = x.trim();
646
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
647
+ try {
648
+ obj = JSON.parse(x) as Record<string, unknown>;
649
+ } catch {}
650
+ }
651
+ }
652
+ return Object.assign(
653
+ {},
654
+ ...Object.entries(shape.rec).map(([k, t]) => {
655
+ return {
656
+ [k]: convert(obj[k], t),
657
+ };
658
+ }),
659
+ );
660
+ }
661
+ };
662
+
663
+ return convert(x, shape) as OutputOfShape<S>;
664
+ }
542
665
  }
@@ -51,6 +51,8 @@ const pgType = (shape: Shape): string => {
51
51
  return `vector(${shape.dims})`;
52
52
  case "mapping":
53
53
  return "JSONB";
54
+ case "bytes":
55
+ return "BYTEA";
54
56
  }
55
57
  };
56
58
 
@@ -12,6 +12,7 @@ describe("typescript", (it) => {
12
12
  expect(typescript(shapes.date())).toBe("Date");
13
13
  expect(typescript(shapes.nil())).toBe("null");
14
14
  expect(typescript(shapes.unknown())).toBe("unknown");
15
+ expect(typescript(shapes.bytes())).toBe("Uint8Array");
15
16
  });
16
17
 
17
18
  it("translates unions", () => {
@@ -136,6 +136,8 @@ const ts = (shape: Shape, ctx: Context): string => {
136
136
  return "null";
137
137
  case "unknown":
138
138
  return "unknown";
139
+ case "bytes":
140
+ return "Uint8Array";
139
141
  case "literal-number":
140
142
  return `${shape.value}`;
141
143
  case "literal-int":
package/src/validation.ts CHANGED
@@ -94,6 +94,8 @@ export function validate(x: unknown, shape: Shape): ValidationResult {
94
94
  return maybe(x instanceof Date, "expected a date", path);
95
95
  case "unknown":
96
96
  return ok();
97
+ case "bytes":
98
+ return maybe(x instanceof Uint8Array, "expected a Uint8Array", path);
97
99
  case "array": {
98
100
  if (!Array.isArray(x)) return err("expected an array", path);
99
101
  if (shape.anno.min !== undefined && x.length < shape.anno.min)