@constructive-io/coerce 0.1.0 → 0.2.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.
@@ -68,6 +74,69 @@ export declare const asDate: (value: unknown) => Date | null;
68
74
  * against.
69
75
  */
70
76
  export declare const asIsoString: (value: unknown) => string | null;
77
+ /**
78
+ * A number carried as text, else `null` — the env-var/query-string form of
79
+ * {@link asNumber}. Numbers pass through; a blank string is not a `0`, which is
80
+ * what bare `Number('')` would produce.
81
+ */
82
+ export declare const asNumeric: (value: unknown) => number | null;
83
+ /** {@link asNumeric} restricted to integers, else `null`. */
84
+ export declare const asNumericInteger: (value: unknown) => number | null;
85
+ /** Inclusive bounds for {@link asNumberIn} and {@link asIntegerIn}. */
86
+ export interface Bounds {
87
+ min?: number;
88
+ max?: number;
89
+ }
90
+ /**
91
+ * A finite number within `bounds`, else `null`.
92
+ *
93
+ * Out-of-range is a miss rather than a clamp: a `min: 1` that silently became
94
+ * `1` would turn "invalid" into a plausible value nobody configured.
95
+ */
96
+ export declare const asNumberIn: (value: unknown, { min, max }?: Bounds) => number | null;
97
+ /** {@link asNumberIn} restricted to integers, else `null`. */
98
+ export declare const asIntegerIn: (value: unknown, bounds?: Bounds) => number | null;
99
+ /** A TCP/UDP port — an integer in `1..65535`, from a number or its text form. */
100
+ export declare const asPort: (value: unknown) => number | null;
101
+ /**
102
+ * A whole number of arbitrary size, else `null`. Accepts a `bigint`, an integer
103
+ * `number`, or a digit string — the three shapes a 64-bit database id takes on
104
+ * its way through JSON, where it cannot survive as a `number`.
105
+ */
106
+ export declare const asBigInt: (value: unknown) => bigint | null;
107
+ /**
108
+ * An absolute URL, returned **verbatim**, else `null`. A scheme is required, so
109
+ * `'example.com'` — which is a path, and a classic way to fetch the wrong
110
+ * thing — does not qualify.
111
+ */
112
+ export declare const asUrl: (value: unknown) => string | null;
113
+ /**
114
+ * A bare hostname or IP address, else `null`. No scheme, port or path: this is
115
+ * what belongs in a `PGHOST`, and accepting `'localhost:5432'` there is how a
116
+ * port ends up inside a DNS lookup.
117
+ */
118
+ export declare const asHostname: (value: unknown) => string | null;
119
+ /** An email address, else `null`. Shape only — deliverability is not syntax. */
120
+ export declare const asEmail: (value: unknown) => string | null;
121
+ /** A UUID in canonical 8-4-4-4-12 hex form, else `null`. Case is preserved. */
122
+ export declare const asUuid: (value: unknown) => string | null;
123
+ /**
124
+ * A duration normalised to **milliseconds**, else `null`. Accepts a bare number
125
+ * of ms or a suffixed spelling: `500ms`, `30s`, `5m`, `2h`, `1d`, `1w`.
126
+ *
127
+ * A unit-less string is milliseconds, which is what every timeout in the house
128
+ * is already denominated in.
129
+ */
130
+ export declare const asDuration: (value: unknown) => number | null;
131
+ /**
132
+ * A JSON document — an object or an array — else `null`. A string is parsed;
133
+ * an already-parsed object or array passes through, so a value keeps coercing
134
+ * the same way on both sides of a serialisation boundary.
135
+ *
136
+ * A bare scalar is not a document: `'5'` reaching a config var that was meant
137
+ * to carry a map is a mistake worth surfacing.
138
+ */
139
+ export declare const asJson: (value: unknown) => Record<string, unknown> | unknown[] | null;
71
140
  /** {@link asString}, throwing {@link CoerceError} when absent. */
72
141
  export declare const requireString: (value: unknown, label: string) => string;
73
142
  /** {@link asNumber}, throwing {@link CoerceError} when absent. */
@@ -86,6 +155,30 @@ export declare const requireRecord: (value: unknown, label: string) => Record<st
86
155
  export declare const requireDate: (value: unknown, label: string) => Date;
87
156
  /** {@link asIsoString}, throwing {@link CoerceError} when absent. */
88
157
  export declare const requireIsoString: (value: unknown, label: string) => string;
158
+ /** {@link asNumeric}, throwing {@link CoerceError} when absent. */
159
+ export declare const requireNumeric: (value: unknown, label: string) => number;
160
+ /** {@link asNumericInteger}, throwing {@link CoerceError} when absent. */
161
+ export declare const requireNumericInteger: (value: unknown, label: string) => number;
162
+ /** {@link asPort}, throwing {@link CoerceError} when absent. */
163
+ export declare const requirePort: (value: unknown, label: string) => number;
164
+ /** {@link asBigInt}, throwing {@link CoerceError} when absent. */
165
+ export declare const requireBigInt: (value: unknown, label: string) => bigint;
166
+ /** {@link asUrl}, throwing {@link CoerceError} when absent. */
167
+ export declare const requireUrl: (value: unknown, label: string) => string;
168
+ /** {@link asHostname}, throwing {@link CoerceError} when absent. */
169
+ export declare const requireHostname: (value: unknown, label: string) => string;
170
+ /** {@link asEmail}, throwing {@link CoerceError} when absent. */
171
+ export declare const requireEmail: (value: unknown, label: string) => string;
172
+ /** {@link asUuid}, throwing {@link CoerceError} when absent. */
173
+ export declare const requireUuid: (value: unknown, label: string) => string;
174
+ /** {@link asDuration}, throwing {@link CoerceError} when absent. */
175
+ export declare const requireDuration: (value: unknown, label: string) => number;
176
+ /** {@link asJson}, throwing {@link CoerceError} when absent. */
177
+ export declare const requireJson: (value: unknown, label: string) => unknown[] | Record<string, unknown>;
178
+ /** {@link asNumberIn}, throwing {@link CoerceError} when out of range. */
179
+ export declare const requireNumberIn: (value: unknown, bounds: Bounds, label: string) => number;
180
+ /** {@link asIntegerIn}, throwing {@link CoerceError} when out of range. */
181
+ export declare const requireIntegerIn: (value: unknown, bounds: Bounds, label: string) => number;
89
182
  /**
90
183
  * Narrow a value to one of `allowed`, else `null`.
91
184
  *
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.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'`. */
@@ -124,6 +130,160 @@ exports.asDate = asDate;
124
130
  */
125
131
  const asIsoString = (value) => value instanceof Date ? (0, exports.asDate)(value)?.toISOString() ?? null : (0, exports.asString)(value);
126
132
  exports.asIsoString = asIsoString;
133
+ /**
134
+ * A number carried as text, else `null` — the env-var/query-string form of
135
+ * {@link asNumber}. Numbers pass through; a blank string is not a `0`, which is
136
+ * what bare `Number('')` would produce.
137
+ */
138
+ const asNumeric = (value) => {
139
+ if (typeof value === 'number')
140
+ return (0, exports.asNumber)(value);
141
+ const str = (0, exports.asString)(value);
142
+ return str === null ? null : (0, exports.asNumber)(Number(str));
143
+ };
144
+ exports.asNumeric = asNumeric;
145
+ /** {@link asNumeric} restricted to integers, else `null`. */
146
+ const asNumericInteger = (value) => (0, exports.asInteger)((0, exports.asNumeric)(value));
147
+ exports.asNumericInteger = asNumericInteger;
148
+ /**
149
+ * A finite number within `bounds`, else `null`.
150
+ *
151
+ * Out-of-range is a miss rather than a clamp: a `min: 1` that silently became
152
+ * `1` would turn "invalid" into a plausible value nobody configured.
153
+ */
154
+ const asNumberIn = (value, { min, max } = {}) => {
155
+ const num = (0, exports.asNumber)(value);
156
+ if (num === null)
157
+ return null;
158
+ if (min !== undefined && num < min)
159
+ return null;
160
+ if (max !== undefined && num > max)
161
+ return null;
162
+ return num;
163
+ };
164
+ exports.asNumberIn = asNumberIn;
165
+ /** {@link asNumberIn} restricted to integers, else `null`. */
166
+ const asIntegerIn = (value, bounds = {}) => (0, exports.asInteger)((0, exports.asNumberIn)(value, bounds));
167
+ exports.asIntegerIn = asIntegerIn;
168
+ /** A TCP/UDP port — an integer in `1..65535`, from a number or its text form. */
169
+ const asPort = (value) => (0, exports.asIntegerIn)((0, exports.asNumeric)(value), { min: 1, max: 65535 });
170
+ exports.asPort = asPort;
171
+ /**
172
+ * A whole number of arbitrary size, else `null`. Accepts a `bigint`, an integer
173
+ * `number`, or a digit string — the three shapes a 64-bit database id takes on
174
+ * its way through JSON, where it cannot survive as a `number`.
175
+ */
176
+ const asBigInt = (value) => {
177
+ if (typeof value === 'bigint')
178
+ return value;
179
+ if (typeof value === 'number')
180
+ return Number.isInteger(value) ? BigInt(value) : null;
181
+ const str = (0, exports.asString)(value)?.trim();
182
+ return str !== undefined && INTEGER_TEXT.test(str) ? BigInt(str) : null;
183
+ };
184
+ exports.asBigInt = asBigInt;
185
+ const INTEGER_TEXT = /^[+-]?\d+$/;
186
+ /**
187
+ * An absolute URL, returned **verbatim**, else `null`. A scheme is required, so
188
+ * `'example.com'` — which is a path, and a classic way to fetch the wrong
189
+ * thing — does not qualify.
190
+ */
191
+ const asUrl = (value) => {
192
+ const str = (0, exports.asString)(value);
193
+ if (str === null)
194
+ return null;
195
+ try {
196
+ new URL(str);
197
+ return str;
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ };
203
+ exports.asUrl = asUrl;
204
+ /**
205
+ * A bare hostname or IP address, else `null`. No scheme, port or path: this is
206
+ * what belongs in a `PGHOST`, and accepting `'localhost:5432'` there is how a
207
+ * port ends up inside a DNS lookup.
208
+ */
209
+ const asHostname = (value) => {
210
+ const str = (0, exports.asString)(value)?.trim();
211
+ if (str === undefined)
212
+ return null;
213
+ return HOSTNAME.test(str) || IPV6.test(str) ? str : null;
214
+ };
215
+ exports.asHostname = asHostname;
216
+ 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;
217
+ // Deliberately loose: enough to reject a URL, a port suffix or a path, not a
218
+ // substitute for the resolver that will parse it for real.
219
+ const IPV6 = /^\[?(?:[0-9a-f]{0,4}:){2,7}[0-9a-f.]{0,4}\]?$/i;
220
+ /** An email address, else `null`. Shape only — deliverability is not syntax. */
221
+ const asEmail = (value) => {
222
+ const str = (0, exports.asString)(value)?.trim();
223
+ return str !== undefined && EMAIL.test(str) ? str : null;
224
+ };
225
+ exports.asEmail = asEmail;
226
+ const EMAIL = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
227
+ /** A UUID in canonical 8-4-4-4-12 hex form, else `null`. Case is preserved. */
228
+ const asUuid = (value) => {
229
+ const str = (0, exports.asString)(value)?.trim();
230
+ return str !== undefined && UUID.test(str) ? str : null;
231
+ };
232
+ exports.asUuid = asUuid;
233
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
234
+ /**
235
+ * A duration normalised to **milliseconds**, else `null`. Accepts a bare number
236
+ * of ms or a suffixed spelling: `500ms`, `30s`, `5m`, `2h`, `1d`, `1w`.
237
+ *
238
+ * A unit-less string is milliseconds, which is what every timeout in the house
239
+ * is already denominated in.
240
+ */
241
+ const asDuration = (value) => {
242
+ if (typeof value === 'number')
243
+ return (0, exports.asNumber)(value);
244
+ const str = (0, exports.asString)(value)?.trim();
245
+ if (str === undefined)
246
+ return null;
247
+ const match = DURATION.exec(str);
248
+ if (!match)
249
+ return null;
250
+ return Number(match[1]) * DURATION_MS[(match[2] ?? 'ms').toLowerCase()];
251
+ };
252
+ exports.asDuration = asDuration;
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
+ const asJson = (value) => {
271
+ if (Array.isArray(value))
272
+ return value;
273
+ const record = (0, exports.asRecord)(value);
274
+ if (record !== null)
275
+ return record;
276
+ const str = (0, exports.asString)(value);
277
+ if (str === null)
278
+ return null;
279
+ try {
280
+ return (0, exports.asJson)(JSON.parse(str));
281
+ }
282
+ catch {
283
+ return null;
284
+ }
285
+ };
286
+ exports.asJson = asJson;
127
287
  const require_ = (as, expected) => (value, label) => {
128
288
  const coerced = as(value);
129
289
  if (coerced === null)
@@ -148,6 +308,49 @@ exports.requireRecord = require_(exports.asRecord, 'an object');
148
308
  exports.requireDate = require_(exports.asDate, 'a date');
149
309
  /** {@link asIsoString}, throwing {@link CoerceError} when absent. */
150
310
  exports.requireIsoString = require_(exports.asIsoString, 'a timestamp');
311
+ /** {@link asNumeric}, throwing {@link CoerceError} when absent. */
312
+ exports.requireNumeric = require_(exports.asNumeric, 'a number');
313
+ /** {@link asNumericInteger}, throwing {@link CoerceError} when absent. */
314
+ exports.requireNumericInteger = require_(exports.asNumericInteger, 'an integer');
315
+ /** {@link asPort}, throwing {@link CoerceError} when absent. */
316
+ exports.requirePort = require_(exports.asPort, 'a port in 1..65535');
317
+ /** {@link asBigInt}, throwing {@link CoerceError} when absent. */
318
+ exports.requireBigInt = require_(exports.asBigInt, 'a whole number');
319
+ /** {@link asUrl}, throwing {@link CoerceError} when absent. */
320
+ exports.requireUrl = require_(exports.asUrl, 'an absolute URL');
321
+ /** {@link asHostname}, throwing {@link CoerceError} when absent. */
322
+ exports.requireHostname = require_(exports.asHostname, 'a hostname or IP address');
323
+ /** {@link asEmail}, throwing {@link CoerceError} when absent. */
324
+ exports.requireEmail = require_(exports.asEmail, 'an email address');
325
+ /** {@link asUuid}, throwing {@link CoerceError} when absent. */
326
+ exports.requireUuid = require_(exports.asUuid, 'a UUID');
327
+ /** {@link asDuration}, throwing {@link CoerceError} when absent. */
328
+ exports.requireDuration = require_(exports.asDuration, 'a duration');
329
+ /** {@link asJson}, throwing {@link CoerceError} when absent. */
330
+ exports.requireJson = require_(exports.asJson, 'a JSON object or array');
331
+ /** {@link asNumberIn}, throwing {@link CoerceError} when out of range. */
332
+ const requireNumberIn = (value, bounds, label) => {
333
+ const num = (0, exports.asNumberIn)(value, bounds);
334
+ if (num === null)
335
+ throw new CoerceError(label, describeBounds('a number', bounds));
336
+ return num;
337
+ };
338
+ exports.requireNumberIn = requireNumberIn;
339
+ /** {@link asIntegerIn}, throwing {@link CoerceError} when out of range. */
340
+ const requireIntegerIn = (value, bounds, label) => {
341
+ const num = (0, exports.asIntegerIn)(value, bounds);
342
+ if (num === null)
343
+ throw new CoerceError(label, describeBounds('an integer', bounds));
344
+ return num;
345
+ };
346
+ exports.requireIntegerIn = requireIntegerIn;
347
+ const describeBounds = (what, { min, max }) => min !== undefined && max !== undefined
348
+ ? `${what} in ${min}..${max}`
349
+ : min !== undefined
350
+ ? `${what} >= ${min}`
351
+ : max !== undefined
352
+ ? `${what} <= ${max}`
353
+ : what;
151
354
  /**
152
355
  * Narrow a value to one of `allowed`, else `null`.
153
356
  *
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.
@@ -111,6 +117,148 @@ export const asDate = (value) => {
111
117
  * against.
112
118
  */
113
119
  export const asIsoString = (value) => value instanceof Date ? asDate(value)?.toISOString() ?? null : asString(value);
120
+ /**
121
+ * A number carried as text, else `null` — the env-var/query-string form of
122
+ * {@link asNumber}. Numbers pass through; a blank string is not a `0`, which is
123
+ * what bare `Number('')` would produce.
124
+ */
125
+ export const asNumeric = (value) => {
126
+ if (typeof value === 'number')
127
+ return asNumber(value);
128
+ const str = asString(value);
129
+ return str === null ? null : asNumber(Number(str));
130
+ };
131
+ /** {@link asNumeric} restricted to integers, else `null`. */
132
+ export const asNumericInteger = (value) => asInteger(asNumeric(value));
133
+ /**
134
+ * A finite number within `bounds`, else `null`.
135
+ *
136
+ * Out-of-range is a miss rather than a clamp: a `min: 1` that silently became
137
+ * `1` would turn "invalid" into a plausible value nobody configured.
138
+ */
139
+ export const asNumberIn = (value, { min, max } = {}) => {
140
+ const num = asNumber(value);
141
+ if (num === null)
142
+ return null;
143
+ if (min !== undefined && num < min)
144
+ return null;
145
+ if (max !== undefined && num > max)
146
+ return null;
147
+ return num;
148
+ };
149
+ /** {@link asNumberIn} restricted to integers, else `null`. */
150
+ export const asIntegerIn = (value, bounds = {}) => asInteger(asNumberIn(value, bounds));
151
+ /** A TCP/UDP port — an integer in `1..65535`, from a number or its text form. */
152
+ export const asPort = (value) => asIntegerIn(asNumeric(value), { min: 1, max: 65535 });
153
+ /**
154
+ * A whole number of arbitrary size, else `null`. Accepts a `bigint`, an integer
155
+ * `number`, or a digit string — the three shapes a 64-bit database id takes on
156
+ * its way through JSON, where it cannot survive as a `number`.
157
+ */
158
+ export const asBigInt = (value) => {
159
+ if (typeof value === 'bigint')
160
+ return value;
161
+ if (typeof value === 'number')
162
+ return Number.isInteger(value) ? BigInt(value) : null;
163
+ const str = asString(value)?.trim();
164
+ return str !== undefined && INTEGER_TEXT.test(str) ? BigInt(str) : null;
165
+ };
166
+ const INTEGER_TEXT = /^[+-]?\d+$/;
167
+ /**
168
+ * An absolute URL, returned **verbatim**, else `null`. A scheme is required, so
169
+ * `'example.com'` — which is a path, and a classic way to fetch the wrong
170
+ * thing — does not qualify.
171
+ */
172
+ export const asUrl = (value) => {
173
+ const str = asString(value);
174
+ if (str === null)
175
+ return null;
176
+ try {
177
+ new URL(str);
178
+ return str;
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ };
184
+ /**
185
+ * A bare hostname or IP address, else `null`. No scheme, port or path: this is
186
+ * what belongs in a `PGHOST`, and accepting `'localhost:5432'` there is how a
187
+ * port ends up inside a DNS lookup.
188
+ */
189
+ export const asHostname = (value) => {
190
+ const str = asString(value)?.trim();
191
+ if (str === undefined)
192
+ return null;
193
+ return HOSTNAME.test(str) || IPV6.test(str) ? str : null;
194
+ };
195
+ 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;
196
+ // Deliberately loose: enough to reject a URL, a port suffix or a path, not a
197
+ // substitute for the resolver that will parse it for real.
198
+ const IPV6 = /^\[?(?:[0-9a-f]{0,4}:){2,7}[0-9a-f.]{0,4}\]?$/i;
199
+ /** An email address, else `null`. Shape only — deliverability is not syntax. */
200
+ export const asEmail = (value) => {
201
+ const str = asString(value)?.trim();
202
+ return str !== undefined && EMAIL.test(str) ? str : null;
203
+ };
204
+ const EMAIL = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
205
+ /** A UUID in canonical 8-4-4-4-12 hex form, else `null`. Case is preserved. */
206
+ export const asUuid = (value) => {
207
+ const str = asString(value)?.trim();
208
+ return str !== undefined && UUID.test(str) ? str : null;
209
+ };
210
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
211
+ /**
212
+ * A duration normalised to **milliseconds**, else `null`. Accepts a bare number
213
+ * of ms or a suffixed spelling: `500ms`, `30s`, `5m`, `2h`, `1d`, `1w`.
214
+ *
215
+ * A unit-less string is milliseconds, which is what every timeout in the house
216
+ * is already denominated in.
217
+ */
218
+ export const asDuration = (value) => {
219
+ if (typeof value === 'number')
220
+ return asNumber(value);
221
+ const str = asString(value)?.trim();
222
+ if (str === undefined)
223
+ return null;
224
+ const match = DURATION.exec(str);
225
+ if (!match)
226
+ return null;
227
+ return Number(match[1]) * DURATION_MS[(match[2] ?? 'ms').toLowerCase()];
228
+ };
229
+ const DURATION = /^([+-]?\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)?$/i;
230
+ const DURATION_MS = {
231
+ ms: 1,
232
+ s: 1_000,
233
+ m: 60_000,
234
+ h: 3_600_000,
235
+ d: 86_400_000,
236
+ w: 604_800_000
237
+ };
238
+ /**
239
+ * A JSON document — an object or an array — else `null`. A string is parsed;
240
+ * an already-parsed object or array passes through, so a value keeps coercing
241
+ * the same way on both sides of a serialisation boundary.
242
+ *
243
+ * A bare scalar is not a document: `'5'` reaching a config var that was meant
244
+ * to carry a map is a mistake worth surfacing.
245
+ */
246
+ export const asJson = (value) => {
247
+ if (Array.isArray(value))
248
+ return value;
249
+ const record = asRecord(value);
250
+ if (record !== null)
251
+ return record;
252
+ const str = asString(value);
253
+ if (str === null)
254
+ return null;
255
+ try {
256
+ return asJson(JSON.parse(str));
257
+ }
258
+ catch {
259
+ return null;
260
+ }
261
+ };
114
262
  const require_ = (as, expected) => (value, label) => {
115
263
  const coerced = as(value);
116
264
  if (coerced === null)
@@ -135,6 +283,47 @@ export const requireRecord = require_(asRecord, 'an object');
135
283
  export const requireDate = require_(asDate, 'a date');
136
284
  /** {@link asIsoString}, throwing {@link CoerceError} when absent. */
137
285
  export const requireIsoString = require_(asIsoString, 'a timestamp');
286
+ /** {@link asNumeric}, throwing {@link CoerceError} when absent. */
287
+ export const requireNumeric = require_(asNumeric, 'a number');
288
+ /** {@link asNumericInteger}, throwing {@link CoerceError} when absent. */
289
+ export const requireNumericInteger = require_(asNumericInteger, 'an integer');
290
+ /** {@link asPort}, throwing {@link CoerceError} when absent. */
291
+ export const requirePort = require_(asPort, 'a port in 1..65535');
292
+ /** {@link asBigInt}, throwing {@link CoerceError} when absent. */
293
+ export const requireBigInt = require_(asBigInt, 'a whole number');
294
+ /** {@link asUrl}, throwing {@link CoerceError} when absent. */
295
+ export const requireUrl = require_(asUrl, 'an absolute URL');
296
+ /** {@link asHostname}, throwing {@link CoerceError} when absent. */
297
+ export const requireHostname = require_(asHostname, 'a hostname or IP address');
298
+ /** {@link asEmail}, throwing {@link CoerceError} when absent. */
299
+ export const requireEmail = require_(asEmail, 'an email address');
300
+ /** {@link asUuid}, throwing {@link CoerceError} when absent. */
301
+ export const requireUuid = require_(asUuid, 'a UUID');
302
+ /** {@link asDuration}, throwing {@link CoerceError} when absent. */
303
+ export const requireDuration = require_(asDuration, 'a duration');
304
+ /** {@link asJson}, throwing {@link CoerceError} when absent. */
305
+ export const requireJson = require_(asJson, 'a JSON object or array');
306
+ /** {@link asNumberIn}, throwing {@link CoerceError} when out of range. */
307
+ export const requireNumberIn = (value, bounds, label) => {
308
+ const num = asNumberIn(value, bounds);
309
+ if (num === null)
310
+ throw new CoerceError(label, describeBounds('a number', bounds));
311
+ return num;
312
+ };
313
+ /** {@link asIntegerIn}, throwing {@link CoerceError} when out of range. */
314
+ export const requireIntegerIn = (value, bounds, label) => {
315
+ const num = asIntegerIn(value, bounds);
316
+ if (num === null)
317
+ throw new CoerceError(label, describeBounds('an integer', bounds));
318
+ return num;
319
+ };
320
+ const describeBounds = (what, { min, max }) => min !== undefined && max !== undefined
321
+ ? `${what} in ${min}..${max}`
322
+ : min !== undefined
323
+ ? `${what} >= ${min}`
324
+ : max !== undefined
325
+ ? `${what} <= ${max}`
326
+ : what;
138
327
  /**
139
328
  * Narrow a value to one of `allowed`, else `null`.
140
329
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/coerce",
3
- "version": "0.1.0",
3
+ "version": "0.2.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": "10ee73e43c2277405c0e19423e2075baef6c2755"
40
40
  }