@tldraw/validate 4.2.0 → 4.2.2
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/dist-cjs/index.d.ts +37 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/validation.js +304 -68
- package/dist-cjs/lib/validation.js.map +3 -3
- package/dist-esm/index.d.mts +37 -1
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/validation.mjs +304 -68
- package/dist-esm/lib/validation.mjs.map +3 -3
- package/package.json +2 -2
- package/src/lib/validation.ts +355 -68
- package/src/test/validation.test.ts +38 -5
package/src/lib/validation.ts
CHANGED
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
validateIndexKey,
|
|
10
10
|
} from '@tldraw/utils'
|
|
11
11
|
|
|
12
|
+
/** @internal */
|
|
13
|
+
const IS_DEV = process.env.NODE_ENV !== 'production'
|
|
14
|
+
|
|
12
15
|
/**
|
|
13
16
|
* A function that validates and returns a value of type T from unknown input.
|
|
14
17
|
* The function should throw a ValidationError if the value is invalid.
|
|
@@ -231,10 +234,13 @@ export class Validator<T> implements Validatable<T> {
|
|
|
231
234
|
*
|
|
232
235
|
* validationFn - Function that validates and returns a value of type T
|
|
233
236
|
* validateUsingKnownGoodVersionFn - Optional performance-optimized validation function
|
|
237
|
+
* skipSameValueCheck - Internal flag to skip dev check for validators that transform values
|
|
234
238
|
*/
|
|
235
239
|
constructor(
|
|
236
240
|
readonly validationFn: ValidatorFn<T>,
|
|
237
|
-
readonly validateUsingKnownGoodVersionFn?: ValidatorUsingKnownGoodVersionFn<T
|
|
241
|
+
readonly validateUsingKnownGoodVersionFn?: ValidatorUsingKnownGoodVersionFn<T>,
|
|
242
|
+
/** @internal */
|
|
243
|
+
readonly skipSameValueCheck: boolean = false
|
|
238
244
|
) {}
|
|
239
245
|
|
|
240
246
|
/**
|
|
@@ -259,7 +265,7 @@ export class Validator<T> implements Validatable<T> {
|
|
|
259
265
|
*/
|
|
260
266
|
validate(value: unknown): T {
|
|
261
267
|
const validated = this.validationFn(value)
|
|
262
|
-
if (
|
|
268
|
+
if (IS_DEV && !this.skipSameValueCheck && !Object.is(value, validated)) {
|
|
263
269
|
throw new ValidationError('Validator functions must return the same value they were passed')
|
|
264
270
|
}
|
|
265
271
|
return validated
|
|
@@ -428,7 +434,8 @@ export class Validator<T> implements Validatable<T> {
|
|
|
428
434
|
return knownGoodValue
|
|
429
435
|
}
|
|
430
436
|
return otherValidationFn(validated)
|
|
431
|
-
}
|
|
437
|
+
},
|
|
438
|
+
true // skipSameValueCheck: refine is designed to transform values
|
|
432
439
|
)
|
|
433
440
|
}
|
|
434
441
|
|
|
@@ -516,11 +523,27 @@ export class ArrayOfValidator<T> extends Validator<T[]> {
|
|
|
516
523
|
(value) => {
|
|
517
524
|
const arr = array.validate(value)
|
|
518
525
|
for (let i = 0; i < arr.length; i++) {
|
|
519
|
-
|
|
526
|
+
if (IS_DEV) {
|
|
527
|
+
prefixError(i, () => itemValidator.validate(arr[i]))
|
|
528
|
+
} else {
|
|
529
|
+
// Production: inline error handling to avoid closure overhead
|
|
530
|
+
try {
|
|
531
|
+
itemValidator.validate(arr[i])
|
|
532
|
+
} catch (err) {
|
|
533
|
+
if (err instanceof ValidationError) {
|
|
534
|
+
throw new ValidationError(err.rawMessage, [i, ...err.path])
|
|
535
|
+
}
|
|
536
|
+
throw new ValidationError((err as Error).toString(), [i])
|
|
537
|
+
}
|
|
538
|
+
}
|
|
520
539
|
}
|
|
521
540
|
return arr as T[]
|
|
522
541
|
},
|
|
523
542
|
(knownGoodValue, newValue) => {
|
|
543
|
+
// Fast path: reference equality means no changes
|
|
544
|
+
if (Object.is(knownGoodValue, newValue)) {
|
|
545
|
+
return knownGoodValue
|
|
546
|
+
}
|
|
524
547
|
if (!itemValidator.validateUsingKnownGoodVersion) return this.validate(newValue)
|
|
525
548
|
const arr = array.validate(newValue)
|
|
526
549
|
let isDifferent = knownGoodValue.length !== arr.length
|
|
@@ -528,18 +551,46 @@ export class ArrayOfValidator<T> extends Validator<T[]> {
|
|
|
528
551
|
const item = arr[i]
|
|
529
552
|
if (i >= knownGoodValue.length) {
|
|
530
553
|
isDifferent = true
|
|
531
|
-
|
|
554
|
+
if (IS_DEV) {
|
|
555
|
+
prefixError(i, () => itemValidator.validate(item))
|
|
556
|
+
} else {
|
|
557
|
+
try {
|
|
558
|
+
itemValidator.validate(item)
|
|
559
|
+
} catch (err) {
|
|
560
|
+
if (err instanceof ValidationError) {
|
|
561
|
+
throw new ValidationError(err.rawMessage, [i, ...err.path])
|
|
562
|
+
}
|
|
563
|
+
throw new ValidationError((err as Error).toString(), [i])
|
|
564
|
+
}
|
|
565
|
+
}
|
|
532
566
|
continue
|
|
533
567
|
}
|
|
534
568
|
// sneaky quick check here to avoid the prefix + validator overhead
|
|
535
569
|
if (Object.is(knownGoodValue[i], item)) {
|
|
536
570
|
continue
|
|
537
571
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
572
|
+
if (IS_DEV) {
|
|
573
|
+
const checkedItem = prefixError(i, () =>
|
|
574
|
+
itemValidator.validateUsingKnownGoodVersion!(knownGoodValue[i], item)
|
|
575
|
+
)
|
|
576
|
+
if (!Object.is(checkedItem, knownGoodValue[i])) {
|
|
577
|
+
isDifferent = true
|
|
578
|
+
}
|
|
579
|
+
} else {
|
|
580
|
+
try {
|
|
581
|
+
const checkedItem = itemValidator.validateUsingKnownGoodVersion!(
|
|
582
|
+
knownGoodValue[i],
|
|
583
|
+
item
|
|
584
|
+
)
|
|
585
|
+
if (!Object.is(checkedItem, knownGoodValue[i])) {
|
|
586
|
+
isDifferent = true
|
|
587
|
+
}
|
|
588
|
+
} catch (err) {
|
|
589
|
+
if (err instanceof ValidationError) {
|
|
590
|
+
throw new ValidationError(err.rawMessage, [i, ...err.path])
|
|
591
|
+
}
|
|
592
|
+
throw new ValidationError((err as Error).toString(), [i])
|
|
593
|
+
}
|
|
543
594
|
}
|
|
544
595
|
}
|
|
545
596
|
|
|
@@ -628,10 +679,24 @@ export class ObjectValidator<Shape extends object> extends Validator<Shape> {
|
|
|
628
679
|
throw new ValidationError(`Expected object, got ${typeToString(object)}`)
|
|
629
680
|
}
|
|
630
681
|
|
|
631
|
-
for (const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
682
|
+
for (const key in config) {
|
|
683
|
+
if (!hasOwnProperty(config, key)) continue
|
|
684
|
+
const validator = config[key as keyof typeof config]
|
|
685
|
+
if (IS_DEV) {
|
|
686
|
+
prefixError(key, () => {
|
|
687
|
+
;(validator as Validatable<unknown>).validate(getOwnProperty(object, key))
|
|
688
|
+
})
|
|
689
|
+
} else {
|
|
690
|
+
// Production: inline error handling to avoid closure overhead
|
|
691
|
+
try {
|
|
692
|
+
;(validator as Validatable<unknown>).validate(getOwnProperty(object, key))
|
|
693
|
+
} catch (err) {
|
|
694
|
+
if (err instanceof ValidationError) {
|
|
695
|
+
throw new ValidationError(err.rawMessage, [key, ...err.path])
|
|
696
|
+
}
|
|
697
|
+
throw new ValidationError((err as Error).toString(), [key])
|
|
698
|
+
}
|
|
699
|
+
}
|
|
635
700
|
}
|
|
636
701
|
|
|
637
702
|
if (!shouldAllowUnknownProperties) {
|
|
@@ -645,29 +710,52 @@ export class ObjectValidator<Shape extends object> extends Validator<Shape> {
|
|
|
645
710
|
return object as Shape
|
|
646
711
|
},
|
|
647
712
|
(knownGoodValue, newValue) => {
|
|
713
|
+
// Fast path: reference equality means no changes
|
|
714
|
+
if (Object.is(knownGoodValue, newValue)) {
|
|
715
|
+
return knownGoodValue
|
|
716
|
+
}
|
|
648
717
|
if (typeof newValue !== 'object' || newValue === null) {
|
|
649
718
|
throw new ValidationError(`Expected object, got ${typeToString(newValue)}`)
|
|
650
719
|
}
|
|
651
720
|
|
|
652
721
|
let isDifferent = false
|
|
653
722
|
|
|
654
|
-
for (const
|
|
723
|
+
for (const key in config) {
|
|
724
|
+
if (!hasOwnProperty(config, key)) continue
|
|
725
|
+
const validator = config[key as keyof typeof config]
|
|
655
726
|
const prev = getOwnProperty(knownGoodValue, key)
|
|
656
727
|
const next = getOwnProperty(newValue, key)
|
|
657
728
|
// sneaky quick check here to avoid the prefix + validator overhead
|
|
658
729
|
if (Object.is(prev, next)) {
|
|
659
730
|
continue
|
|
660
731
|
}
|
|
661
|
-
|
|
662
|
-
const
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
732
|
+
if (IS_DEV) {
|
|
733
|
+
const checked = prefixError(key, () => {
|
|
734
|
+
const validatable = validator as Validatable<unknown>
|
|
735
|
+
if (validatable.validateUsingKnownGoodVersion) {
|
|
736
|
+
return validatable.validateUsingKnownGoodVersion(prev, next)
|
|
737
|
+
} else {
|
|
738
|
+
return validatable.validate(next)
|
|
739
|
+
}
|
|
740
|
+
})
|
|
741
|
+
if (!Object.is(checked, prev)) {
|
|
742
|
+
isDifferent = true
|
|
743
|
+
}
|
|
744
|
+
} else {
|
|
745
|
+
try {
|
|
746
|
+
const validatable = validator as Validatable<unknown>
|
|
747
|
+
const checked = validatable.validateUsingKnownGoodVersion
|
|
748
|
+
? validatable.validateUsingKnownGoodVersion(prev, next)
|
|
749
|
+
: validatable.validate(next)
|
|
750
|
+
if (!Object.is(checked, prev)) {
|
|
751
|
+
isDifferent = true
|
|
752
|
+
}
|
|
753
|
+
} catch (err) {
|
|
754
|
+
if (err instanceof ValidationError) {
|
|
755
|
+
throw new ValidationError(err.rawMessage, [key, ...err.path])
|
|
756
|
+
}
|
|
757
|
+
throw new ValidationError((err as Error).toString(), [key])
|
|
667
758
|
}
|
|
668
|
-
})
|
|
669
|
-
if (!Object.is(checked, prev)) {
|
|
670
|
-
isDifferent = true
|
|
671
759
|
}
|
|
672
760
|
}
|
|
673
761
|
|
|
@@ -794,6 +882,7 @@ export class UnionValidator<
|
|
|
794
882
|
return prefixError(`(${key} = ${variant})`, () => matchingSchema.validate(input))
|
|
795
883
|
},
|
|
796
884
|
(prevValue, newValue) => {
|
|
885
|
+
// Note: Object.is check is already done by base Validator class
|
|
797
886
|
this.expectObject(newValue)
|
|
798
887
|
this.expectObject(prevValue)
|
|
799
888
|
|
|
@@ -833,8 +922,15 @@ export class UnionValidator<
|
|
|
833
922
|
throw new ValidationError(
|
|
834
923
|
`Expected a string for key "${this.key}", got ${typeToString(variant)}`
|
|
835
924
|
)
|
|
836
|
-
} else if (this.useNumberKeys
|
|
837
|
-
|
|
925
|
+
} else if (this.useNumberKeys) {
|
|
926
|
+
// Fast finite number check: numVariant - numVariant === 0 is false for Infinity and NaN
|
|
927
|
+
// This avoids Number.isFinite function call overhead
|
|
928
|
+
const numVariant = Number(variant)
|
|
929
|
+
if (numVariant - numVariant !== 0) {
|
|
930
|
+
throw new ValidationError(
|
|
931
|
+
`Expected a number for key "${this.key}", got "${variant as any}"`
|
|
932
|
+
)
|
|
933
|
+
}
|
|
838
934
|
}
|
|
839
935
|
|
|
840
936
|
const matchingSchema = hasOwnProperty(this.config, variant) ? this.config[variant] : undefined
|
|
@@ -895,11 +991,26 @@ export class DictValidator<Key extends string, Value> extends Validator<Record<K
|
|
|
895
991
|
throw new ValidationError(`Expected object, got ${typeToString(object)}`)
|
|
896
992
|
}
|
|
897
993
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
994
|
+
// Use for...in instead of Object.entries() to avoid array allocation
|
|
995
|
+
for (const key in object) {
|
|
996
|
+
if (!hasOwnProperty(object, key)) continue
|
|
997
|
+
if (IS_DEV) {
|
|
998
|
+
prefixError(key, () => {
|
|
999
|
+
keyValidator.validate(key)
|
|
1000
|
+
valueValidator.validate((object as Record<string, unknown>)[key])
|
|
1001
|
+
})
|
|
1002
|
+
} else {
|
|
1003
|
+
// Production: inline error handling to avoid closure overhead
|
|
1004
|
+
try {
|
|
1005
|
+
keyValidator.validate(key)
|
|
1006
|
+
valueValidator.validate((object as Record<string, unknown>)[key])
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
if (err instanceof ValidationError) {
|
|
1009
|
+
throw new ValidationError(err.rawMessage, [key, ...err.path])
|
|
1010
|
+
}
|
|
1011
|
+
throw new ValidationError((err as Error).toString(), [key])
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
903
1014
|
}
|
|
904
1015
|
|
|
905
1016
|
return object as Record<Key, Value>
|
|
@@ -909,39 +1020,84 @@ export class DictValidator<Key extends string, Value> extends Validator<Record<K
|
|
|
909
1020
|
throw new ValidationError(`Expected object, got ${typeToString(newValue)}`)
|
|
910
1021
|
}
|
|
911
1022
|
|
|
1023
|
+
const newObj = newValue as Record<string, unknown>
|
|
912
1024
|
let isDifferent = false
|
|
1025
|
+
let newKeyCount = 0
|
|
1026
|
+
|
|
1027
|
+
// Use for...in instead of Object.entries() to avoid array allocation
|
|
1028
|
+
for (const key in newObj) {
|
|
1029
|
+
if (!hasOwnProperty(newObj, key)) continue
|
|
1030
|
+
newKeyCount++
|
|
1031
|
+
|
|
1032
|
+
const next = newObj[key]
|
|
913
1033
|
|
|
914
|
-
for (const [key, value] of Object.entries(newValue)) {
|
|
915
1034
|
if (!hasOwnProperty(knownGoodValue, key)) {
|
|
916
1035
|
isDifferent = true
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1036
|
+
if (IS_DEV) {
|
|
1037
|
+
prefixError(key, () => {
|
|
1038
|
+
keyValidator.validate(key)
|
|
1039
|
+
valueValidator.validate(next)
|
|
1040
|
+
})
|
|
1041
|
+
} else {
|
|
1042
|
+
try {
|
|
1043
|
+
keyValidator.validate(key)
|
|
1044
|
+
valueValidator.validate(next)
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
if (err instanceof ValidationError) {
|
|
1047
|
+
throw new ValidationError(err.rawMessage, [key, ...err.path])
|
|
1048
|
+
}
|
|
1049
|
+
throw new ValidationError((err as Error).toString(), [key])
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
921
1052
|
continue
|
|
922
1053
|
}
|
|
923
|
-
|
|
924
|
-
const
|
|
925
|
-
|
|
1054
|
+
|
|
1055
|
+
const prev = (knownGoodValue as Record<string, unknown>)[key]
|
|
1056
|
+
|
|
1057
|
+
// Quick reference equality check to avoid validator overhead
|
|
926
1058
|
if (Object.is(prev, next)) {
|
|
927
1059
|
continue
|
|
928
1060
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1061
|
+
|
|
1062
|
+
if (IS_DEV) {
|
|
1063
|
+
const checked = prefixError(key, () => {
|
|
1064
|
+
if (valueValidator.validateUsingKnownGoodVersion) {
|
|
1065
|
+
return valueValidator.validateUsingKnownGoodVersion(prev as Value, next)
|
|
1066
|
+
} else {
|
|
1067
|
+
return valueValidator.validate(next)
|
|
1068
|
+
}
|
|
1069
|
+
})
|
|
1070
|
+
if (!Object.is(checked, prev)) {
|
|
1071
|
+
isDifferent = true
|
|
1072
|
+
}
|
|
1073
|
+
} else {
|
|
1074
|
+
try {
|
|
1075
|
+
const checked = valueValidator.validateUsingKnownGoodVersion
|
|
1076
|
+
? valueValidator.validateUsingKnownGoodVersion(prev as Value, next)
|
|
1077
|
+
: valueValidator.validate(next)
|
|
1078
|
+
if (!Object.is(checked, prev)) {
|
|
1079
|
+
isDifferent = true
|
|
1080
|
+
}
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
if (err instanceof ValidationError) {
|
|
1083
|
+
throw new ValidationError(err.rawMessage, [key, ...err.path])
|
|
1084
|
+
}
|
|
1085
|
+
throw new ValidationError((err as Error).toString(), [key])
|
|
934
1086
|
}
|
|
935
|
-
})
|
|
936
|
-
if (!Object.is(checked, prev)) {
|
|
937
|
-
isDifferent = true
|
|
938
1087
|
}
|
|
939
1088
|
}
|
|
940
1089
|
|
|
941
|
-
|
|
942
|
-
|
|
1090
|
+
// Only check for removed keys if counts might differ
|
|
1091
|
+
// This avoids iterating over knownGoodValue when no keys were removed
|
|
1092
|
+
if (!isDifferent) {
|
|
1093
|
+
let oldKeyCount = 0
|
|
1094
|
+
for (const key in knownGoodValue) {
|
|
1095
|
+
if (hasOwnProperty(knownGoodValue, key)) {
|
|
1096
|
+
oldKeyCount++
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (oldKeyCount !== newKeyCount) {
|
|
943
1100
|
isDifferent = true
|
|
944
|
-
break
|
|
945
1101
|
}
|
|
946
1102
|
}
|
|
947
1103
|
|
|
@@ -1008,13 +1164,21 @@ export const string = typeofValidator<string>('string')
|
|
|
1008
1164
|
* ```
|
|
1009
1165
|
* @public
|
|
1010
1166
|
*/
|
|
1011
|
-
export const number =
|
|
1012
|
-
|
|
1013
|
-
|
|
1167
|
+
export const number = new Validator<number>((value) => {
|
|
1168
|
+
// Fast path: check for valid finite number using arithmetic trick
|
|
1169
|
+
// value - value === 0 is false for Infinity and NaN (avoids function call overhead)
|
|
1170
|
+
if (Number.isFinite(value)) {
|
|
1171
|
+
return value as number
|
|
1014
1172
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1173
|
+
// Slow path: determine specific error
|
|
1174
|
+
if (typeof value !== 'number') {
|
|
1175
|
+
throw new ValidationError(`Expected number, got ${typeToString(value)}`)
|
|
1017
1176
|
}
|
|
1177
|
+
// value !== value is true only for NaN (faster than Number.isNaN)
|
|
1178
|
+
if (value !== value) {
|
|
1179
|
+
throw new ValidationError('Expected a number, got NaN')
|
|
1180
|
+
}
|
|
1181
|
+
throw new ValidationError(`Expected a finite number, got ${value}`)
|
|
1018
1182
|
})
|
|
1019
1183
|
/**
|
|
1020
1184
|
* Validator that ensures a value is a non-negative number (\>= 0).
|
|
@@ -1028,8 +1192,20 @@ export const number = typeofValidator<number>('number').check((number) => {
|
|
|
1028
1192
|
* ```
|
|
1029
1193
|
* @public
|
|
1030
1194
|
*/
|
|
1031
|
-
export const positiveNumber = number
|
|
1032
|
-
if (value
|
|
1195
|
+
export const positiveNumber = new Validator<number>((value) => {
|
|
1196
|
+
if (Number.isFinite(value) && (value as number) >= 0) {
|
|
1197
|
+
return value as number
|
|
1198
|
+
}
|
|
1199
|
+
if (typeof value !== 'number') {
|
|
1200
|
+
throw new ValidationError(`Expected number, got ${typeToString(value)}`)
|
|
1201
|
+
}
|
|
1202
|
+
if (value !== value) {
|
|
1203
|
+
throw new ValidationError('Expected a number, got NaN')
|
|
1204
|
+
}
|
|
1205
|
+
if (value < 0) {
|
|
1206
|
+
throw new ValidationError(`Expected a positive number, got ${value}`)
|
|
1207
|
+
}
|
|
1208
|
+
throw new ValidationError(`Expected a finite number, got ${value}`)
|
|
1033
1209
|
})
|
|
1034
1210
|
/**
|
|
1035
1211
|
* Validator that ensures a value is a positive number (\> 0). Rejects zero and negative numbers.
|
|
@@ -1042,8 +1218,73 @@ export const positiveNumber = number.check((value) => {
|
|
|
1042
1218
|
* ```
|
|
1043
1219
|
* @public
|
|
1044
1220
|
*/
|
|
1045
|
-
export const nonZeroNumber = number
|
|
1046
|
-
if (value
|
|
1221
|
+
export const nonZeroNumber = new Validator<number>((value) => {
|
|
1222
|
+
if (Number.isFinite(value) && (value as number) > 0) {
|
|
1223
|
+
return value as number
|
|
1224
|
+
}
|
|
1225
|
+
if (typeof value !== 'number') {
|
|
1226
|
+
throw new ValidationError(`Expected number, got ${typeToString(value)}`)
|
|
1227
|
+
}
|
|
1228
|
+
if (value !== value) {
|
|
1229
|
+
throw new ValidationError('Expected a number, got NaN')
|
|
1230
|
+
}
|
|
1231
|
+
if (value <= 0) {
|
|
1232
|
+
throw new ValidationError(`Expected a non-zero positive number, got ${value}`)
|
|
1233
|
+
}
|
|
1234
|
+
throw new ValidationError(`Expected a finite number, got ${value}`)
|
|
1235
|
+
})
|
|
1236
|
+
/**
|
|
1237
|
+
* Validator that ensures a value is a finite, non-zero number. Allows negative numbers.
|
|
1238
|
+
* Useful for scale factors that can be negative (for flipping) but not zero.
|
|
1239
|
+
*
|
|
1240
|
+
* @example
|
|
1241
|
+
* ```ts
|
|
1242
|
+
* const scale = T.nonZeroFiniteNumber.validate(-1.5) // Returns -1.5 (valid, allows negative)
|
|
1243
|
+
* T.nonZeroFiniteNumber.validate(0) // Throws ValidationError: "Expected a non-zero number, got 0"
|
|
1244
|
+
* T.nonZeroFiniteNumber.validate(Infinity) // Throws ValidationError
|
|
1245
|
+
* ```
|
|
1246
|
+
* @public
|
|
1247
|
+
*/
|
|
1248
|
+
export const nonZeroFiniteNumber = new Validator<number>((value) => {
|
|
1249
|
+
if (Number.isFinite(value) && (value as number) !== 0) {
|
|
1250
|
+
return value as number
|
|
1251
|
+
}
|
|
1252
|
+
if (typeof value !== 'number') {
|
|
1253
|
+
throw new ValidationError(`Expected number, got ${typeToString(value)}`)
|
|
1254
|
+
}
|
|
1255
|
+
if (value !== value) {
|
|
1256
|
+
throw new ValidationError('Expected a number, got NaN')
|
|
1257
|
+
}
|
|
1258
|
+
if (value === 0) {
|
|
1259
|
+
throw new ValidationError(`Expected a non-zero number, got 0`)
|
|
1260
|
+
}
|
|
1261
|
+
throw new ValidationError(`Expected a finite number, got ${value}`)
|
|
1262
|
+
})
|
|
1263
|
+
/**
|
|
1264
|
+
* Validator that ensures a value is a number in the unit interval [0, 1].
|
|
1265
|
+
* Useful for opacity, percentages expressed as decimals, and other normalized values.
|
|
1266
|
+
*
|
|
1267
|
+
* @example
|
|
1268
|
+
* ```ts
|
|
1269
|
+
* const opacity = T.unitInterval.validate(0.5) // Returns 0.5
|
|
1270
|
+
* T.unitInterval.validate(0) // Returns 0 (valid)
|
|
1271
|
+
* T.unitInterval.validate(1) // Returns 1 (valid)
|
|
1272
|
+
* T.unitInterval.validate(1.5) // Throws ValidationError
|
|
1273
|
+
* T.unitInterval.validate(-0.1) // Throws ValidationError
|
|
1274
|
+
* ```
|
|
1275
|
+
* @public
|
|
1276
|
+
*/
|
|
1277
|
+
export const unitInterval = new Validator<number>((value) => {
|
|
1278
|
+
if (Number.isFinite(value) && (value as number) >= 0 && (value as number) <= 1) {
|
|
1279
|
+
return value as number
|
|
1280
|
+
}
|
|
1281
|
+
if (typeof value !== 'number') {
|
|
1282
|
+
throw new ValidationError(`Expected number, got ${typeToString(value)}`)
|
|
1283
|
+
}
|
|
1284
|
+
if (value !== value) {
|
|
1285
|
+
throw new ValidationError('Expected a number, got NaN')
|
|
1286
|
+
}
|
|
1287
|
+
throw new ValidationError(`Expected a number between 0 and 1, got ${value}`)
|
|
1047
1288
|
})
|
|
1048
1289
|
/**
|
|
1049
1290
|
* Validator that ensures a value is an integer (whole number).
|
|
@@ -1056,8 +1297,21 @@ export const nonZeroNumber = number.check((value) => {
|
|
|
1056
1297
|
* ```
|
|
1057
1298
|
* @public
|
|
1058
1299
|
*/
|
|
1059
|
-
export const integer = number
|
|
1060
|
-
|
|
1300
|
+
export const integer = new Validator<number>((value) => {
|
|
1301
|
+
// Fast path: Number.isInteger checks typeof, finiteness, and integrality in one call
|
|
1302
|
+
if (Number.isInteger(value)) {
|
|
1303
|
+
return value as number
|
|
1304
|
+
}
|
|
1305
|
+
if (typeof value !== 'number') {
|
|
1306
|
+
throw new ValidationError(`Expected number, got ${typeToString(value)}`)
|
|
1307
|
+
}
|
|
1308
|
+
if (value !== value) {
|
|
1309
|
+
throw new ValidationError('Expected a number, got NaN')
|
|
1310
|
+
}
|
|
1311
|
+
if (value - value !== 0) {
|
|
1312
|
+
throw new ValidationError(`Expected a finite number, got ${value}`)
|
|
1313
|
+
}
|
|
1314
|
+
throw new ValidationError(`Expected an integer, got ${value}`)
|
|
1061
1315
|
})
|
|
1062
1316
|
/**
|
|
1063
1317
|
* Validator that ensures a value is a non-negative integer (\>= 0).
|
|
@@ -1072,8 +1326,23 @@ export const integer = number.check((value) => {
|
|
|
1072
1326
|
* ```
|
|
1073
1327
|
* @public
|
|
1074
1328
|
*/
|
|
1075
|
-
export const positiveInteger =
|
|
1076
|
-
if (value
|
|
1329
|
+
export const positiveInteger = new Validator<number>((value) => {
|
|
1330
|
+
if (Number.isInteger(value) && (value as number) >= 0) {
|
|
1331
|
+
return value as number
|
|
1332
|
+
}
|
|
1333
|
+
if (typeof value !== 'number') {
|
|
1334
|
+
throw new ValidationError(`Expected number, got ${typeToString(value)}`)
|
|
1335
|
+
}
|
|
1336
|
+
if (value !== value) {
|
|
1337
|
+
throw new ValidationError('Expected a number, got NaN')
|
|
1338
|
+
}
|
|
1339
|
+
if (value - value !== 0) {
|
|
1340
|
+
throw new ValidationError(`Expected a finite number, got ${value}`)
|
|
1341
|
+
}
|
|
1342
|
+
if (value < 0) {
|
|
1343
|
+
throw new ValidationError(`Expected a positive integer, got ${value}`)
|
|
1344
|
+
}
|
|
1345
|
+
throw new ValidationError(`Expected an integer, got ${value}`)
|
|
1077
1346
|
})
|
|
1078
1347
|
/**
|
|
1079
1348
|
* Validator that ensures a value is a positive integer (\> 0). Rejects zero and negative integers.
|
|
@@ -1086,8 +1355,23 @@ export const positiveInteger = integer.check((value) => {
|
|
|
1086
1355
|
* ```
|
|
1087
1356
|
* @public
|
|
1088
1357
|
*/
|
|
1089
|
-
export const nonZeroInteger =
|
|
1090
|
-
if (value
|
|
1358
|
+
export const nonZeroInteger = new Validator<number>((value) => {
|
|
1359
|
+
if (Number.isInteger(value) && (value as number) > 0) {
|
|
1360
|
+
return value as number
|
|
1361
|
+
}
|
|
1362
|
+
if (typeof value !== 'number') {
|
|
1363
|
+
throw new ValidationError(`Expected number, got ${typeToString(value)}`)
|
|
1364
|
+
}
|
|
1365
|
+
if (value !== value) {
|
|
1366
|
+
throw new ValidationError('Expected a number, got NaN')
|
|
1367
|
+
}
|
|
1368
|
+
if (value - value !== 0) {
|
|
1369
|
+
throw new ValidationError(`Expected a finite number, got ${value}`)
|
|
1370
|
+
}
|
|
1371
|
+
if (value <= 0) {
|
|
1372
|
+
throw new ValidationError(`Expected a non-zero positive integer, got ${value}`)
|
|
1373
|
+
}
|
|
1374
|
+
throw new ValidationError(`Expected an integer, got ${value}`)
|
|
1091
1375
|
})
|
|
1092
1376
|
|
|
1093
1377
|
/**
|
|
@@ -1536,13 +1820,14 @@ export function optional<T>(validator: Validatable<T>): Validator<T | undefined>
|
|
|
1536
1820
|
return validator.validate(value)
|
|
1537
1821
|
},
|
|
1538
1822
|
(knownGoodValue, newValue) => {
|
|
1539
|
-
if (knownGoodValue === undefined && newValue === undefined) return undefined
|
|
1540
1823
|
if (newValue === undefined) return undefined
|
|
1541
1824
|
if (validator.validateUsingKnownGoodVersion && knownGoodValue !== undefined) {
|
|
1542
1825
|
return validator.validateUsingKnownGoodVersion(knownGoodValue as T, newValue)
|
|
1543
1826
|
}
|
|
1544
1827
|
return validator.validate(newValue)
|
|
1545
|
-
}
|
|
1828
|
+
},
|
|
1829
|
+
// Propagate skipSameValueCheck from inner validator to allow refine wrappers
|
|
1830
|
+
validator instanceof Validator && validator.skipSameValueCheck
|
|
1546
1831
|
)
|
|
1547
1832
|
}
|
|
1548
1833
|
|
|
@@ -1572,7 +1857,9 @@ export function nullable<T>(validator: Validatable<T>): Validator<T | null> {
|
|
|
1572
1857
|
return validator.validateUsingKnownGoodVersion(knownGoodValue as T, newValue)
|
|
1573
1858
|
}
|
|
1574
1859
|
return validator.validate(newValue)
|
|
1575
|
-
}
|
|
1860
|
+
},
|
|
1861
|
+
// Propagate skipSameValueCheck from inner validator to allow refine wrappers
|
|
1862
|
+
validator instanceof Validator && validator.skipSameValueCheck
|
|
1576
1863
|
)
|
|
1577
1864
|
}
|
|
1578
1865
|
|
|
@@ -117,23 +117,56 @@ describe('validations', () => {
|
|
|
117
117
|
`[ValidationError: At animal(type = cat).meow: Expected boolean, got undefined]`
|
|
118
118
|
)
|
|
119
119
|
})
|
|
120
|
+
|
|
121
|
+
it('Rejects Infinity and -Infinity in numberUnion discriminators', () => {
|
|
122
|
+
const numberUnionSchema = T.numberUnion('version', {
|
|
123
|
+
1: T.object({ version: T.literal(1), data: T.string }),
|
|
124
|
+
2: T.object({ version: T.literal(2), data: T.string }),
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// Valid cases
|
|
128
|
+
expect(numberUnionSchema.validate({ version: 1, data: 'hello' })).toEqual({
|
|
129
|
+
version: 1,
|
|
130
|
+
data: 'hello',
|
|
131
|
+
})
|
|
132
|
+
expect(numberUnionSchema.validate({ version: 2, data: 'world' })).toEqual({
|
|
133
|
+
version: 2,
|
|
134
|
+
data: 'world',
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// Should reject Infinity
|
|
138
|
+
expect(() =>
|
|
139
|
+
numberUnionSchema.validate({ version: Infinity, data: 'test' })
|
|
140
|
+
).toThrowErrorMatchingInlineSnapshot(
|
|
141
|
+
`[ValidationError: At null: Expected a number for key "version", got "Infinity"]`
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
// Should reject -Infinity
|
|
145
|
+
expect(() =>
|
|
146
|
+
numberUnionSchema.validate({ version: -Infinity, data: 'test' })
|
|
147
|
+
).toThrowErrorMatchingInlineSnapshot(
|
|
148
|
+
`[ValidationError: At null: Expected a number for key "version", got "-Infinity"]`
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
// Should reject NaN
|
|
152
|
+
expect(() => numberUnionSchema.validate({ version: NaN, data: 'test' })).toThrowError(
|
|
153
|
+
/Expected a number for key "version"/
|
|
154
|
+
)
|
|
155
|
+
})
|
|
120
156
|
})
|
|
121
157
|
|
|
122
158
|
describe('T.refine', () => {
|
|
123
159
|
it('Refines a validator.', () => {
|
|
160
|
+
// refine can transform values (e.g., string to number)
|
|
124
161
|
const stringToNumber = T.string.refine((str) => parseInt(str, 10))
|
|
125
|
-
const originalEnv = process.env.NODE_ENV
|
|
126
|
-
process.env.NODE_ENV = 'production'
|
|
127
162
|
expect(stringToNumber.validate('42')).toBe(42)
|
|
128
|
-
process.env.NODE_ENV = originalEnv
|
|
129
163
|
|
|
164
|
+
// refine can also modify values of the same type
|
|
130
165
|
const prefixedString = T.string.refine((str) =>
|
|
131
166
|
str.startsWith('prefix:') ? str : `prefix:${str}`
|
|
132
167
|
)
|
|
133
|
-
process.env.NODE_ENV = 'production'
|
|
134
168
|
expect(prefixedString.validate('test')).toBe('prefix:test')
|
|
135
169
|
expect(prefixedString.validate('prefix:existing')).toBe('prefix:existing')
|
|
136
|
-
process.env.NODE_ENV = originalEnv
|
|
137
170
|
})
|
|
138
171
|
|
|
139
172
|
it('Produces a type error if the refinement is not of the correct type.', () => {
|