@constructive-io/coerce 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,9 +56,32 @@ const messageId = requireString(body.message_id, 'message_id'); // string, or th
56
56
  | `asIsoString` | `requireIsoString` | a `Date` (serialised) or a string (verbatim) |
57
57
  | `asOneOf` | `requireOneOf` | one of the given string literals |
58
58
 
59
+ Then the text-carried and named shapes — what an env var, a query string or a header hands you:
60
+
61
+ | lenient | strict | accepts |
62
+ | --- | --- | --- |
63
+ | `asNumeric` | `requireNumeric` | a finite number, or a numeric string (`'5'`) |
64
+ | `asNumericInteger` | `requireNumericInteger` | an integer, or an integral string (`'5'`) |
65
+ | `asNumberIn` | `requireNumberIn` | a finite number within `{ min, max }`, inclusive |
66
+ | `asIntegerIn` | `requireIntegerIn` | an integer within `{ min, max }`, inclusive |
67
+ | `asPort` | `requirePort` | a port: an integer in `1..65535`, from a number or a string |
68
+ | `asBigInt` | `requireBigInt` | a `bigint`, an integer, or a digit string |
69
+ | `asUrl` | `requireUrl` | an absolute URL — a scheme is required |
70
+ | `asHostname` | `requireHostname` | a hostname or IP address — no scheme, port or path |
71
+ | `asEmail` | `requireEmail` | an email address |
72
+ | `asUuid` | `requireUuid` | a canonical UUID |
73
+ | `asDuration` | `requireDuration` | milliseconds, or a suffixed duration (`'30s'`, `'5m'`, `'2h'`, `'1d'`, `'1w'`) |
74
+ | `asJson` | `requireJson` | an object or array, or a string that parses to one |
75
+
76
+ ```ts
77
+ const port = asPort(process.env.PORT); // 1..65535, or null
78
+ const ttl = asDuration('30s'); // 30000
79
+ const timeout = asIntegerIn(body.timeout_ms, { min: 1, max: 60_000 });
80
+ ```
81
+
59
82
  ## rules
60
83
 
61
- - **No guessing across types.** `'5'` is not an integer and `1` is not a string. Only `asBoolean` and `asDate` read strings, because env vars and query strings carry nothing else.
84
+ - **No guessing across types.** `'5'` is not an integer to `asInteger` and `1` is not a string. A JSON body that sent a string meant a string; the coercers that do read text (`asNumeric`, `asPort`, `asDuration`, `asBoolean`, `asDate`, `asJson`) say so in their names, because an env var or a query string carries nothing else.
62
85
  - **A blank string is absent.** `''` and `' '` coerce to `null`, so an empty value can never read as a supplied identifier.
63
86
  - **All-or-nothing lists.** `asStringArray` rejects the whole array rather than dropping bad entries — that is how a list of allowed origins quietly shrinks.
64
87
  - **No transport semantics.** `CoerceError` carries the `label` that failed, not an HTTP status. Map it at your boundary:
@@ -74,11 +97,7 @@ try {
74
97
 
75
98
  ## related
76
99
 
77
- `12factor-env` builds on this package for its lenient env parsing, and adds envalid-backed environment *schemas* (`cleanEnv`, redaction, cross-field checks). Reach for `12factor-env` when validating `process.env` at boot, and for this package when validating a value at runtime.
78
-
79
- ## license
80
-
81
- MIT
100
+ `12factor-env` coerces through this package: its `port`/`url`/`host`/`email`/`uuid`/`duration`/`num`/`int`/`list`/`enumerated` validators are these coercers wrapped in envalid specs, so a variable and a request body agree on what each shape accepts. On top it adds what is environment-only — schemas (`cleanEnv`), defaults, secret redaction, cross-field checks. Reach for `12factor-env` when validating `process.env` at boot, and for this package when validating a value at runtime.
82
101
 
83
102
  ---
84
103
 
package/coerce.d.ts CHANGED
@@ -10,6 +10,12 @@
10
10
  * present, so a caller cannot mistake `''` for a supplied identifier.
11
11
  * - `require*` is strict: same test, but a miss throws {@link CoerceError}.
12
12
  *
13
+ * A value that arrives as text — an env var, a query parameter, a header — has
14
+ * its own family (`asNumeric`, `asPort`, `asDuration`, `asBigInt`, `asJson`,
15
+ * and the domain coercers) which parses the string spelling, because for those
16
+ * the string IS the wire format. `asNumber`/`asInteger` stay strict so that a
17
+ * JSON body cannot pass `'5'` where a number was declared.
18
+ *
13
19
  * Deliberately transport-agnostic: `CoerceError` carries the offending
14
20
  * `label`, not an HTTP status. A server maps it to 400 at its own boundary,
15
21
  * which keeps this package usable off the request path.
@@ -42,6 +48,14 @@ export declare const asBoolean: (value: unknown) => boolean | null;
42
48
  * shrinks.
43
49
  */
44
50
  export declare const asStringArray: (value: unknown) => string[] | null;
51
+ /**
52
+ * An array whose every entry coerces through `element`, else `null`. One bad
53
+ * entry rejects the whole array — a partially-valid list is a partially-wrong
54
+ * answer.
55
+ */
56
+ export declare const asArrayOf: <T>(value: unknown, element: (entry: unknown) => T | null) => T[] | null;
57
+ /** {@link asArrayOf}, throwing {@link CoerceError} when absent. */
58
+ export declare const requireArrayOf: <T>(value: unknown, element: (entry: unknown) => T | null, label: string, expected?: string) => T[];
45
59
  /**
46
60
  * A list of non-empty strings from either an array (see
47
61
  * {@link asStringArray}) or a delimited string, else `null`.
@@ -68,6 +82,69 @@ export declare const asDate: (value: unknown) => Date | null;
68
82
  * against.
69
83
  */
70
84
  export declare const asIsoString: (value: unknown) => string | null;
85
+ /**
86
+ * A number carried as text, else `null` — the env-var/query-string form of
87
+ * {@link asNumber}. Numbers pass through; a blank string is not a `0`, which is
88
+ * what bare `Number('')` would produce.
89
+ */
90
+ export declare const asNumeric: (value: unknown) => number | null;
91
+ /** {@link asNumeric} restricted to integers, else `null`. */
92
+ export declare const asNumericInteger: (value: unknown) => number | null;
93
+ /** Inclusive bounds for {@link asNumberIn} and {@link asIntegerIn}. */
94
+ export interface Bounds {
95
+ min?: number;
96
+ max?: number;
97
+ }
98
+ /**
99
+ * A finite number within `bounds`, else `null`.
100
+ *
101
+ * Out-of-range is a miss rather than a clamp: a `min: 1` that silently became
102
+ * `1` would turn "invalid" into a plausible value nobody configured.
103
+ */
104
+ export declare const asNumberIn: (value: unknown, { min, max }?: Bounds) => number | null;
105
+ /** {@link asNumberIn} restricted to integers, else `null`. */
106
+ export declare const asIntegerIn: (value: unknown, bounds?: Bounds) => number | null;
107
+ /** A TCP/UDP port — an integer in `1..65535`, from a number or its text form. */
108
+ export declare const asPort: (value: unknown) => number | null;
109
+ /**
110
+ * A whole number of arbitrary size, else `null`. Accepts a `bigint`, an integer
111
+ * `number`, or a digit string — the three shapes a 64-bit database id takes on
112
+ * its way through JSON, where it cannot survive as a `number`.
113
+ */
114
+ export declare const asBigInt: (value: unknown) => bigint | null;
115
+ /**
116
+ * An absolute URL, returned **verbatim**, else `null`. A scheme is required, so
117
+ * `'example.com'` — which is a path, and a classic way to fetch the wrong
118
+ * thing — does not qualify.
119
+ */
120
+ export declare const asUrl: (value: unknown) => string | null;
121
+ /**
122
+ * A bare hostname or IP address, else `null`. No scheme, port or path: this is
123
+ * what belongs in a `PGHOST`, and accepting `'localhost:5432'` there is how a
124
+ * port ends up inside a DNS lookup.
125
+ */
126
+ export declare const asHostname: (value: unknown) => string | null;
127
+ /** An email address, else `null`. Shape only — deliverability is not syntax. */
128
+ export declare const asEmail: (value: unknown) => string | null;
129
+ /** A UUID in canonical 8-4-4-4-12 hex form, else `null`. Case is preserved. */
130
+ export declare const asUuid: (value: unknown) => string | null;
131
+ /**
132
+ * A duration normalised to **milliseconds**, else `null`. Accepts a bare number
133
+ * of ms or a suffixed spelling: `500ms`, `30s`, `5m`, `2h`, `1d`, `1w`.
134
+ *
135
+ * A unit-less string is milliseconds, which is what every timeout in the house
136
+ * is already denominated in.
137
+ */
138
+ export declare const asDuration: (value: unknown) => number | null;
139
+ /**
140
+ * A JSON document — an object or an array — else `null`. A string is parsed;
141
+ * an already-parsed object or array passes through, so a value keeps coercing
142
+ * the same way on both sides of a serialisation boundary.
143
+ *
144
+ * A bare scalar is not a document: `'5'` reaching a config var that was meant
145
+ * to carry a map is a mistake worth surfacing.
146
+ */
147
+ export declare const asJson: (value: unknown) => Record<string, unknown> | unknown[] | null;
71
148
  /** {@link asString}, throwing {@link CoerceError} when absent. */
72
149
  export declare const requireString: (value: unknown, label: string) => string;
73
150
  /** {@link asNumber}, throwing {@link CoerceError} when absent. */
@@ -86,6 +163,30 @@ export declare const requireRecord: (value: unknown, label: string) => Record<st
86
163
  export declare const requireDate: (value: unknown, label: string) => Date;
87
164
  /** {@link asIsoString}, throwing {@link CoerceError} when absent. */
88
165
  export declare const requireIsoString: (value: unknown, label: string) => string;
166
+ /** {@link asNumeric}, throwing {@link CoerceError} when absent. */
167
+ export declare const requireNumeric: (value: unknown, label: string) => number;
168
+ /** {@link asNumericInteger}, throwing {@link CoerceError} when absent. */
169
+ export declare const requireNumericInteger: (value: unknown, label: string) => number;
170
+ /** {@link asPort}, throwing {@link CoerceError} when absent. */
171
+ export declare const requirePort: (value: unknown, label: string) => number;
172
+ /** {@link asBigInt}, throwing {@link CoerceError} when absent. */
173
+ export declare const requireBigInt: (value: unknown, label: string) => bigint;
174
+ /** {@link asUrl}, throwing {@link CoerceError} when absent. */
175
+ export declare const requireUrl: (value: unknown, label: string) => string;
176
+ /** {@link asHostname}, throwing {@link CoerceError} when absent. */
177
+ export declare const requireHostname: (value: unknown, label: string) => string;
178
+ /** {@link asEmail}, throwing {@link CoerceError} when absent. */
179
+ export declare const requireEmail: (value: unknown, label: string) => string;
180
+ /** {@link asUuid}, throwing {@link CoerceError} when absent. */
181
+ export declare const requireUuid: (value: unknown, label: string) => string;
182
+ /** {@link asDuration}, throwing {@link CoerceError} when absent. */
183
+ export declare const requireDuration: (value: unknown, label: string) => number;
184
+ /** {@link asJson}, throwing {@link CoerceError} when absent. */
185
+ export declare const requireJson: (value: unknown, label: string) => unknown[] | Record<string, unknown>;
186
+ /** {@link asNumberIn}, throwing {@link CoerceError} when out of range. */
187
+ export declare const requireNumberIn: (value: unknown, bounds: Bounds, label: string) => number;
188
+ /** {@link asIntegerIn}, throwing {@link CoerceError} when out of range. */
189
+ export declare const requireIntegerIn: (value: unknown, bounds: Bounds, label: string) => number;
89
190
  /**
90
191
  * Narrow a value to one of `allowed`, else `null`.
91
192
  *
package/coerce.js CHANGED
@@ -11,12 +11,18 @@
11
11
  * present, so a caller cannot mistake `''` for a supplied identifier.
12
12
  * - `require*` is strict: same test, but a miss throws {@link CoerceError}.
13
13
  *
14
+ * A value that arrives as text — an env var, a query parameter, a header — has
15
+ * its own family (`asNumeric`, `asPort`, `asDuration`, `asBigInt`, `asJson`,
16
+ * and the domain coercers) which parses the string spelling, because for those
17
+ * the string IS the wire format. `asNumber`/`asInteger` stay strict so that a
18
+ * JSON body cannot pass `'5'` where a number was declared.
19
+ *
14
20
  * Deliberately transport-agnostic: `CoerceError` carries the offending
15
21
  * `label`, not an HTTP status. A server maps it to 400 at its own boundary,
16
22
  * which keeps this package usable off the request path.
17
23
  */
18
24
  Object.defineProperty(exports, "__esModule", { value: true });
19
- exports.requireOneOf = exports.asOneOf = exports.requireIsoString = exports.requireDate = exports.requireRecord = exports.requireStringList = exports.requireStringArray = exports.requireBoolean = exports.requireInteger = exports.requireNumber = exports.requireString = exports.asIsoString = exports.asDate = exports.asRecord = exports.asStringList = exports.asStringArray = exports.asBoolean = exports.asInteger = exports.asNumber = exports.asString = exports.CoerceError = void 0;
25
+ exports.requireOneOf = exports.asOneOf = exports.requireIntegerIn = exports.requireNumberIn = exports.requireJson = exports.requireDuration = exports.requireUuid = exports.requireEmail = exports.requireHostname = exports.requireUrl = exports.requireBigInt = exports.requirePort = exports.requireNumericInteger = exports.requireNumeric = exports.requireIsoString = exports.requireDate = exports.requireRecord = exports.requireStringList = exports.requireStringArray = exports.requireBoolean = exports.requireInteger = exports.requireNumber = exports.requireString = exports.asJson = exports.asDuration = exports.asUuid = exports.asEmail = exports.asHostname = exports.asUrl = exports.asBigInt = exports.asPort = exports.asIntegerIn = exports.asNumberIn = exports.asNumericInteger = exports.asNumeric = exports.asIsoString = exports.asDate = exports.asRecord = exports.asStringList = exports.requireArrayOf = exports.asArrayOf = exports.asStringArray = exports.asBoolean = exports.asInteger = exports.asNumber = exports.asString = exports.CoerceError = void 0;
20
26
  /** A value that was required but absent or of the wrong shape. */
21
27
  class CoerceError extends Error {
22
28
  /** What the caller was asking for, e.g. `'message_id'`. */
@@ -79,6 +85,32 @@ const asStringArray = (value) => {
79
85
  return strings;
80
86
  };
81
87
  exports.asStringArray = asStringArray;
88
+ /**
89
+ * An array whose every entry coerces through `element`, else `null`. One bad
90
+ * entry rejects the whole array — a partially-valid list is a partially-wrong
91
+ * answer.
92
+ */
93
+ const asArrayOf = (value, element) => {
94
+ if (!Array.isArray(value))
95
+ return null;
96
+ const entries = [];
97
+ for (const item of value) {
98
+ const coerced = element(item);
99
+ if (coerced === null)
100
+ return null;
101
+ entries.push(coerced);
102
+ }
103
+ return entries;
104
+ };
105
+ exports.asArrayOf = asArrayOf;
106
+ /** {@link asArrayOf}, throwing {@link CoerceError} when absent. */
107
+ const requireArrayOf = (value, element, label, expected = 'an array of valid entries') => {
108
+ const entries = (0, exports.asArrayOf)(value, element);
109
+ if (entries === null)
110
+ throw new CoerceError(label, expected);
111
+ return entries;
112
+ };
113
+ exports.requireArrayOf = requireArrayOf;
82
114
  /**
83
115
  * A list of non-empty strings from either an array (see
84
116
  * {@link asStringArray}) or a delimited string, else `null`.
@@ -124,6 +156,160 @@ exports.asDate = asDate;
124
156
  */
125
157
  const asIsoString = (value) => value instanceof Date ? (0, exports.asDate)(value)?.toISOString() ?? null : (0, exports.asString)(value);
126
158
  exports.asIsoString = asIsoString;
159
+ /**
160
+ * A number carried as text, else `null` — the env-var/query-string form of
161
+ * {@link asNumber}. Numbers pass through; a blank string is not a `0`, which is
162
+ * what bare `Number('')` would produce.
163
+ */
164
+ const asNumeric = (value) => {
165
+ if (typeof value === 'number')
166
+ return (0, exports.asNumber)(value);
167
+ const str = (0, exports.asString)(value);
168
+ return str === null ? null : (0, exports.asNumber)(Number(str));
169
+ };
170
+ exports.asNumeric = asNumeric;
171
+ /** {@link asNumeric} restricted to integers, else `null`. */
172
+ const asNumericInteger = (value) => (0, exports.asInteger)((0, exports.asNumeric)(value));
173
+ exports.asNumericInteger = asNumericInteger;
174
+ /**
175
+ * A finite number within `bounds`, else `null`.
176
+ *
177
+ * Out-of-range is a miss rather than a clamp: a `min: 1` that silently became
178
+ * `1` would turn "invalid" into a plausible value nobody configured.
179
+ */
180
+ const asNumberIn = (value, { min, max } = {}) => {
181
+ const num = (0, exports.asNumber)(value);
182
+ if (num === null)
183
+ return null;
184
+ if (min !== undefined && num < min)
185
+ return null;
186
+ if (max !== undefined && num > max)
187
+ return null;
188
+ return num;
189
+ };
190
+ exports.asNumberIn = asNumberIn;
191
+ /** {@link asNumberIn} restricted to integers, else `null`. */
192
+ const asIntegerIn = (value, bounds = {}) => (0, exports.asInteger)((0, exports.asNumberIn)(value, bounds));
193
+ exports.asIntegerIn = asIntegerIn;
194
+ /** A TCP/UDP port — an integer in `1..65535`, from a number or its text form. */
195
+ const asPort = (value) => (0, exports.asIntegerIn)((0, exports.asNumeric)(value), { min: 1, max: 65535 });
196
+ exports.asPort = asPort;
197
+ /**
198
+ * A whole number of arbitrary size, else `null`. Accepts a `bigint`, an integer
199
+ * `number`, or a digit string — the three shapes a 64-bit database id takes on
200
+ * its way through JSON, where it cannot survive as a `number`.
201
+ */
202
+ const asBigInt = (value) => {
203
+ if (typeof value === 'bigint')
204
+ return value;
205
+ if (typeof value === 'number')
206
+ return Number.isInteger(value) ? BigInt(value) : null;
207
+ const str = (0, exports.asString)(value)?.trim();
208
+ return str !== undefined && INTEGER_TEXT.test(str) ? BigInt(str) : null;
209
+ };
210
+ exports.asBigInt = asBigInt;
211
+ const INTEGER_TEXT = /^[+-]?\d+$/;
212
+ /**
213
+ * An absolute URL, returned **verbatim**, else `null`. A scheme is required, so
214
+ * `'example.com'` — which is a path, and a classic way to fetch the wrong
215
+ * thing — does not qualify.
216
+ */
217
+ const asUrl = (value) => {
218
+ const str = (0, exports.asString)(value);
219
+ if (str === null)
220
+ return null;
221
+ try {
222
+ new URL(str);
223
+ return str;
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ };
229
+ exports.asUrl = asUrl;
230
+ /**
231
+ * A bare hostname or IP address, else `null`. No scheme, port or path: this is
232
+ * what belongs in a `PGHOST`, and accepting `'localhost:5432'` there is how a
233
+ * port ends up inside a DNS lookup.
234
+ */
235
+ const asHostname = (value) => {
236
+ const str = (0, exports.asString)(value)?.trim();
237
+ if (str === undefined)
238
+ return null;
239
+ return HOSTNAME.test(str) || IPV6.test(str) ? str : null;
240
+ };
241
+ exports.asHostname = asHostname;
242
+ const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
243
+ // Deliberately loose: enough to reject a URL, a port suffix or a path, not a
244
+ // substitute for the resolver that will parse it for real.
245
+ const IPV6 = /^\[?(?:[0-9a-f]{0,4}:){2,7}[0-9a-f.]{0,4}\]?$/i;
246
+ /** An email address, else `null`. Shape only — deliverability is not syntax. */
247
+ const asEmail = (value) => {
248
+ const str = (0, exports.asString)(value)?.trim();
249
+ return str !== undefined && EMAIL.test(str) ? str : null;
250
+ };
251
+ exports.asEmail = asEmail;
252
+ const EMAIL = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
253
+ /** A UUID in canonical 8-4-4-4-12 hex form, else `null`. Case is preserved. */
254
+ const asUuid = (value) => {
255
+ const str = (0, exports.asString)(value)?.trim();
256
+ return str !== undefined && UUID.test(str) ? str : null;
257
+ };
258
+ exports.asUuid = asUuid;
259
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
260
+ /**
261
+ * A duration normalised to **milliseconds**, else `null`. Accepts a bare number
262
+ * of ms or a suffixed spelling: `500ms`, `30s`, `5m`, `2h`, `1d`, `1w`.
263
+ *
264
+ * A unit-less string is milliseconds, which is what every timeout in the house
265
+ * is already denominated in.
266
+ */
267
+ const asDuration = (value) => {
268
+ if (typeof value === 'number')
269
+ return (0, exports.asNumber)(value);
270
+ const str = (0, exports.asString)(value)?.trim();
271
+ if (str === undefined)
272
+ return null;
273
+ const match = DURATION.exec(str);
274
+ if (!match)
275
+ return null;
276
+ return Number(match[1]) * DURATION_MS[(match[2] ?? 'ms').toLowerCase()];
277
+ };
278
+ exports.asDuration = asDuration;
279
+ const DURATION = /^([+-]?\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)?$/i;
280
+ const DURATION_MS = {
281
+ ms: 1,
282
+ s: 1_000,
283
+ m: 60_000,
284
+ h: 3_600_000,
285
+ d: 86_400_000,
286
+ w: 604_800_000
287
+ };
288
+ /**
289
+ * A JSON document — an object or an array — else `null`. A string is parsed;
290
+ * an already-parsed object or array passes through, so a value keeps coercing
291
+ * the same way on both sides of a serialisation boundary.
292
+ *
293
+ * A bare scalar is not a document: `'5'` reaching a config var that was meant
294
+ * to carry a map is a mistake worth surfacing.
295
+ */
296
+ const asJson = (value) => {
297
+ if (Array.isArray(value))
298
+ return value;
299
+ const record = (0, exports.asRecord)(value);
300
+ if (record !== null)
301
+ return record;
302
+ const str = (0, exports.asString)(value);
303
+ if (str === null)
304
+ return null;
305
+ try {
306
+ return (0, exports.asJson)(JSON.parse(str));
307
+ }
308
+ catch {
309
+ return null;
310
+ }
311
+ };
312
+ exports.asJson = asJson;
127
313
  const require_ = (as, expected) => (value, label) => {
128
314
  const coerced = as(value);
129
315
  if (coerced === null)
@@ -148,6 +334,49 @@ exports.requireRecord = require_(exports.asRecord, 'an object');
148
334
  exports.requireDate = require_(exports.asDate, 'a date');
149
335
  /** {@link asIsoString}, throwing {@link CoerceError} when absent. */
150
336
  exports.requireIsoString = require_(exports.asIsoString, 'a timestamp');
337
+ /** {@link asNumeric}, throwing {@link CoerceError} when absent. */
338
+ exports.requireNumeric = require_(exports.asNumeric, 'a number');
339
+ /** {@link asNumericInteger}, throwing {@link CoerceError} when absent. */
340
+ exports.requireNumericInteger = require_(exports.asNumericInteger, 'an integer');
341
+ /** {@link asPort}, throwing {@link CoerceError} when absent. */
342
+ exports.requirePort = require_(exports.asPort, 'a port in 1..65535');
343
+ /** {@link asBigInt}, throwing {@link CoerceError} when absent. */
344
+ exports.requireBigInt = require_(exports.asBigInt, 'a whole number');
345
+ /** {@link asUrl}, throwing {@link CoerceError} when absent. */
346
+ exports.requireUrl = require_(exports.asUrl, 'an absolute URL');
347
+ /** {@link asHostname}, throwing {@link CoerceError} when absent. */
348
+ exports.requireHostname = require_(exports.asHostname, 'a hostname or IP address');
349
+ /** {@link asEmail}, throwing {@link CoerceError} when absent. */
350
+ exports.requireEmail = require_(exports.asEmail, 'an email address');
351
+ /** {@link asUuid}, throwing {@link CoerceError} when absent. */
352
+ exports.requireUuid = require_(exports.asUuid, 'a UUID');
353
+ /** {@link asDuration}, throwing {@link CoerceError} when absent. */
354
+ exports.requireDuration = require_(exports.asDuration, 'a duration');
355
+ /** {@link asJson}, throwing {@link CoerceError} when absent. */
356
+ exports.requireJson = require_(exports.asJson, 'a JSON object or array');
357
+ /** {@link asNumberIn}, throwing {@link CoerceError} when out of range. */
358
+ const requireNumberIn = (value, bounds, label) => {
359
+ const num = (0, exports.asNumberIn)(value, bounds);
360
+ if (num === null)
361
+ throw new CoerceError(label, describeBounds('a number', bounds));
362
+ return num;
363
+ };
364
+ exports.requireNumberIn = requireNumberIn;
365
+ /** {@link asIntegerIn}, throwing {@link CoerceError} when out of range. */
366
+ const requireIntegerIn = (value, bounds, label) => {
367
+ const num = (0, exports.asIntegerIn)(value, bounds);
368
+ if (num === null)
369
+ throw new CoerceError(label, describeBounds('an integer', bounds));
370
+ return num;
371
+ };
372
+ exports.requireIntegerIn = requireIntegerIn;
373
+ const describeBounds = (what, { min, max }) => min !== undefined && max !== undefined
374
+ ? `${what} in ${min}..${max}`
375
+ : min !== undefined
376
+ ? `${what} >= ${min}`
377
+ : max !== undefined
378
+ ? `${what} <= ${max}`
379
+ : what;
151
380
  /**
152
381
  * Narrow a value to one of `allowed`, else `null`.
153
382
  *
package/esm/coerce.js CHANGED
@@ -10,6 +10,12 @@
10
10
  * present, so a caller cannot mistake `''` for a supplied identifier.
11
11
  * - `require*` is strict: same test, but a miss throws {@link CoerceError}.
12
12
  *
13
+ * A value that arrives as text — an env var, a query parameter, a header — has
14
+ * its own family (`asNumeric`, `asPort`, `asDuration`, `asBigInt`, `asJson`,
15
+ * and the domain coercers) which parses the string spelling, because for those
16
+ * the string IS the wire format. `asNumber`/`asInteger` stay strict so that a
17
+ * JSON body cannot pass `'5'` where a number was declared.
18
+ *
13
19
  * Deliberately transport-agnostic: `CoerceError` carries the offending
14
20
  * `label`, not an HTTP status. A server maps it to 400 at its own boundary,
15
21
  * which keeps this package usable off the request path.
@@ -70,6 +76,30 @@ export const asStringArray = (value) => {
70
76
  }
71
77
  return strings;
72
78
  };
79
+ /**
80
+ * An array whose every entry coerces through `element`, else `null`. One bad
81
+ * entry rejects the whole array — a partially-valid list is a partially-wrong
82
+ * answer.
83
+ */
84
+ export const asArrayOf = (value, element) => {
85
+ if (!Array.isArray(value))
86
+ return null;
87
+ const entries = [];
88
+ for (const item of value) {
89
+ const coerced = element(item);
90
+ if (coerced === null)
91
+ return null;
92
+ entries.push(coerced);
93
+ }
94
+ return entries;
95
+ };
96
+ /** {@link asArrayOf}, throwing {@link CoerceError} when absent. */
97
+ export const requireArrayOf = (value, element, label, expected = 'an array of valid entries') => {
98
+ const entries = asArrayOf(value, element);
99
+ if (entries === null)
100
+ throw new CoerceError(label, expected);
101
+ return entries;
102
+ };
73
103
  /**
74
104
  * A list of non-empty strings from either an array (see
75
105
  * {@link asStringArray}) or a delimited string, else `null`.
@@ -111,6 +141,148 @@ export const asDate = (value) => {
111
141
  * against.
112
142
  */
113
143
  export const asIsoString = (value) => value instanceof Date ? asDate(value)?.toISOString() ?? null : asString(value);
144
+ /**
145
+ * A number carried as text, else `null` — the env-var/query-string form of
146
+ * {@link asNumber}. Numbers pass through; a blank string is not a `0`, which is
147
+ * what bare `Number('')` would produce.
148
+ */
149
+ export const asNumeric = (value) => {
150
+ if (typeof value === 'number')
151
+ return asNumber(value);
152
+ const str = asString(value);
153
+ return str === null ? null : asNumber(Number(str));
154
+ };
155
+ /** {@link asNumeric} restricted to integers, else `null`. */
156
+ export const asNumericInteger = (value) => asInteger(asNumeric(value));
157
+ /**
158
+ * A finite number within `bounds`, else `null`.
159
+ *
160
+ * Out-of-range is a miss rather than a clamp: a `min: 1` that silently became
161
+ * `1` would turn "invalid" into a plausible value nobody configured.
162
+ */
163
+ export const asNumberIn = (value, { min, max } = {}) => {
164
+ const num = asNumber(value);
165
+ if (num === null)
166
+ return null;
167
+ if (min !== undefined && num < min)
168
+ return null;
169
+ if (max !== undefined && num > max)
170
+ return null;
171
+ return num;
172
+ };
173
+ /** {@link asNumberIn} restricted to integers, else `null`. */
174
+ export const asIntegerIn = (value, bounds = {}) => asInteger(asNumberIn(value, bounds));
175
+ /** A TCP/UDP port — an integer in `1..65535`, from a number or its text form. */
176
+ export const asPort = (value) => asIntegerIn(asNumeric(value), { min: 1, max: 65535 });
177
+ /**
178
+ * A whole number of arbitrary size, else `null`. Accepts a `bigint`, an integer
179
+ * `number`, or a digit string — the three shapes a 64-bit database id takes on
180
+ * its way through JSON, where it cannot survive as a `number`.
181
+ */
182
+ export const asBigInt = (value) => {
183
+ if (typeof value === 'bigint')
184
+ return value;
185
+ if (typeof value === 'number')
186
+ return Number.isInteger(value) ? BigInt(value) : null;
187
+ const str = asString(value)?.trim();
188
+ return str !== undefined && INTEGER_TEXT.test(str) ? BigInt(str) : null;
189
+ };
190
+ const INTEGER_TEXT = /^[+-]?\d+$/;
191
+ /**
192
+ * An absolute URL, returned **verbatim**, else `null`. A scheme is required, so
193
+ * `'example.com'` — which is a path, and a classic way to fetch the wrong
194
+ * thing — does not qualify.
195
+ */
196
+ export const asUrl = (value) => {
197
+ const str = asString(value);
198
+ if (str === null)
199
+ return null;
200
+ try {
201
+ new URL(str);
202
+ return str;
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ };
208
+ /**
209
+ * A bare hostname or IP address, else `null`. No scheme, port or path: this is
210
+ * what belongs in a `PGHOST`, and accepting `'localhost:5432'` there is how a
211
+ * port ends up inside a DNS lookup.
212
+ */
213
+ export const asHostname = (value) => {
214
+ const str = asString(value)?.trim();
215
+ if (str === undefined)
216
+ return null;
217
+ return HOSTNAME.test(str) || IPV6.test(str) ? str : null;
218
+ };
219
+ const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
220
+ // Deliberately loose: enough to reject a URL, a port suffix or a path, not a
221
+ // substitute for the resolver that will parse it for real.
222
+ const IPV6 = /^\[?(?:[0-9a-f]{0,4}:){2,7}[0-9a-f.]{0,4}\]?$/i;
223
+ /** An email address, else `null`. Shape only — deliverability is not syntax. */
224
+ export const asEmail = (value) => {
225
+ const str = asString(value)?.trim();
226
+ return str !== undefined && EMAIL.test(str) ? str : null;
227
+ };
228
+ const EMAIL = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
229
+ /** A UUID in canonical 8-4-4-4-12 hex form, else `null`. Case is preserved. */
230
+ export const asUuid = (value) => {
231
+ const str = asString(value)?.trim();
232
+ return str !== undefined && UUID.test(str) ? str : null;
233
+ };
234
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
235
+ /**
236
+ * A duration normalised to **milliseconds**, else `null`. Accepts a bare number
237
+ * of ms or a suffixed spelling: `500ms`, `30s`, `5m`, `2h`, `1d`, `1w`.
238
+ *
239
+ * A unit-less string is milliseconds, which is what every timeout in the house
240
+ * is already denominated in.
241
+ */
242
+ export const asDuration = (value) => {
243
+ if (typeof value === 'number')
244
+ return asNumber(value);
245
+ const str = asString(value)?.trim();
246
+ if (str === undefined)
247
+ return null;
248
+ const match = DURATION.exec(str);
249
+ if (!match)
250
+ return null;
251
+ return Number(match[1]) * DURATION_MS[(match[2] ?? 'ms').toLowerCase()];
252
+ };
253
+ const DURATION = /^([+-]?\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)?$/i;
254
+ const DURATION_MS = {
255
+ ms: 1,
256
+ s: 1_000,
257
+ m: 60_000,
258
+ h: 3_600_000,
259
+ d: 86_400_000,
260
+ w: 604_800_000
261
+ };
262
+ /**
263
+ * A JSON document — an object or an array — else `null`. A string is parsed;
264
+ * an already-parsed object or array passes through, so a value keeps coercing
265
+ * the same way on both sides of a serialisation boundary.
266
+ *
267
+ * A bare scalar is not a document: `'5'` reaching a config var that was meant
268
+ * to carry a map is a mistake worth surfacing.
269
+ */
270
+ export const asJson = (value) => {
271
+ if (Array.isArray(value))
272
+ return value;
273
+ const record = asRecord(value);
274
+ if (record !== null)
275
+ return record;
276
+ const str = asString(value);
277
+ if (str === null)
278
+ return null;
279
+ try {
280
+ return asJson(JSON.parse(str));
281
+ }
282
+ catch {
283
+ return null;
284
+ }
285
+ };
114
286
  const require_ = (as, expected) => (value, label) => {
115
287
  const coerced = as(value);
116
288
  if (coerced === null)
@@ -135,6 +307,47 @@ export const requireRecord = require_(asRecord, 'an object');
135
307
  export const requireDate = require_(asDate, 'a date');
136
308
  /** {@link asIsoString}, throwing {@link CoerceError} when absent. */
137
309
  export const requireIsoString = require_(asIsoString, 'a timestamp');
310
+ /** {@link asNumeric}, throwing {@link CoerceError} when absent. */
311
+ export const requireNumeric = require_(asNumeric, 'a number');
312
+ /** {@link asNumericInteger}, throwing {@link CoerceError} when absent. */
313
+ export const requireNumericInteger = require_(asNumericInteger, 'an integer');
314
+ /** {@link asPort}, throwing {@link CoerceError} when absent. */
315
+ export const requirePort = require_(asPort, 'a port in 1..65535');
316
+ /** {@link asBigInt}, throwing {@link CoerceError} when absent. */
317
+ export const requireBigInt = require_(asBigInt, 'a whole number');
318
+ /** {@link asUrl}, throwing {@link CoerceError} when absent. */
319
+ export const requireUrl = require_(asUrl, 'an absolute URL');
320
+ /** {@link asHostname}, throwing {@link CoerceError} when absent. */
321
+ export const requireHostname = require_(asHostname, 'a hostname or IP address');
322
+ /** {@link asEmail}, throwing {@link CoerceError} when absent. */
323
+ export const requireEmail = require_(asEmail, 'an email address');
324
+ /** {@link asUuid}, throwing {@link CoerceError} when absent. */
325
+ export const requireUuid = require_(asUuid, 'a UUID');
326
+ /** {@link asDuration}, throwing {@link CoerceError} when absent. */
327
+ export const requireDuration = require_(asDuration, 'a duration');
328
+ /** {@link asJson}, throwing {@link CoerceError} when absent. */
329
+ export const requireJson = require_(asJson, 'a JSON object or array');
330
+ /** {@link asNumberIn}, throwing {@link CoerceError} when out of range. */
331
+ export const requireNumberIn = (value, bounds, label) => {
332
+ const num = asNumberIn(value, bounds);
333
+ if (num === null)
334
+ throw new CoerceError(label, describeBounds('a number', bounds));
335
+ return num;
336
+ };
337
+ /** {@link asIntegerIn}, throwing {@link CoerceError} when out of range. */
338
+ export const requireIntegerIn = (value, bounds, label) => {
339
+ const num = asIntegerIn(value, bounds);
340
+ if (num === null)
341
+ throw new CoerceError(label, describeBounds('an integer', bounds));
342
+ return num;
343
+ };
344
+ const describeBounds = (what, { min, max }) => min !== undefined && max !== undefined
345
+ ? `${what} in ${min}..${max}`
346
+ : min !== undefined
347
+ ? `${what} >= ${min}`
348
+ : max !== undefined
349
+ ? `${what} <= ${max}`
350
+ : what;
138
351
  /**
139
352
  * Narrow a value to one of `allowed`, else `null`.
140
353
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/coerce",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Coerce untrusted unknown values into narrow TypeScript types",
6
6
  "main": "index.js",
@@ -36,5 +36,5 @@
36
36
  "validation",
37
37
  "typescript"
38
38
  ],
39
- "gitHead": "50a5ab31f64974a079bfbef6714ffa4b0044ffee"
39
+ "gitHead": "06bf3edad0d1d1dba9b44f17b8c791de6768576d"
40
40
  }