@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.
@@ -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 (process.env.NODE_ENV !== 'production' && !Object.is(value, validated)) {
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
- prefixError(i, () => itemValidator.validate(arr[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
+ }
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
- prefixError(i, () => itemValidator.validate(item))
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
- const checkedItem = prefixError(i, () =>
539
- itemValidator.validateUsingKnownGoodVersion!(knownGoodValue[i], item)
540
- )
541
- if (!Object.is(checkedItem, knownGoodValue[i])) {
542
- isDifferent = true
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 [key, validator] of Object.entries(config)) {
632
- prefixError(key, () => {
633
- ;(validator as Validatable<unknown>).validate(getOwnProperty(object, key))
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 [key, validator] of Object.entries(config)) {
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
- const checked = prefixError(key, () => {
662
- const validatable = validator as Validatable<unknown>
663
- if (validatable.validateUsingKnownGoodVersion) {
664
- return validatable.validateUsingKnownGoodVersion(prev, next)
665
- } else {
666
- return validatable.validate(next)
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 && !Number.isFinite(Number(variant))) {
837
- throw new ValidationError(`Expected a number for key "${this.key}", got "${variant as any}"`)
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
- for (const [key, value] of Object.entries(object)) {
899
- prefixError(key, () => {
900
- keyValidator.validate(key)
901
- valueValidator.validate(value)
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
- prefixError(key, () => {
918
- keyValidator.validate(key)
919
- valueValidator.validate(value)
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
- const prev = getOwnProperty(knownGoodValue, key)
924
- const next = value
925
- // sneaky quick check here to avoid the prefix + validator overhead
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
- const checked = prefixError(key, () => {
930
- if (valueValidator.validateUsingKnownGoodVersion) {
931
- return valueValidator.validateUsingKnownGoodVersion(prev as any, next)
932
- } else {
933
- return valueValidator.validate(next)
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
- for (const key of Object.keys(knownGoodValue)) {
942
- if (!hasOwnProperty(newValue, key)) {
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 = typeofValidator<number>('number').check((number) => {
1012
- if (Number.isNaN(number)) {
1013
- throw new ValidationError('Expected a number, got NaN')
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
- if (!Number.isFinite(number)) {
1016
- throw new ValidationError(`Expected a finite number, got ${number}`)
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.check((value) => {
1032
- if (value < 0) throw new ValidationError(`Expected a positive number, got ${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.check((value) => {
1046
- if (value <= 0) throw new ValidationError(`Expected a non-zero positive number, got ${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.check((value) => {
1060
- if (!Number.isInteger(value)) throw new ValidationError(`Expected an integer, got ${value}`)
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 = integer.check((value) => {
1076
- if (value < 0) throw new ValidationError(`Expected a positive integer, got ${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 = integer.check((value) => {
1090
- if (value <= 0) throw new ValidationError(`Expected a non-zero positive integer, got ${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.', () => {