@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.
@@ -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(path ? `At ${formattedPath}: ${indentedMessage}` : indentedMessage)
165
+ super(formattedPath ? `At ${formattedPath}: ${indentedMessage}` : indentedMessage)
166
166
  }
167
167
  }
168
168
 
169
- function prefixError<T>(path: string | number, fn: () => T): T {
170
- try {
171
- return fn()
172
- } catch (err) {
173
- if (err instanceof ValidationError) {
174
- throw new ValidationError(err.rawMessage, [path, ...err.path])
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
- prefixError(`(check ${nameOrCheckFn})`, () => checkFn!(value))
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
- 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
- }
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
- 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
- }
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
- if (IS_DEV) {
573
- const checkedItem = prefixError(i, () =>
574
- itemValidator.validateUsingKnownGoodVersion!(knownGoodValue[i], item)
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
- } 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
- }
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 (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
- }
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 (const key of Object.keys(object)) {
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 (const key in config) {
724
- if (!hasOwnProperty(config, key)) continue
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
- 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
- })
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
- } 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])
758
- }
718
+ } catch (err) {
719
+ rethrowPrefixed(key, err)
759
720
  }
760
721
  }
761
722
 
762
723
  if (!shouldAllowUnknownProperties) {
763
- for (const key of Object.keys(newValue)) {
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
- for (const key of Object.keys(knownGoodValue)) {
771
- if (!hasOwnProperty(newValue, key)) {
772
- isDifferent = true
773
- break
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 { matchingSchema, variant } = this.getMatchingSchemaAndVariant(input)
857
+ const matchingSchema = this.getMatchingSchema(input)
878
858
  if (matchingSchema === undefined) {
879
- return this.unknownValueValidation(input, variant)
859
+ return this.unknownValueValidation(input, this.getVariant(input))
880
860
  }
881
861
 
882
- return prefixError(`(${key} = ${variant})`, () => matchingSchema.validate(input))
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 { matchingSchema, variant } = this.getMatchingSchemaAndVariant(newValue)
875
+ const matchingSchema = this.getMatchingSchema(newValue)
890
876
  if (matchingSchema === undefined) {
891
- return this.unknownValueValidation(newValue, variant)
877
+ return this.unknownValueValidation(newValue, this.getVariant(newValue))
892
878
  }
893
879
 
894
- if (getOwnProperty(prevValue, key) !== getOwnProperty(newValue, key)) {
895
- // the type has changed so bail out and do a regular validation
896
- return prefixError(`(${key} = ${variant})`, () => matchingSchema.validate(newValue))
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 getMatchingSchemaAndVariant(object: object): {
917
- matchingSchema: Validatable<any> | undefined
918
- variant: string
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
- const matchingSchema = hasOwnProperty(this.config, variant) ? this.config[variant] : undefined
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
- 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
- }
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
- 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
- }
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
- 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
- })
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
- } 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])
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 an object (non-null, non-array). Does not validate
1470
- * the properties of the object.
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]) // Throws ValidationError: "Expected object, got an array"
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
- return value.every(isValidJson)
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
- return Object.values(value).every(isValidJson)
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 (const key of Object.keys(newValue)) {
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
- for (const key of Object.keys(knownGoodValue)) {
1609
- if (!hasOwnProperty(newValue, key)) {
1610
- isDifferent = true
1611
- break
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
- return prefixError(name, () => validator.validate(value))
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
- return prefixError(name, () => {
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
+ })