@orkestrel/validator 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 ADDED
@@ -0,0 +1,948 @@
1
+ # @orkestrel/validator
2
+
3
+ > Runtime validation toolkit for TypeScript — zero dependencies, fully typed.
4
+
5
+ `@orkestrel/validator` gives you two independent tools that work together:
6
+
7
+ - **String validation** — a declarative rule engine for validating user input. Define which rules apply, call `validateInput`, and get back a structured result with every failing rule and its message.
8
+ - **Type guards** — a composable system for narrowing `unknown` values to precise TypeScript types at runtime. Over 60 built-in `is*` guards plus a full set of builder functions for constructing guards over objects, arrays, tuples, enums, and more.
9
+
10
+ Both axes are ESM-first, tree-shakeable, and produce no side effects.
11
+
12
+ ---
13
+
14
+ ## Installation
15
+
16
+ ```sh
17
+ npm install @orkestrel/validator
18
+ ```
19
+
20
+ All exports are available from the package root:
21
+
22
+ ```ts
23
+ import { validateInput, isString, objectOf } from '@orkestrel/validator'
24
+ ```
25
+
26
+ ---
27
+
28
+ ## String Validation
29
+
30
+ Use the rule engine when you need to validate free-form user input — form fields, query parameters, CLI arguments — and want structured error feedback.
31
+
32
+ ### Defining rules
33
+
34
+ Describe constraints as a plain object:
35
+
36
+ ```ts
37
+ import type { ValidationRules } from '@orkestrel/validator'
38
+
39
+ const usernameRules: ValidationRules = {
40
+ required: true,
41
+ minimum: 3,
42
+ maximum: 20,
43
+ alphanumeric: true,
44
+ }
45
+
46
+ const emailRules: ValidationRules = {
47
+ required: true,
48
+ email: true,
49
+ }
50
+
51
+ const commentRules: ValidationRules = {
52
+ required: true,
53
+ minimum: 1,
54
+ maximum: 500,
55
+ }
56
+ ```
57
+
58
+ ### Validating input
59
+
60
+ ```ts
61
+ import { validateInput } from '@orkestrel/validator'
62
+
63
+ const result = validateInput('Ada123', usernameRules)
64
+
65
+ if (result.valid) {
66
+ // result.errors is []
67
+ } else {
68
+ for (const error of result.errors) {
69
+ console.log(error.rule, error.message)
70
+ }
71
+ }
72
+ ```
73
+
74
+ `validateInput` evaluates every active rule and collects **all** failures — it does not stop at the first one. The returned `ValidationResult` always has a `valid` boolean and an `errors` array.
75
+
76
+ ### Quick pass/fail check
77
+
78
+ When you only need a boolean and don't need the error list:
79
+
80
+ ```ts
81
+ import { testInput } from '@orkestrel/validator'
82
+
83
+ if (testInput('Ada123', usernameRules)) {
84
+ // input is valid
85
+ }
86
+ ```
87
+
88
+ ### Built-in rules
89
+
90
+ | Rule | Value | Fails when |
91
+ | -------------- | ------------------- | -------------------------------------------------------- |
92
+ | `required` | `true` | The trimmed input is empty |
93
+ | `minimum` | `number ≥ 0` | `input.length < minimum` |
94
+ | `maximum` | `number ≥ 0` | `input.length > maximum` |
95
+ | `pattern` | regex string | Input does not match the compiled pattern |
96
+ | `email` | `true` | Input does not look like `local@domain.tld` |
97
+ | `url` | `true` | Input does not begin with `http://` or `https://` |
98
+ | `numeric` | `true` | Input is not an integer or decimal (optionally negative) |
99
+ | `integer` | `true` | Input is not a whole integer (optionally negative) |
100
+ | `alphanumeric` | `true` | Input contains anything other than letters and digits |
101
+ | `custom` | `ValidatorFunction` | The function returns `false` or an error string |
102
+
103
+ Rules set to `false` or omitted are skipped. Rules are always evaluated in this order:
104
+ `required → minimum → maximum → pattern → email → url → numeric → integer → alphanumeric → custom`
105
+
106
+ ### Default error messages
107
+
108
+ Each built-in rule produces a default message when it fails:
109
+
110
+ | Rule | Default message |
111
+ | -------------- | ---------------------------------------- |
112
+ | `required` | `'This field is required'` |
113
+ | `minimum` | `'Must be at least N characters'` |
114
+ | `maximum` | `'Must be at most N characters'` |
115
+ | `pattern` | `'Must match pattern: <pattern>'` |
116
+ | `email` | `'Must be a valid email address'` |
117
+ | `url` | `'Must be a valid URL'` |
118
+ | `numeric` | `'Must be a numeric value'` |
119
+ | `integer` | `'Must be an integer'` |
120
+ | `alphanumeric` | `'Must contain only letters and digits'` |
121
+
122
+ ### Custom validators
123
+
124
+ Every rule slot accepts a `ValidatorFunction` instead of its canonical value type. The function replaces the built-in check entirely for that slot.
125
+
126
+ ```ts
127
+ type ValidatorFunction = (input: string) => boolean | string
128
+ ```
129
+
130
+ Return `true` when the input passes. Return `false` or a message string when it fails.
131
+
132
+ ```ts
133
+ const rules: ValidationRules = {
134
+ // Replace the built-in minimum check with custom logic
135
+ minimum: (input) => input.startsWith('z') || 'Must start with z',
136
+
137
+ // The custom slot has no built-in behaviour — always use a function
138
+ custom: (input) => input !== 'forbidden' || 'That value is not allowed',
139
+ }
140
+ ```
141
+
142
+ Using a function on a rule slot still uses that slot's position in the evaluation order — a `minimum` function runs in the `minimum` position.
143
+
144
+ ### Collecting multiple errors
145
+
146
+ ```ts
147
+ const result = validateInput('', {
148
+ required: true,
149
+ minimum: 3,
150
+ email: true,
151
+ })
152
+
153
+ // {
154
+ // valid: false,
155
+ // errors: [
156
+ // { rule: 'required', message: 'This field is required' },
157
+ // { rule: 'minimum', message: 'Must be at least 3 characters' },
158
+ // { rule: 'email', message: 'Must be a valid email address' },
159
+ // ]
160
+ // }
161
+ ```
162
+
163
+ ### Validating rule objects at runtime
164
+
165
+ If rules arrive from an untrusted source (config file, API), assert them before use:
166
+
167
+ ```ts
168
+ import { assertValidationRules } from '@orkestrel/validator'
169
+
170
+ assertValidationRules(untrustedRules) // throws ValidatorError when invalid
171
+ // now untrustedRules is narrowed to ValidationRules
172
+ ```
173
+
174
+ `assertValidationRules` checks:
175
+
176
+ - All keys are supported rule names
177
+ - All values are the correct kind for their rule (`boolean` for flag rules, `number ≥ 0` for `minimum`/`maximum`, string for `pattern`)
178
+ - `minimum ≤ maximum` when both are provided as numbers
179
+ - `pattern` compiles as a valid regular expression
180
+
181
+ ### ValidatorError
182
+
183
+ Invalid rule configuration throws a `ValidatorError` — a subclass of `Error` with two extra fields:
184
+
185
+ ```ts
186
+ import { ValidatorError, isValidatorError } from '@orkestrel/validator'
187
+
188
+ try {
189
+ assertValidationRules({ minimum: 10, maximum: 3 })
190
+ } catch (error) {
191
+ if (isValidatorError(error)) {
192
+ console.log(error.code) // 'INVALID_RULE_VALUE'
193
+ console.log(error.message) // 'Minimum cannot be greater than maximum'
194
+ console.log(error.context.rule) // 'minimum'
195
+ console.log(error.context.value) // 10
196
+ }
197
+ }
198
+ ```
199
+
200
+ The three error codes:
201
+
202
+ | Code | When thrown |
203
+ | -------------------- | -------------------------------------------------------------------- |
204
+ | `INVALID_RULES` | The rule object has unsupported keys or wrong value kinds |
205
+ | `INVALID_RULE_VALUE` | A numeric rule is out of range (`minimum > maximum`, negative value) |
206
+ | `INVALID_PATTERN` | The `pattern` string is not a valid regular expression |
207
+
208
+ Use `isValidatorError` in `catch` blocks to distinguish package errors from unexpected throws.
209
+
210
+ ---
211
+
212
+ ## Type Guards
213
+
214
+ Use type guards when you need to narrow `unknown` values — incoming API responses, `JSON.parse` output, event payloads, dynamic config — to specific TypeScript types.
215
+
216
+ All guards follow the same contract:
217
+
218
+ ```ts
219
+ // Returns true and narrows the type — never throws
220
+ isString(value) // value is string
221
+ isNumber(value) // value is number
222
+ ```
223
+
224
+ ### Primitive guards
225
+
226
+ ```ts
227
+ import {
228
+ isNull,
229
+ isUndefined,
230
+ isDefined,
231
+ isString,
232
+ isNumber,
233
+ isBoolean,
234
+ isBigInt,
235
+ isSymbol,
236
+ isFunction,
237
+ isDate,
238
+ isRegExp,
239
+ isError,
240
+ isPromise,
241
+ isPromiseLike,
242
+ isIterable,
243
+ isAsyncIterator,
244
+ isArrayBuffer,
245
+ isSharedArrayBuffer,
246
+ } from '@orkestrel/validator'
247
+ ```
248
+
249
+ | Guard | Narrows to | Notes |
250
+ | ------------------------ | ------------------------ | ----------------------------------------- |
251
+ | `isNull(v)` | `null` | Strict `=== null` |
252
+ | `isUndefined(v)` | `undefined` | Strict `=== undefined` |
253
+ | `isDefined(v)` | `T` | Excludes both `null` and `undefined` |
254
+ | `isString(v)` | `string` | `typeof === 'string'` |
255
+ | `isNumber(v)` | `number` | Includes `NaN` and `Infinity` |
256
+ | `isBoolean(v)` | `boolean` | |
257
+ | `isBigInt(v)` | `bigint` | |
258
+ | `isSymbol(v)` | `symbol` | |
259
+ | `isFunction(v)` | `AnyFunction` | `typeof === 'function'` |
260
+ | `isDate(v)` | `Date` | `instanceof Date` |
261
+ | `isRegExp(v)` | `RegExp` | `instanceof RegExp` |
262
+ | `isError(v)` | `Error` | `instanceof Error` |
263
+ | `isPromise(v)` | `Promise<T>` | Native `instanceof Promise` only |
264
+ | `isPromiseLike(v)` | `Promise<T>` or thenable | Checks `then`, `catch`, `finally` methods |
265
+ | `isIterable(v)` | `Iterable<T>` | Strings are iterable |
266
+ | `isAsyncIterator(v)` | `AsyncIterable<T>` | Checks `Symbol.asyncIterator` |
267
+ | `isArrayBuffer(v)` | `ArrayBuffer` | |
268
+ | `isSharedArrayBuffer(v)` | `SharedArrayBuffer` | Feature-checks availability first |
269
+
270
+ ```ts
271
+ const raw: unknown = JSON.parse(text)
272
+
273
+ if (isString(raw)) {
274
+ // raw: string
275
+ }
276
+
277
+ // isDefined removes both null and undefined
278
+ function process<T>(value: T | null | undefined) {
279
+ if (isDefined(value)) {
280
+ // value: T
281
+ }
282
+ }
283
+ ```
284
+
285
+ ### Object and collection guards
286
+
287
+ ```ts
288
+ import { isObject, isRecord, isMap, isSet, isWeakMap, isWeakSet } from '@orkestrel/validator'
289
+ ```
290
+
291
+ | Guard | Narrows to | Notes |
292
+ | -------------- | -------------------------- | ------------------------------------- |
293
+ | `isObject(v)` | `object` | Any non-null object, including arrays |
294
+ | `isRecord(v)` | `Record<string, unknown>` | Non-null, non-array object |
295
+ | `isMap(v)` | `ReadonlyMap<K, V>` | `instanceof Map` |
296
+ | `isSet(v)` | `ReadonlySet<T>` | `instanceof Set` |
297
+ | `isWeakMap(v)` | `WeakMap<object, unknown>` | |
298
+ | `isWeakSet(v)` | `WeakSet<object>` | |
299
+
300
+ `isRecord` is the right choice for plain objects — it excludes arrays:
301
+
302
+ ```ts
303
+ isObject([]) // true — arrays are objects
304
+ isRecord([]) // false — arrays excluded
305
+ isRecord({}) // true
306
+ isRecord(null) // false
307
+ ```
308
+
309
+ ### Array and typed array guards
310
+
311
+ ```ts
312
+ import {
313
+ isArray,
314
+ isDataView,
315
+ isArrayBufferView,
316
+ isUint8Array,
317
+ isInt32Array,
318
+ isFloat64Array,
319
+ // ... all 11 typed-array guards
320
+ } from '@orkestrel/validator'
321
+ ```
322
+
323
+ `isArray` wraps `Array.isArray`. For element-level checking use `arrayOf` from the guard builders section.
324
+
325
+ `isArrayBufferView` accepts any typed array or `DataView` via `ArrayBuffer.isView`.
326
+
327
+ All 11 typed-array guards: `isInt8Array`, `isUint8Array`, `isUint8ClampedArray`, `isInt16Array`, `isUint16Array`, `isInt32Array`, `isUint32Array`, `isFloat32Array`, `isFloat64Array`, `isBigInt64Array`, `isBigUint64Array`.
328
+
329
+ ### Emptiness guards
330
+
331
+ ```ts
332
+ import {
333
+ isEmptyString,
334
+ isEmptyArray,
335
+ isEmptyObject,
336
+ isEmptyMap,
337
+ isEmptySet,
338
+ isNonEmptyString,
339
+ isNonEmptyArray,
340
+ isNonEmptyObject,
341
+ isNonEmptyMap,
342
+ isNonEmptySet,
343
+ } from '@orkestrel/validator'
344
+ ```
345
+
346
+ **Empty:**
347
+
348
+ | Guard | Narrows to |
349
+ | ------------------ | --------------------------------- |
350
+ | `isEmptyString(v)` | `''` |
351
+ | `isEmptyArray(v)` | `readonly []` |
352
+ | `isEmptyObject(v)` | `Record<string \| symbol, never>` |
353
+ | `isEmptyMap(v)` | `ReadonlyMap<never, never>` |
354
+ | `isEmptySet(v)` | `ReadonlySet<never>` |
355
+
356
+ **Non-empty:**
357
+
358
+ | Guard | Narrows to |
359
+ | --------------------- | ----------------------------------- |
360
+ | `isNonEmptyString(v)` | `string` (length > 0) |
361
+ | `isNonEmptyArray(v)` | `readonly [T, ...T[]]` |
362
+ | `isNonEmptyObject(v)` | `Record<string \| symbol, unknown>` |
363
+ | `isNonEmptyMap(v)` | `ReadonlyMap<K, V>` |
364
+ | `isNonEmptySet(v)` | `ReadonlySet<T>` |
365
+
366
+ `isEmptyObject` counts both string keys and **enumerable symbol keys** — an object with only a symbol property is not considered empty:
367
+
368
+ ```ts
369
+ isEmptyObject({}) // true
370
+ isEmptyObject({ [Symbol('x')]: true }) // false
371
+ ```
372
+
373
+ ### Function guards
374
+
375
+ ```ts
376
+ import {
377
+ isAsyncFunction,
378
+ isGeneratorFunction,
379
+ isAsyncGeneratorFunction,
380
+ isZeroArg,
381
+ isZeroArgAsync,
382
+ isZeroArgGenerator,
383
+ isZeroArgAsyncGenerator,
384
+ } from '@orkestrel/validator'
385
+ ```
386
+
387
+ | Guard | Passes when |
388
+ | ------------------------------ | ---------------------------------- |
389
+ | `isZeroArg(fn)` | `fn.length === 0` |
390
+ | `isAsyncFunction(fn)` | Native `async function` |
391
+ | `isGeneratorFunction(fn)` | `function*` |
392
+ | `isAsyncGeneratorFunction(fn)` | `async function*` |
393
+ | `isZeroArgAsync(fn)` | Async and zero arguments |
394
+ | `isZeroArgGenerator(fn)` | Generator and zero arguments |
395
+ | `isZeroArgAsyncGenerator(fn)` | Async generator and zero arguments |
396
+
397
+ ```ts
398
+ isAsyncFunction(async () => true) // true
399
+ isAsyncFunction(() => Promise.resolve(true)) // false — sync, returns a Promise
400
+ isGeneratorFunction(function* () {
401
+ yield 1
402
+ }) // true
403
+ ```
404
+
405
+ > **Note:** Detection uses `constructor.name`. Minifiers that rename function constructors will break these guards.
406
+
407
+ ---
408
+
409
+ ## Guard Builders
410
+
411
+ Guard builders compose primitive guards into guards for complex shapes. They are the primary API for narrowing structured data like API responses.
412
+
413
+ ### Building object schemas — `objectOf`
414
+
415
+ ```ts
416
+ import { objectOf, isString, isNumber } from '@orkestrel/validator'
417
+
418
+ const isUser = objectOf({ id: isString, age: isNumber })
419
+
420
+ const raw: unknown = await fetchUser()
421
+ if (isUser(raw)) {
422
+ // raw: Readonly<{ id: string; age: number }>
423
+ console.log(raw.id, raw.age)
424
+ }
425
+ ```
426
+
427
+ `objectOf` is **exact** by default: it rejects any value with extra string keys not in the shape. Symbol keys on the value are silently ignored.
428
+
429
+ #### Optional keys
430
+
431
+ Pass an array of key names to make those keys optional:
432
+
433
+ ```ts
434
+ const isUser = objectOf(
435
+ { id: isString, role: isString, note: isString },
436
+ ['note'], // note may be absent
437
+ )
438
+
439
+ isUser({ id: 'u1', role: 'admin' }) // true
440
+ isUser({ id: 'u1', role: 'admin', note: 'hi' }) // true
441
+ isUser({ id: 'u1', role: 'admin', note: 1 }) // false — note fails its guard
442
+ ```
443
+
444
+ Pass `true` to make every key optional:
445
+
446
+ ```ts
447
+ const isPartialUser = objectOf({ id: isString, age: isNumber }, true)
448
+ isPartialUser({}) // true
449
+ isPartialUser({ id: 'u1' }) // true
450
+ ```
451
+
452
+ #### `recordOf`
453
+
454
+ Identical signature to `objectOf`. Use `recordOf` when the value is a dictionary-style record rather than a structured entity — purely a naming convention for readability.
455
+
456
+ ### Arrays — `arrayOf`
457
+
458
+ ```ts
459
+ import { arrayOf, isString } from '@orkestrel/validator'
460
+
461
+ const isStrings = arrayOf(isString)
462
+ isStrings(['a', 'b']) // true
463
+ isStrings(['a', 1]) // false
464
+ ```
465
+
466
+ ### Tuples — `tupleOf`
467
+
468
+ Validates a fixed-length array with per-index guards:
469
+
470
+ ```ts
471
+ import { tupleOf, isString, isNumber } from '@orkestrel/validator'
472
+
473
+ const isEntry = tupleOf(isString, isNumber)
474
+ isEntry(['id', 42]) // true
475
+ isEntry(['id', 'x']) // false — second element fails
476
+ isEntry(['id']) // false — length mismatch
477
+ ```
478
+
479
+ ### Literals and enums — `literalOf`, `enumOf`
480
+
481
+ ```ts
482
+ import { literalOf, enumOf } from '@orkestrel/validator'
483
+
484
+ // Narrow to a fixed set of literal values
485
+ const isRole = literalOf('user', 'admin', 'guest')
486
+ isRole('admin') // true
487
+ isRole('owner') // false
488
+
489
+ // Narrow to a TypeScript enum
490
+ enum Status {
491
+ Idle,
492
+ Busy,
493
+ }
494
+ const isStatus = enumOf(Status)
495
+ isStatus(Status.Idle) // true
496
+ isStatus(0) // true — numeric enum value
497
+ isStatus('Idle') // false — reverse-mapped names are not values
498
+
499
+ // String enum
500
+ const isFlag = enumOf({ active: 'ACTIVE', paused: 'PAUSED' })
501
+ isFlag('ACTIVE') // true
502
+ isFlag('active') // false — keys are not values
503
+ ```
504
+
505
+ ### Sets and Maps — `setOf`, `mapOf`
506
+
507
+ ```ts
508
+ import { setOf, mapOf, isString, isNumber } from '@orkestrel/validator'
509
+
510
+ const isTagSet = setOf(isString)
511
+ const isScores = mapOf(isString, isNumber)
512
+
513
+ isTagSet(new Set(['a', 'b'])) // true
514
+ isTagSet(new Set(['a', 1])) // false
515
+ isScores(new Map([['alice', 98]])) // true
516
+ isScores(new Map([['alice', '98']])) // false
517
+ ```
518
+
519
+ ### Iterables — `iterableOf`
520
+
521
+ Accepts any value with `Symbol.iterator` — arrays, sets, generators — and validates every yielded element:
522
+
523
+ ```ts
524
+ import { iterableOf, isNumber } from '@orkestrel/validator'
525
+
526
+ const isNumbers = iterableOf(isNumber)
527
+ isNumbers([1, 2, 3]) // true
528
+ isNumbers(new Set([1, 2])) // true
529
+ isNumbers([1, '2']) // false
530
+ ```
531
+
532
+ ### Keys and instance checks — `keyOf`, `instanceOf`
533
+
534
+ ```ts
535
+ import { keyOf, instanceOf } from '@orkestrel/validator'
536
+
537
+ // Accept only keys present on a specific object
538
+ const COLORS = { red: '#f00', blue: '#00f', green: '#0f0' } as const
539
+ const isColorKey = keyOf(COLORS)
540
+ isColorKey('red') // true
541
+ isColorKey('yellow') // false
542
+
543
+ // Accept instances of a class
544
+ class ApiError extends Error {
545
+ constructor(
546
+ readonly status: number,
547
+ message: string,
548
+ ) {
549
+ super(message)
550
+ }
551
+ }
552
+ const isApiError = instanceOf(ApiError)
553
+ isApiError(new ApiError(404, 'Not found')) // true
554
+ isApiError(new Error('generic')) // false
555
+ ```
556
+
557
+ ### Shape transforms — `pickOf`, `omitOf`
558
+
559
+ Build a new guard shape by selecting or removing keys from an existing one. The result is a `GuardsShape` — pass it to `objectOf` or `recordOf`.
560
+
561
+ ```ts
562
+ import { pickOf, omitOf, objectOf, isString, isNumber } from '@orkestrel/validator'
563
+
564
+ const userShape = { id: isString, age: isNumber, name: isString, role: isString }
565
+
566
+ // Keep only the keys you need
567
+ const isPublicUser = objectOf(pickOf(userShape, ['id', 'name']))
568
+ isPublicUser({ id: 'u1', name: 'Ada' }) // true
569
+ isPublicUser({ id: 'u1', name: 'Ada', age: 30 }) // false — exact shape, extra key rejected
570
+
571
+ // Remove keys you don't want
572
+ const isUserWithoutAge = objectOf(omitOf(userShape, ['age']))
573
+ isUserWithoutAge({ id: 'u1', name: 'Ada', role: 'admin' }) // true
574
+ ```
575
+
576
+ ---
577
+
578
+ ## Logical Combinators
579
+
580
+ ### Refinement — `whereOf`
581
+
582
+ The most common combinator. Refines a base guard with an additional predicate. The predicate receives a typed value (the base guard already narrowed it), so TypeScript provides full autocomplete.
583
+
584
+ ```ts
585
+ import { whereOf, isString, testInput } from '@orkestrel/validator'
586
+
587
+ // Simple predicate
588
+ const isNonEmptyString = whereOf(isString, (value) => value.length > 0)
589
+
590
+ // Embed a rule check inside a guard
591
+ const usernameRules = { required: true, minimum: 3, maximum: 20, alphanumeric: true }
592
+ const isUsername = whereOf(isString, (value) => testInput(value, usernameRules))
593
+ ```
594
+
595
+ ### AND — `andOf`
596
+
597
+ ```ts
598
+ import { andOf, isString } from '@orkestrel/validator'
599
+
600
+ const isNonEmptyString = andOf(isString, (value: string) => value.length > 0)
601
+ ```
602
+
603
+ ### OR — `orOf`
604
+
605
+ ```ts
606
+ import { orOf, isString, isNumber } from '@orkestrel/validator'
607
+
608
+ const isStringOrNumber = orOf(isString, isNumber)
609
+ ```
610
+
611
+ ### NOT — `notOf`
612
+
613
+ ```ts
614
+ import { notOf, isString } from '@orkestrel/validator'
615
+
616
+ const isNotString = notOf(isString)
617
+ ```
618
+
619
+ ### Union — `unionOf`
620
+
621
+ Variadic OR across any number of guards:
622
+
623
+ ```ts
624
+ import { unionOf, literalOf } from '@orkestrel/validator'
625
+
626
+ const isDirection = unionOf(
627
+ literalOf('north'),
628
+ literalOf('south'),
629
+ literalOf('east'),
630
+ literalOf('west'),
631
+ )
632
+ ```
633
+
634
+ ### Intersection — `intersectionOf` and `composedOf`
635
+
636
+ Both require every guard to pass. Use `intersectionOf` for true type intersections; use `composedOf` when layering constraints on the same base type:
637
+
638
+ ```ts
639
+ import { intersectionOf, composedOf, isString } from '@orkestrel/validator'
640
+
641
+ const isShortString = intersectionOf(
642
+ isString,
643
+ (value: unknown): value is string => isString(value) && value.length < 10,
644
+ )
645
+
646
+ // composedOf reads more clearly when all guards share the same type
647
+ const isAlphaCode = composedOf(
648
+ (value: unknown): value is string => isString(value) && /^[A-Za-z]+$/.test(value),
649
+ (value: unknown): value is string => isString(value) && value.length === 2,
650
+ )
651
+ isAlphaCode('en') // true
652
+ isAlphaCode('en1') // false
653
+ ```
654
+
655
+ ### Complement — `complementOf`
656
+
657
+ Subtracts a narrower type from a broader one — passes when the value satisfies the base guard but not the excluded guard:
658
+
659
+ ```ts
660
+ import { complementOf, unionOf, objectOf, literalOf, isNumber } from '@orkestrel/validator'
661
+
662
+ const isCircle = objectOf({ kind: literalOf('circle'), radius: isNumber })
663
+ const isRectangle = objectOf({ kind: literalOf('rectangle'), width: isNumber, height: isNumber })
664
+ const isShape = unionOf(isCircle, isRectangle)
665
+ const isNotCircle = complementOf(isShape, isCircle)
666
+
667
+ isNotCircle({ kind: 'rectangle', width: 2, height: 3 }) // true
668
+ isNotCircle({ kind: 'circle', radius: 3 }) // false
669
+ ```
670
+
671
+ ### Nullable — `nullableOf`
672
+
673
+ Extends any guard to also accept `null`:
674
+
675
+ ```ts
676
+ import { nullableOf, isString } from '@orkestrel/validator'
677
+
678
+ const isMaybeString = nullableOf(isString)
679
+ isMaybeString(null) // true
680
+ isMaybeString('value') // true
681
+ isMaybeString(undefined) // false
682
+ ```
683
+
684
+ ### Transform — `transformOf`
685
+
686
+ Validates the original input by projecting it and checking the projected value. The original (not the projection) is what gets narrowed:
687
+
688
+ ```ts
689
+ import { transformOf, isString, isNumber } from '@orkestrel/validator'
690
+
691
+ // Accept strings with a positive character count
692
+ const isNonEmptyString = transformOf(
693
+ isString,
694
+ (value) => value.length,
695
+ (n): n is number => typeof n === 'number' && n > 0,
696
+ )
697
+ isNonEmptyString('abc') // true
698
+ isNonEmptyString('') // false
699
+ ```
700
+
701
+ ### Lazy — `lazyOf`
702
+
703
+ Defers guard creation until first call. Required when building guards for **recursive types**, because the guard variable isn't defined yet at the point of the `objectOf` call:
704
+
705
+ ```ts
706
+ import type { Guard } from '@orkestrel/validator'
707
+ import { lazyOf, objectOf, arrayOf, isNumber } from '@orkestrel/validator'
708
+
709
+ interface Tree {
710
+ readonly value: number
711
+ readonly children?: readonly Tree[]
712
+ }
713
+
714
+ const isTree: Guard<Tree> = lazyOf(() =>
715
+ objectOf(
716
+ {
717
+ value: isNumber,
718
+ children: arrayOf(isTree), // safe — isTree is resolved at call time
719
+ },
720
+ ['children'],
721
+ ),
722
+ )
723
+
724
+ isTree({ value: 1, children: [{ value: 2 }] }) // true
725
+ isTree({ value: 'x' }) // false
726
+ ```
727
+
728
+ ---
729
+
730
+ ## Combining Guards and Validation
731
+
732
+ The two axes compose naturally. Use `whereOf` with `testInput` to embed field-level validation inside a structural guard:
733
+
734
+ ```ts
735
+ import {
736
+ objectOf,
737
+ arrayOf,
738
+ literalOf,
739
+ isString,
740
+ whereOf,
741
+ testInput,
742
+ validateInput,
743
+ } from '@orkestrel/validator'
744
+
745
+ const usernameRules = { required: true, minimum: 3, maximum: 20, alphanumeric: true }
746
+ const emailRules = { required: true, email: true }
747
+
748
+ const isSignupForm = objectOf(
749
+ {
750
+ username: whereOf(isString, (v) => testInput(v, usernameRules)),
751
+ email: whereOf(isString, (v) => testInput(v, emailRules)),
752
+ role: literalOf('user', 'admin'),
753
+ tags: arrayOf(whereOf(isString, (v) => v.length > 0)),
754
+ },
755
+ ['tags'], // tags is optional
756
+ )
757
+
758
+ const payload: unknown = JSON.parse(request.body)
759
+
760
+ if (isSignupForm(payload)) {
761
+ // payload is fully typed:
762
+ // {
763
+ // username: string
764
+ // email: string
765
+ // role: 'user' | 'admin'
766
+ // tags?: readonly string[]
767
+ // }
768
+ submit(payload)
769
+ } else {
770
+ // Get per-field error details with validateInput
771
+ const usernameResult = validateInput(isString(payload) ? payload : '', usernameRules)
772
+ return { errors: usernameResult.errors }
773
+ }
774
+ ```
775
+
776
+ ---
777
+
778
+ ## TypeScript Types
779
+
780
+ These types are exported for use in your own function signatures.
781
+
782
+ ### Guard types
783
+
784
+ ```ts
785
+ import type { Guard, GuardType, GuardsShape } from '@orkestrel/validator'
786
+
787
+ // Guard<T> — the type predicate signature
788
+ const isRole: Guard<'user' | 'admin'> = literalOf('user', 'admin')
789
+
790
+ // GuardType — extract T from a Guard<T>
791
+ type Role = GuardType<typeof isRole> // 'user' | 'admin'
792
+
793
+ // GuardsShape — a map of string keys to guards, input to objectOf / recordOf
794
+ const userShape: GuardsShape = { id: isString, age: isNumber }
795
+ ```
796
+
797
+ ### Output types from shapes
798
+
799
+ ```ts
800
+ import type { FromGuards, OptionalFromGuards, AllOptionalFromGuards } from '@orkestrel/validator'
801
+
802
+ const userShape = { id: isString, age: isNumber, note: isString }
803
+
804
+ // All keys required
805
+ type User = FromGuards<typeof userShape>
806
+ // Readonly<{ id: string; age: number; note: string }>
807
+
808
+ // Selected keys optional
809
+ type FlexUser = OptionalFromGuards<typeof userShape, ['note']>
810
+ // Readonly<{ id: string; age: number; note?: string }>
811
+
812
+ // All keys optional
813
+ type PartialUser = AllOptionalFromGuards<typeof userShape>
814
+ // Readonly<Partial<{ id: string; age: number; note: string }>>
815
+ ```
816
+
817
+ ### Validation types
818
+
819
+ ```ts
820
+ import type {
821
+ ValidationRules,
822
+ ValidationResult,
823
+ ValidationError,
824
+ ValidationRuleName,
825
+ ValidatorFunction,
826
+ ValidatorErrorCode,
827
+ ValidatorErrorContext,
828
+ } from '@orkestrel/validator'
829
+ ```
830
+
831
+ ### Function type aliases
832
+
833
+ ```ts
834
+ import type {
835
+ AnyFunction, // (...args: unknown[]) => unknown
836
+ AnyAsyncFunction, // (...args: unknown[]) => Promise<unknown>
837
+ ZeroArgFunction, // () => unknown
838
+ ZeroArgAsyncFunction, // () => Promise<unknown>
839
+ AnyConstructor, // new (...args: never[]) => T
840
+ } from '@orkestrel/validator'
841
+ ```
842
+
843
+ ---
844
+
845
+ ## Low-level APIs
846
+
847
+ These are exported for advanced use cases but most consumers won't need them directly.
848
+
849
+ ### `evaluateRule`
850
+
851
+ Evaluate a single rule against a string without constructing a full rule set:
852
+
853
+ ```ts
854
+ import { evaluateRule } from '@orkestrel/validator'
855
+
856
+ evaluateRule('required', true, '') // 'This field is required'
857
+ evaluateRule('required', true, 'x') // undefined (passed)
858
+ evaluateRule('minimum', 3, 'ab') // 'Must be at least 3 characters'
859
+ ```
860
+
861
+ ### `cloneValidationRules`
862
+
863
+ Shallow-clone a rule object (validates it first):
864
+
865
+ ```ts
866
+ import { cloneValidationRules } from '@orkestrel/validator'
867
+
868
+ const copy = cloneValidationRules({ required: true, minimum: 3 })
869
+ ```
870
+
871
+ ### `createEnumValues`
872
+
873
+ Extract the runtime values from a TypeScript enum. Numeric enum reverse-mapping is handled automatically:
874
+
875
+ ```ts
876
+ import { createEnumValues } from '@orkestrel/validator'
877
+
878
+ createEnumValues({ idle: 'IDLE', busy: 'BUSY' }) // ['IDLE', 'BUSY']
879
+
880
+ enum Status {
881
+ Idle,
882
+ Busy,
883
+ }
884
+ createEnumValues(Status) // [0, 1] — not ['Idle', 'Busy']
885
+ ```
886
+
887
+ ### `createShapeGuard`
888
+
889
+ The factory behind `objectOf` and `recordOf`. Prefer those functions in application code:
890
+
891
+ ```ts
892
+ import { createShapeGuard } from '@orkestrel/validator'
893
+
894
+ const isUser = createShapeGuard({ id: isString, age: isNumber })
895
+ const isFlexUser = createShapeGuard({ id: isString, note: isString }, ['note'])
896
+ const isPartialUser = createShapeGuard({ id: isString, age: isNumber }, true)
897
+ ```
898
+
899
+ ### Domain-level type guards
900
+
901
+ Guards for the validation domain types themselves — useful when processing rule objects or results from unknown sources:
902
+
903
+ ```ts
904
+ import {
905
+ isValidationRuleName, // 'required' | 'minimum' | ...
906
+ isValidationRules, // ValidationRules
907
+ isValidationError, // ValidationError
908
+ isValidationResult, // ValidationResult
909
+ isValidatorFunction, // ValidatorFunction
910
+ } from '@orkestrel/validator'
911
+
912
+ isValidationRuleName('required') // true
913
+ isValidationRuleName('unknown') // false
914
+ isValidationRules({ required: true, minimum: 3 }) // true
915
+ isValidationRules({ required: 'yes' }) // false
916
+ isValidationError({ rule: 'required', message: 'This field...' }) // true
917
+ isValidationResult({ valid: true, errors: [] }) // true
918
+ ```
919
+
920
+ ### `recordValue`
921
+
922
+ Read a property from an object by any key type (including symbols):
923
+
924
+ ```ts
925
+ import { recordValue } from '@orkestrel/validator'
926
+
927
+ const sym = Symbol('id')
928
+ const obj = { name: 'Ada', [sym]: 42 }
929
+
930
+ recordValue(obj, 'name') // 'Ada'
931
+ recordValue(obj, sym) // 42
932
+ recordValue(obj, 'missing') // undefined
933
+ ```
934
+
935
+ ### Built-in regex constants
936
+
937
+ ```ts
938
+ import {
939
+ EMAIL_PATTERN, // /^[^\s@]+@[^\s@]+\.[^\s@]+$/
940
+ URL_PATTERN, // /^https?:\/\/.+/
941
+ NUMERIC_PATTERN, // /^-?\d+(\.\d+)?$/
942
+ INTEGER_PATTERN, // /^-?\d+$/
943
+ ALPHANUMERIC_PATTERN, // /^[a-zA-Z0-9]+$/
944
+ VALIDATION_RULE_NAMES, // ordered list of all rule names
945
+ } from '@orkestrel/validator'
946
+ ```
947
+
948
+ Import these when building custom validators that should match the built-in rule semantics exactly.