@tldraw/validate 5.1.0 → 5.2.0-canary.05c017c18b15
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/DOCS.md +888 -0
- package/README.md +9 -1
- package/dist-cjs/index.d.ts +5 -4
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/validation.js +126 -173
- package/dist-cjs/lib/validation.js.map +2 -2
- package/dist-esm/index.d.mts +5 -4
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/validation.mjs +126 -173
- package/dist-esm/lib/validation.mjs.map +2 -2
- package/package.json +4 -3
- package/src/lib/validation.ts +163 -184
- package/src/test/arrays.test.ts +103 -0
- package/src/test/dicts.test.ts +86 -0
- package/src/test/enums.test.ts +28 -0
- package/src/test/errors.test.ts +124 -0
- package/src/test/index-key.test.ts +15 -0
- package/src/test/json.test.ts +108 -0
- package/src/test/model.test.ts +34 -0
- package/src/test/nullable-optional.test.ts +75 -0
- package/src/test/numbers.test.ts +114 -0
- package/src/test/objects.test.ts +137 -0
- package/src/test/or.test.ts +13 -0
- package/src/test/primitives.test.ts +67 -0
- package/src/test/refine-check.test.ts +82 -0
- package/src/test/unions.test.ts +147 -0
- package/src/test/urls.test.ts +59 -0
- package/src/test/validator.test.ts +60 -0
- package/src/test/validation.test.ts +0 -230
package/src/lib/validation.ts
CHANGED
|
@@ -162,19 +162,18 @@ export class ValidationError extends Error {
|
|
|
162
162
|
.split('\n')
|
|
163
163
|
.map((line, i) => (i === 0 ? line : ` ${line}`))
|
|
164
164
|
.join('\n')
|
|
165
|
-
super(
|
|
165
|
+
super(formattedPath ? `At ${formattedPath}: ${indentedMessage}` : indentedMessage)
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
throw new ValidationError((err as Error).toString(), [path])
|
|
169
|
+
// Rethrows a validation error with a path prefix. Validation paths use this in a plain
|
|
170
|
+
// try/catch (rather than wrapping the work in a callback) so that the success path
|
|
171
|
+
// allocates no closure.
|
|
172
|
+
function rethrowPrefixed(path: string | number, err: unknown): never {
|
|
173
|
+
if (err instanceof ValidationError) {
|
|
174
|
+
throw new ValidationError(err.rawMessage, [path, ...err.path])
|
|
177
175
|
}
|
|
176
|
+
throw new ValidationError((err as Error).toString(), [path])
|
|
178
177
|
}
|
|
179
178
|
|
|
180
179
|
function typeToString(value: unknown): string {
|
|
@@ -487,7 +486,13 @@ export class Validator<T> implements Validatable<T> {
|
|
|
487
486
|
check(nameOrCheckFn: string | ((value: T) => void), checkFn?: (value: T) => void): Validator<T> {
|
|
488
487
|
if (typeof nameOrCheckFn === 'string') {
|
|
489
488
|
return this.refine((value) => {
|
|
490
|
-
|
|
489
|
+
// build the check-name error prefix only on failure so the success path
|
|
490
|
+
// allocates no string or closure
|
|
491
|
+
try {
|
|
492
|
+
checkFn!(value)
|
|
493
|
+
} catch (err) {
|
|
494
|
+
rethrowPrefixed(`(check ${nameOrCheckFn})`, err)
|
|
495
|
+
}
|
|
491
496
|
return value
|
|
492
497
|
})
|
|
493
498
|
} else {
|
|
@@ -523,18 +528,11 @@ export class ArrayOfValidator<T> extends Validator<T[]> {
|
|
|
523
528
|
(value) => {
|
|
524
529
|
const arr = array.validate(value)
|
|
525
530
|
for (let i = 0; i < arr.length; i++) {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
}
|
|
531
|
+
// inline error handling to avoid closure overhead on the success path
|
|
532
|
+
try {
|
|
533
|
+
itemValidator.validate(arr[i])
|
|
534
|
+
} catch (err) {
|
|
535
|
+
rethrowPrefixed(i, err)
|
|
538
536
|
}
|
|
539
537
|
}
|
|
540
538
|
return arr as T[]
|
|
@@ -551,17 +549,10 @@ export class ArrayOfValidator<T> extends Validator<T[]> {
|
|
|
551
549
|
const item = arr[i]
|
|
552
550
|
if (i >= knownGoodValue.length) {
|
|
553
551
|
isDifferent = true
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
557
|
-
|
|
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
|
-
}
|
|
552
|
+
try {
|
|
553
|
+
itemValidator.validate(item)
|
|
554
|
+
} catch (err) {
|
|
555
|
+
rethrowPrefixed(i, err)
|
|
565
556
|
}
|
|
566
557
|
continue
|
|
567
558
|
}
|
|
@@ -569,28 +560,16 @@ export class ArrayOfValidator<T> extends Validator<T[]> {
|
|
|
569
560
|
if (Object.is(knownGoodValue[i], item)) {
|
|
570
561
|
continue
|
|
571
562
|
}
|
|
572
|
-
|
|
573
|
-
const checkedItem =
|
|
574
|
-
|
|
563
|
+
try {
|
|
564
|
+
const checkedItem = itemValidator.validateUsingKnownGoodVersion!(
|
|
565
|
+
knownGoodValue[i],
|
|
566
|
+
item
|
|
575
567
|
)
|
|
576
568
|
if (!Object.is(checkedItem, knownGoodValue[i])) {
|
|
577
569
|
isDifferent = true
|
|
578
570
|
}
|
|
579
|
-
}
|
|
580
|
-
|
|
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
|
-
}
|
|
571
|
+
} catch (err) {
|
|
572
|
+
rethrowPrefixed(i, err)
|
|
594
573
|
}
|
|
595
574
|
}
|
|
596
575
|
|
|
@@ -673,34 +652,34 @@ export class ObjectValidator<Shape extends object> extends Validator<Shape> {
|
|
|
673
652
|
},
|
|
674
653
|
private readonly shouldAllowUnknownProperties = false
|
|
675
654
|
) {
|
|
655
|
+
// Cache the config keys and validators once so each validation iterates flat arrays
|
|
656
|
+
// instead of walking the config object with a hasOwnProperty check per key per call.
|
|
657
|
+
const configKeys: string[] = []
|
|
658
|
+
const configValidators: Validatable<unknown>[] = []
|
|
659
|
+
for (const [key, validator] of Object.entries(config)) {
|
|
660
|
+
configKeys.push(key)
|
|
661
|
+
configValidators.push(validator as Validatable<unknown>)
|
|
662
|
+
}
|
|
663
|
+
|
|
676
664
|
super(
|
|
677
665
|
(object) => {
|
|
678
666
|
if (typeof object !== 'object' || object === null) {
|
|
679
667
|
throw new ValidationError(`Expected object, got ${typeToString(object)}`)
|
|
680
668
|
}
|
|
681
669
|
|
|
682
|
-
for (
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
}
|
|
670
|
+
for (let i = 0; i < configKeys.length; i++) {
|
|
671
|
+
const key = configKeys[i]
|
|
672
|
+
try {
|
|
673
|
+
configValidators[i].validate(getOwnProperty(object, key))
|
|
674
|
+
} catch (err) {
|
|
675
|
+
rethrowPrefixed(key, err)
|
|
699
676
|
}
|
|
700
677
|
}
|
|
701
678
|
|
|
702
679
|
if (!shouldAllowUnknownProperties) {
|
|
703
|
-
for
|
|
680
|
+
// for...in instead of Object.keys() to avoid the key array allocation
|
|
681
|
+
for (const key in object) {
|
|
682
|
+
if (!hasOwnProperty(object, key)) continue
|
|
704
683
|
if (!hasOwnProperty(config, key)) {
|
|
705
684
|
throw new ValidationError(`Unexpected property`, [key])
|
|
706
685
|
}
|
|
@@ -720,57 +699,58 @@ export class ObjectValidator<Shape extends object> extends Validator<Shape> {
|
|
|
720
699
|
|
|
721
700
|
let isDifferent = false
|
|
722
701
|
|
|
723
|
-
for (
|
|
724
|
-
|
|
725
|
-
const validator = config[key as keyof typeof config]
|
|
702
|
+
for (let i = 0; i < configKeys.length; i++) {
|
|
703
|
+
const key = configKeys[i]
|
|
726
704
|
const prev = getOwnProperty(knownGoodValue, key)
|
|
727
705
|
const next = getOwnProperty(newValue, key)
|
|
728
706
|
// sneaky quick check here to avoid the prefix + validator overhead
|
|
729
707
|
if (Object.is(prev, next)) {
|
|
730
708
|
continue
|
|
731
709
|
}
|
|
732
|
-
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
} else {
|
|
738
|
-
return validatable.validate(next)
|
|
739
|
-
}
|
|
740
|
-
})
|
|
710
|
+
try {
|
|
711
|
+
const validator = configValidators[i]
|
|
712
|
+
const checked = validator.validateUsingKnownGoodVersion
|
|
713
|
+
? validator.validateUsingKnownGoodVersion(prev, next)
|
|
714
|
+
: validator.validate(next)
|
|
741
715
|
if (!Object.is(checked, prev)) {
|
|
742
716
|
isDifferent = true
|
|
743
717
|
}
|
|
744
|
-
}
|
|
745
|
-
|
|
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])
|
|
758
|
-
}
|
|
718
|
+
} catch (err) {
|
|
719
|
+
rethrowPrefixed(key, err)
|
|
759
720
|
}
|
|
760
721
|
}
|
|
761
722
|
|
|
762
723
|
if (!shouldAllowUnknownProperties) {
|
|
763
|
-
for
|
|
724
|
+
// for...in instead of Object.keys() to avoid the key array allocation
|
|
725
|
+
for (const key in newValue) {
|
|
726
|
+
if (!hasOwnProperty(newValue, key)) continue
|
|
764
727
|
if (!hasOwnProperty(config, key)) {
|
|
765
728
|
throw new ValidationError(`Unexpected property`, [key])
|
|
766
729
|
}
|
|
767
730
|
}
|
|
731
|
+
} else if (!isDifferent) {
|
|
732
|
+
// Unknown properties are not validated, but changes to them still
|
|
733
|
+
// count as changes: otherwise the stale known-good object would be
|
|
734
|
+
// returned and callers would drop the update.
|
|
735
|
+
for (const key of Object.keys(newValue)) {
|
|
736
|
+
if (
|
|
737
|
+
!hasOwnProperty(config, key) &&
|
|
738
|
+
!Object.is(getOwnProperty(knownGoodValue, key), getOwnProperty(newValue, key))
|
|
739
|
+
) {
|
|
740
|
+
isDifferent = true
|
|
741
|
+
break
|
|
742
|
+
}
|
|
743
|
+
}
|
|
768
744
|
}
|
|
769
745
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
746
|
+
if (!isDifferent) {
|
|
747
|
+
// this loop only detects removed keys, so skip it once a difference is known
|
|
748
|
+
for (const key in knownGoodValue) {
|
|
749
|
+
if (!hasOwnProperty(knownGoodValue, key)) continue
|
|
750
|
+
if (!hasOwnProperty(newValue, key)) {
|
|
751
|
+
isDifferent = true
|
|
752
|
+
break
|
|
753
|
+
}
|
|
774
754
|
}
|
|
775
755
|
}
|
|
776
756
|
|
|
@@ -874,35 +854,45 @@ export class UnionValidator<
|
|
|
874
854
|
(input) => {
|
|
875
855
|
this.expectObject(input)
|
|
876
856
|
|
|
877
|
-
const
|
|
857
|
+
const matchingSchema = this.getMatchingSchema(input)
|
|
878
858
|
if (matchingSchema === undefined) {
|
|
879
|
-
return this.unknownValueValidation(input,
|
|
859
|
+
return this.unknownValueValidation(input, this.getVariant(input))
|
|
880
860
|
}
|
|
881
861
|
|
|
882
|
-
|
|
862
|
+
// build the `(key = variant)` error prefix only on failure so the success path
|
|
863
|
+
// allocates no string or closure
|
|
864
|
+
try {
|
|
865
|
+
return matchingSchema.validate(input)
|
|
866
|
+
} catch (err) {
|
|
867
|
+
rethrowPrefixed(`(${key} = ${this.getVariant(input)})`, err)
|
|
868
|
+
}
|
|
883
869
|
},
|
|
884
870
|
(prevValue, newValue) => {
|
|
885
871
|
// Note: Object.is check is already done by base Validator class
|
|
886
872
|
this.expectObject(newValue)
|
|
887
873
|
this.expectObject(prevValue)
|
|
888
874
|
|
|
889
|
-
const
|
|
875
|
+
const matchingSchema = this.getMatchingSchema(newValue)
|
|
890
876
|
if (matchingSchema === undefined) {
|
|
891
|
-
return this.unknownValueValidation(newValue,
|
|
877
|
+
return this.unknownValueValidation(newValue, this.getVariant(newValue))
|
|
892
878
|
}
|
|
893
879
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
880
|
+
// build the `(key = variant)` error prefix only on failure so the success path
|
|
881
|
+
// allocates no string or closure
|
|
882
|
+
try {
|
|
883
|
+
if (getOwnProperty(prevValue, key) !== getOwnProperty(newValue, key)) {
|
|
884
|
+
// the type has changed so bail out and do a regular validation
|
|
885
|
+
return matchingSchema.validate(newValue)
|
|
886
|
+
}
|
|
898
887
|
|
|
899
|
-
return prefixError(`(${key} = ${variant})`, () => {
|
|
900
888
|
if (matchingSchema.validateUsingKnownGoodVersion) {
|
|
901
889
|
return matchingSchema.validateUsingKnownGoodVersion(prevValue, newValue)
|
|
902
890
|
} else {
|
|
903
891
|
return matchingSchema.validate(newValue)
|
|
904
892
|
}
|
|
905
|
-
})
|
|
893
|
+
} catch (err) {
|
|
894
|
+
rethrowPrefixed(`(${key} = ${this.getVariant(newValue)})`, err)
|
|
895
|
+
}
|
|
906
896
|
}
|
|
907
897
|
)
|
|
908
898
|
}
|
|
@@ -913,10 +903,14 @@ export class UnionValidator<
|
|
|
913
903
|
}
|
|
914
904
|
}
|
|
915
905
|
|
|
916
|
-
private
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
906
|
+
private getVariant(object: object): string {
|
|
907
|
+
return getOwnProperty(object, this.key) as unknown as string
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Returns the matching schema for the object's variant, or undefined if the variant is
|
|
911
|
+
// unknown. The variant itself is only needed on cold paths (unknown variants and errors),
|
|
912
|
+
// so this avoids allocating a `{ matchingSchema, variant }` result per validation.
|
|
913
|
+
private getMatchingSchema(object: object): Validatable<any> | undefined {
|
|
920
914
|
const variant = getOwnProperty(object, this.key)! as string & keyof Config
|
|
921
915
|
if (!this.useNumberKeys && typeof variant !== 'string') {
|
|
922
916
|
throw new ValidationError(
|
|
@@ -933,8 +927,7 @@ export class UnionValidator<
|
|
|
933
927
|
}
|
|
934
928
|
}
|
|
935
929
|
|
|
936
|
-
|
|
937
|
-
return { matchingSchema, variant }
|
|
930
|
+
return hasOwnProperty(this.config, variant) ? this.config[variant] : undefined
|
|
938
931
|
}
|
|
939
932
|
|
|
940
933
|
/**
|
|
@@ -994,22 +987,12 @@ export class DictValidator<Key extends string, Value> extends Validator<Record<K
|
|
|
994
987
|
// Use for...in instead of Object.entries() to avoid array allocation
|
|
995
988
|
for (const key in object) {
|
|
996
989
|
if (!hasOwnProperty(object, key)) continue
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
}
|
|
990
|
+
// inline error handling to avoid closure overhead on the success path
|
|
991
|
+
try {
|
|
992
|
+
keyValidator.validate(key)
|
|
993
|
+
valueValidator.validate((object as Record<string, unknown>)[key])
|
|
994
|
+
} catch (err) {
|
|
995
|
+
rethrowPrefixed(key, err)
|
|
1013
996
|
}
|
|
1014
997
|
}
|
|
1015
998
|
|
|
@@ -1033,21 +1016,11 @@ export class DictValidator<Key extends string, Value> extends Validator<Record<K
|
|
|
1033
1016
|
|
|
1034
1017
|
if (!hasOwnProperty(knownGoodValue, key)) {
|
|
1035
1018
|
isDifferent = true
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
-
}
|
|
1019
|
+
try {
|
|
1020
|
+
keyValidator.validate(key)
|
|
1021
|
+
valueValidator.validate(next)
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
rethrowPrefixed(key, err)
|
|
1051
1024
|
}
|
|
1052
1025
|
continue
|
|
1053
1026
|
}
|
|
@@ -1059,31 +1032,15 @@ export class DictValidator<Key extends string, Value> extends Validator<Record<K
|
|
|
1059
1032
|
continue
|
|
1060
1033
|
}
|
|
1061
1034
|
|
|
1062
|
-
|
|
1063
|
-
const checked =
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
} else {
|
|
1067
|
-
return valueValidator.validate(next)
|
|
1068
|
-
}
|
|
1069
|
-
})
|
|
1035
|
+
try {
|
|
1036
|
+
const checked = valueValidator.validateUsingKnownGoodVersion
|
|
1037
|
+
? valueValidator.validateUsingKnownGoodVersion(prev as Value, next)
|
|
1038
|
+
: valueValidator.validate(next)
|
|
1070
1039
|
if (!Object.is(checked, prev)) {
|
|
1071
1040
|
isDifferent = true
|
|
1072
1041
|
}
|
|
1073
|
-
}
|
|
1074
|
-
|
|
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])
|
|
1086
|
-
}
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
rethrowPrefixed(key, err)
|
|
1087
1044
|
}
|
|
1088
1045
|
}
|
|
1089
1046
|
|
|
@@ -1466,14 +1423,14 @@ export function arrayOf<T>(itemValidator: Validatable<T>): ArrayOfValidator<T> {
|
|
|
1466
1423
|
}
|
|
1467
1424
|
|
|
1468
1425
|
/**
|
|
1469
|
-
* Validator that ensures a value is
|
|
1470
|
-
* the
|
|
1426
|
+
* Validator that ensures a value is a non-null object. Does not validate the properties of
|
|
1427
|
+
* the object. Note that arrays also pass this check.
|
|
1471
1428
|
*
|
|
1472
1429
|
* @example
|
|
1473
1430
|
* ```ts
|
|
1474
1431
|
* const obj = T.unknownObject.validate({ any: "properties" }) // Returns Record<string, unknown>
|
|
1475
1432
|
* T.unknownObject.validate(null) // Throws ValidationError: "Expected object, got null"
|
|
1476
|
-
* T.unknownObject.validate([1, 2, 3]) //
|
|
1433
|
+
* T.unknownObject.validate([1, 2, 3]) // Returns the array (arrays pass the object check)
|
|
1477
1434
|
* ```
|
|
1478
1435
|
* @public
|
|
1479
1436
|
*/
|
|
@@ -1536,12 +1493,21 @@ function isValidJson(value: any): value is JsonValue {
|
|
|
1536
1493
|
return true
|
|
1537
1494
|
}
|
|
1538
1495
|
|
|
1496
|
+
// plain loops instead of .every() / Object.values() — this runs for every record on
|
|
1497
|
+
// full validation (e.g. document load), so avoid the closure and array allocations
|
|
1539
1498
|
if (Array.isArray(value)) {
|
|
1540
|
-
|
|
1499
|
+
for (let i = 0; i < value.length; i++) {
|
|
1500
|
+
if (!isValidJson(value[i])) return false
|
|
1501
|
+
}
|
|
1502
|
+
return true
|
|
1541
1503
|
}
|
|
1542
1504
|
|
|
1543
1505
|
if (isPlainObject(value)) {
|
|
1544
|
-
|
|
1506
|
+
for (const key in value) {
|
|
1507
|
+
if (!hasOwnProperty(value, key)) continue
|
|
1508
|
+
if (!isValidJson(value[key])) return false
|
|
1509
|
+
}
|
|
1510
|
+
return true
|
|
1545
1511
|
}
|
|
1546
1512
|
|
|
1547
1513
|
return false
|
|
@@ -1589,7 +1555,9 @@ export const jsonValue: Validator<JsonValue> = new Validator<JsonValue>(
|
|
|
1589
1555
|
return isDifferent ? (newValue as JsonValue) : knownGoodValue
|
|
1590
1556
|
} else if (isPlainObject(knownGoodValue) && isPlainObject(newValue)) {
|
|
1591
1557
|
let isDifferent = false
|
|
1592
|
-
for
|
|
1558
|
+
// for...in instead of Object.keys() to avoid the key array allocation
|
|
1559
|
+
for (const key in newValue) {
|
|
1560
|
+
if (!hasOwnProperty(newValue, key)) continue
|
|
1593
1561
|
if (!hasOwnProperty(knownGoodValue, key)) {
|
|
1594
1562
|
isDifferent = true
|
|
1595
1563
|
jsonValue.validate(newValue[key])
|
|
@@ -1605,10 +1573,14 @@ export const jsonValue: Validator<JsonValue> = new Validator<JsonValue>(
|
|
|
1605
1573
|
isDifferent = true
|
|
1606
1574
|
}
|
|
1607
1575
|
}
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1576
|
+
if (!isDifferent) {
|
|
1577
|
+
// this loop only detects removed keys, so skip it once a difference is known
|
|
1578
|
+
for (const key in knownGoodValue) {
|
|
1579
|
+
if (!hasOwnProperty(knownGoodValue, key)) continue
|
|
1580
|
+
if (!hasOwnProperty(newValue, key)) {
|
|
1581
|
+
isDifferent = true
|
|
1582
|
+
break
|
|
1583
|
+
}
|
|
1612
1584
|
}
|
|
1613
1585
|
}
|
|
1614
1586
|
return isDifferent ? (newValue as JsonValue) : knownGoodValue
|
|
@@ -1760,16 +1732,23 @@ export function model<T extends { readonly id: string }>(
|
|
|
1760
1732
|
): Validator<T> {
|
|
1761
1733
|
return new Validator(
|
|
1762
1734
|
(value) => {
|
|
1763
|
-
|
|
1735
|
+
// plain try/catch so the success path allocates no closure
|
|
1736
|
+
try {
|
|
1737
|
+
return validator.validate(value)
|
|
1738
|
+
} catch (err) {
|
|
1739
|
+
rethrowPrefixed(name, err)
|
|
1740
|
+
}
|
|
1764
1741
|
},
|
|
1765
1742
|
(prevValue, newValue) => {
|
|
1766
|
-
|
|
1743
|
+
try {
|
|
1767
1744
|
if (validator.validateUsingKnownGoodVersion) {
|
|
1768
1745
|
return validator.validateUsingKnownGoodVersion(prevValue, newValue)
|
|
1769
1746
|
} else {
|
|
1770
1747
|
return validator.validate(newValue)
|
|
1771
1748
|
}
|
|
1772
|
-
})
|
|
1749
|
+
} catch (err) {
|
|
1750
|
+
rethrowPrefixed(name, err)
|
|
1751
|
+
}
|
|
1773
1752
|
}
|
|
1774
1753
|
)
|
|
1775
1754
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as T from '../lib/validation'
|
|
2
|
+
import { ValidationError } from '../lib/validation'
|
|
3
|
+
|
|
4
|
+
/** An item validator with a counting validate and a known-good implementation. */
|
|
5
|
+
function countingItemValidator() {
|
|
6
|
+
const counts = { validate: 0, knownGood: 0 }
|
|
7
|
+
const validator: T.Validatable<number> = {
|
|
8
|
+
validate: (value) => {
|
|
9
|
+
counts.validate++
|
|
10
|
+
if (typeof value !== 'number') throw new ValidationError('Expected a number item')
|
|
11
|
+
return value
|
|
12
|
+
},
|
|
13
|
+
validateUsingKnownGoodVersion: (knownGood, value) => {
|
|
14
|
+
counts.knownGood++
|
|
15
|
+
if (typeof value !== 'number') throw new ValidationError('Expected a number item')
|
|
16
|
+
return Object.is(knownGood, value) ? knownGood : (value as number)
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
return { validator, counts }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('§9 Arrays of validated items', () => {
|
|
23
|
+
it('[A1] arrayOf validates every element and prefixes the failing index', () => {
|
|
24
|
+
const validator = T.arrayOf(T.number)
|
|
25
|
+
const arr = [1, 2, 3]
|
|
26
|
+
expect(validator.validate(arr)).toBe(arr)
|
|
27
|
+
|
|
28
|
+
expect(() => validator.validate([1, '2', 3])).toThrow('At 1: Expected number, got a string')
|
|
29
|
+
expect(() => validator.validate('nope')).toThrow('Expected an array, got a string')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('[A2] nonEmpty rejects empty arrays and lengthGreaterThan1 rejects singletons', () => {
|
|
33
|
+
expect(T.arrayOf(T.string).nonEmpty().validate(['a'])).toEqual(['a'])
|
|
34
|
+
expect(() => T.arrayOf(T.string).nonEmpty().validate([])).toThrow('Expected a non-empty array')
|
|
35
|
+
|
|
36
|
+
expect(T.arrayOf(T.string).lengthGreaterThan1().validate(['a', 'b'])).toEqual(['a', 'b'])
|
|
37
|
+
expect(() => T.arrayOf(T.string).lengthGreaterThan1().validate(['a'])).toThrow(
|
|
38
|
+
'Expected an array with length greater than 1'
|
|
39
|
+
)
|
|
40
|
+
expect(() => T.arrayOf(T.string).lengthGreaterThan1().validate([])).toThrow(
|
|
41
|
+
'Expected an array with length greater than 1'
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('[A3] known-good validation returns the known-good array when nothing changed', () => {
|
|
46
|
+
const validator = T.arrayOf(T.number)
|
|
47
|
+
const knownGood = validator.validate([1, 2, 3])
|
|
48
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, [1, 2, 3])).toBe(knownGood)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('[A3] unchanged elements are skipped outright; changed ones go through the item known-good path', () => {
|
|
52
|
+
const { validator: item, counts } = countingItemValidator()
|
|
53
|
+
const validator = T.arrayOf(item)
|
|
54
|
+
const knownGood = validator.validate([1, 2])
|
|
55
|
+
expect(counts).toEqual({ validate: 2, knownGood: 0 })
|
|
56
|
+
|
|
57
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, [1, 2])).toBe(knownGood)
|
|
58
|
+
expect(counts).toEqual({ validate: 2, knownGood: 0 })
|
|
59
|
+
|
|
60
|
+
const next = [1, 5]
|
|
61
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
|
62
|
+
expect(counts).toEqual({ validate: 2, knownGood: 1 })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('[A4] a changed length returns the new array, validating only appended elements', () => {
|
|
66
|
+
const { validator: item, counts } = countingItemValidator()
|
|
67
|
+
const validator = T.arrayOf(item)
|
|
68
|
+
const knownGood = validator.validate([1, 2])
|
|
69
|
+
counts.validate = 0
|
|
70
|
+
|
|
71
|
+
const longer = [1, 2, 3]
|
|
72
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, longer)).toBe(longer)
|
|
73
|
+
expect(counts).toEqual({ validate: 1, knownGood: 0 })
|
|
74
|
+
|
|
75
|
+
const shorter = [1]
|
|
76
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, shorter)).toBe(shorter)
|
|
77
|
+
expect(counts).toEqual({ validate: 1, knownGood: 0 })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('[A4] invalid changed or appended elements fail with their index', () => {
|
|
81
|
+
const validator = T.arrayOf(T.number)
|
|
82
|
+
const knownGood = validator.validate([1, 2])
|
|
83
|
+
expect(() => validator.validateUsingKnownGoodVersion(knownGood, [1, 'x'])).toThrow(
|
|
84
|
+
'At 1: Expected number, got a string'
|
|
85
|
+
)
|
|
86
|
+
expect(() => validator.validateUsingKnownGoodVersion(knownGood, [1, 2, 'x'])).toThrow(
|
|
87
|
+
'At 2: Expected number, got a string'
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('[A5] without an item known-good implementation the new array is returned even when structurally equal', () => {
|
|
92
|
+
const item: T.Validatable<number> = {
|
|
93
|
+
validate: (value) => {
|
|
94
|
+
if (typeof value !== 'number') throw new ValidationError('Expected a number item')
|
|
95
|
+
return value
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
const validator = T.arrayOf(item)
|
|
99
|
+
const knownGood = validator.validate([1, 2])
|
|
100
|
+
const next = [1, 2]
|
|
101
|
+
expect(validator.validateUsingKnownGoodVersion(knownGood, next)).toBe(next)
|
|
102
|
+
})
|
|
103
|
+
})
|