@hy_ong/zod-kit 0.2.1 → 0.2.3

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.
Files changed (139) hide show
  1. package/dist/{chunk-MCDESS3T.js → chunk-2JGRV3JO.js} +1 -1
  2. package/dist/{chunk-4LYZAO3P.js → chunk-3QLUXIY2.js} +1 -1
  3. package/dist/chunk-3ZF5JO3F.js +61 -0
  4. package/dist/{chunk-OP4KV3BY.cjs → chunk-4AQB4RSU.cjs} +3 -3
  5. package/dist/{chunk-UFNVCUPQ.cjs → chunk-53EEWALQ.cjs} +3 -3
  6. package/dist/{chunk-R5G4V7C6.cjs → chunk-5ZMTAI4G.cjs} +3 -3
  7. package/dist/{chunk-RKHX3DGH.js → chunk-6DFP7XY2.js} +1 -1
  8. package/dist/{chunk-P2NONIMS.js → chunk-6IAPM7BP.js} +1 -1
  9. package/dist/{chunk-AWV2IT66.js → chunk-7SKH66CM.js} +1 -1
  10. package/dist/chunk-7X3XPK6A.js +92 -0
  11. package/dist/{chunk-FVO4743A.cjs → chunk-A2GAEU4O.cjs} +3 -3
  12. package/dist/{chunk-P364KRO5.js → chunk-AI4U42JV.js} +1 -1
  13. package/dist/{chunk-5ZEKWPSE.cjs → chunk-AYCXAJRA.cjs} +3 -3
  14. package/dist/{chunk-5LS4DSRQ.cjs → chunk-BZSPJJYT.cjs} +3 -3
  15. package/dist/chunk-BZWQPSHO.cjs +92 -0
  16. package/dist/{chunk-KARFFIMP.js → chunk-D55YFP5R.js} +1 -1
  17. package/dist/{chunk-I2RJMDXN.js → chunk-E52GRXYY.js} +1 -1
  18. package/dist/{chunk-VDOAPLA6.cjs → chunk-EDTNS2XL.cjs} +3 -3
  19. package/dist/{chunk-YALLOVNO.cjs → chunk-FEL432I2.cjs} +3 -3
  20. package/dist/chunk-FMPHW7ID.cjs +61 -0
  21. package/dist/{chunk-OMFQ7Z63.cjs → chunk-FP4O2ICM.cjs} +3 -3
  22. package/dist/{chunk-AANSHH2O.cjs → chunk-G747FHUZ.cjs} +3 -3
  23. package/dist/{chunk-JZ2SHRGZ.js → chunk-G7FLGJYD.js} +1 -1
  24. package/dist/{chunk-LKPXHW5N.cjs → chunk-H25N5GP6.cjs} +3 -3
  25. package/dist/{chunk-FC6VDOC7.js → chunk-H6STFX4I.js} +11 -2
  26. package/dist/{chunk-MAQRXYE6.js → chunk-HQ4RAMSD.js} +1 -1
  27. package/dist/{chunk-6X22I6NQ.cjs → chunk-HZ2WESSL.cjs} +3 -3
  28. package/dist/{chunk-W2EWMV3A.cjs → chunk-IWR3H7IH.cjs} +3 -3
  29. package/dist/{chunk-5GAZQDVS.cjs → chunk-JZEF5Q3W.cjs} +13 -4
  30. package/dist/{chunk-77KZUPPN.cjs → chunk-KIUO2HIR.cjs} +3 -3
  31. package/dist/{chunk-OEK7QSQP.js → chunk-LBH5U2DZ.js} +1 -1
  32. package/dist/{chunk-PGSDXR2I.js → chunk-LC4RNKBM.js} +1 -1
  33. package/dist/{chunk-YAU6JCYL.cjs → chunk-LNWEJED7.cjs} +3 -3
  34. package/dist/{chunk-WWRFBLCR.cjs → chunk-LXFRQLH4.cjs} +3 -3
  35. package/dist/{chunk-B3U5G3AA.js → chunk-NWQSOSNF.js} +1 -1
  36. package/dist/{chunk-LIQSVJLS.js → chunk-OGU7AIZF.js} +1 -1
  37. package/dist/{chunk-NKCYXBGX.js → chunk-OKO6WO6M.js} +1 -1
  38. package/dist/{chunk-6OGDPSWT.js → chunk-OSUPJCBA.js} +1 -1
  39. package/dist/{chunk-ZBOQCXD4.js → chunk-POIDES2L.js} +190 -0
  40. package/dist/{chunk-MG25BEV4.cjs → chunk-Q24GYUTO.cjs} +3 -3
  41. package/dist/{chunk-YWV2BBXN.cjs → chunk-Q7TUNJD4.cjs} +190 -0
  42. package/dist/{chunk-DRXPGQM6.cjs → chunk-QQWX3ICK.cjs} +3 -3
  43. package/dist/{chunk-EAU42EVH.js → chunk-RFWCYULE.js} +1 -1
  44. package/dist/{chunk-VCRKYMJM.js → chunk-RHKBT3M2.js} +1 -1
  45. package/dist/{chunk-36NWHESN.js → chunk-RVGCMQ4J.js} +1 -1
  46. package/dist/{chunk-IJEEM3DI.js → chunk-T7PG4JDW.js} +1 -1
  47. package/dist/{chunk-JBNCMS42.cjs → chunk-TDEXEIHH.cjs} +3 -3
  48. package/dist/{chunk-VP5CCP5F.cjs → chunk-TPXRQT2H.cjs} +3 -3
  49. package/dist/{chunk-DPXRMSB2.js → chunk-TRQMRHFM.js} +1 -1
  50. package/dist/{chunk-ZFQQXWNB.js → chunk-U2PB6XEO.js} +1 -1
  51. package/dist/{chunk-G6DV7LX7.cjs → chunk-UCPKW43K.cjs} +3 -3
  52. package/dist/{chunk-PL2GERLG.cjs → chunk-V2KKGSKQ.cjs} +3 -3
  53. package/dist/{chunk-AI72FMOF.cjs → chunk-VKBNKPFO.cjs} +3 -3
  54. package/dist/{chunk-5OGW2ERW.js → chunk-XAN4CAVH.js} +1 -1
  55. package/dist/{chunk-CFFCBWYL.cjs → chunk-ZCX22PY4.cjs} +3 -3
  56. package/dist/{chunk-TSHL7ZO2.js → chunk-ZXPRRNZR.js} +1 -1
  57. package/dist/common/boolean.cjs +3 -3
  58. package/dist/common/boolean.js +2 -2
  59. package/dist/common/color.cjs +3 -3
  60. package/dist/common/color.js +2 -2
  61. package/dist/common/coordinate.cjs +3 -3
  62. package/dist/common/coordinate.js +2 -2
  63. package/dist/common/credit-card.cjs +3 -3
  64. package/dist/common/credit-card.js +2 -2
  65. package/dist/common/date.cjs +3 -3
  66. package/dist/common/date.js +2 -2
  67. package/dist/common/datetime.cjs +3 -3
  68. package/dist/common/datetime.js +2 -2
  69. package/dist/common/email.cjs +3 -3
  70. package/dist/common/email.js +2 -2
  71. package/dist/common/file.cjs +3 -3
  72. package/dist/common/file.js +2 -2
  73. package/dist/common/id.cjs +3 -3
  74. package/dist/common/id.d.cts +34 -8
  75. package/dist/common/id.d.ts +34 -8
  76. package/dist/common/id.js +2 -2
  77. package/dist/common/ip.cjs +3 -3
  78. package/dist/common/ip.js +2 -2
  79. package/dist/common/many-of.cjs +7 -0
  80. package/dist/common/many-of.d.cts +111 -0
  81. package/dist/common/many-of.d.ts +111 -0
  82. package/dist/common/many-of.js +7 -0
  83. package/dist/common/number.cjs +3 -3
  84. package/dist/common/number.js +2 -2
  85. package/dist/common/one-of.cjs +7 -0
  86. package/dist/common/one-of.d.cts +104 -0
  87. package/dist/common/one-of.d.ts +104 -0
  88. package/dist/common/one-of.js +7 -0
  89. package/dist/common/password.cjs +3 -3
  90. package/dist/common/password.js +2 -2
  91. package/dist/common/text.cjs +3 -3
  92. package/dist/common/text.js +2 -2
  93. package/dist/common/time.cjs +3 -3
  94. package/dist/common/time.js +2 -2
  95. package/dist/common/url.cjs +3 -3
  96. package/dist/common/url.js +2 -2
  97. package/dist/index.cjs +35 -27
  98. package/dist/index.d.cts +2 -0
  99. package/dist/index.d.ts +2 -0
  100. package/dist/index.js +46 -38
  101. package/dist/taiwan/bank-account.cjs +3 -3
  102. package/dist/taiwan/bank-account.js +2 -2
  103. package/dist/taiwan/business-id.cjs +3 -3
  104. package/dist/taiwan/business-id.js +2 -2
  105. package/dist/taiwan/fax.cjs +3 -3
  106. package/dist/taiwan/fax.js +2 -2
  107. package/dist/taiwan/invoice.cjs +3 -3
  108. package/dist/taiwan/invoice.js +2 -2
  109. package/dist/taiwan/license-plate.cjs +3 -3
  110. package/dist/taiwan/license-plate.js +2 -2
  111. package/dist/taiwan/mobile.cjs +3 -3
  112. package/dist/taiwan/mobile.js +2 -2
  113. package/dist/taiwan/national-id.cjs +3 -3
  114. package/dist/taiwan/national-id.js +2 -2
  115. package/dist/taiwan/passport.cjs +3 -3
  116. package/dist/taiwan/passport.js +2 -2
  117. package/dist/taiwan/postal-code.cjs +3 -3
  118. package/dist/taiwan/postal-code.js +2 -2
  119. package/dist/taiwan/tel.cjs +3 -3
  120. package/dist/taiwan/tel.js +2 -2
  121. package/package.json +15 -5
  122. package/src/i18n/locales/en-GB.json +19 -0
  123. package/src/i18n/locales/en-US.json +20 -1
  124. package/src/i18n/locales/id-ID.json +19 -0
  125. package/src/i18n/locales/ja-JP.json +19 -0
  126. package/src/i18n/locales/ko-KR.json +19 -0
  127. package/src/i18n/locales/ms-MY.json +19 -0
  128. package/src/i18n/locales/th-TH.json +19 -0
  129. package/src/i18n/locales/vi-VN.json +19 -0
  130. package/src/i18n/locales/zh-CN.json +19 -0
  131. package/src/i18n/locales/zh-TW.json +19 -0
  132. package/src/index.ts +2 -0
  133. package/src/validators/common/id.ts +45 -3
  134. package/src/validators/common/many-of.ts +219 -0
  135. package/src/validators/common/one-of.ts +172 -0
  136. package/tests/common/id.test.ts +68 -3
  137. package/tests/common/many-of.test.ts +198 -0
  138. package/tests/common/one-of.test.ts +136 -0
  139. package/tsup.config.ts +2 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * @fileoverview OneOf validator for Zod Kit
3
+ *
4
+ * Provides single-select validation that restricts input to a predefined set of allowed values,
5
+ * with support for case-insensitive matching, default values, and transformation.
6
+ *
7
+ * @author Ong Hoe Yuan
8
+ * @version 0.2.2
9
+ */
10
+
11
+ import { z, ZodType } from "zod"
12
+ import { t } from "../../i18n"
13
+ import { getLocale, type Locale } from "../../config"
14
+
15
+ /**
16
+ * Type definition for oneOf validation error messages
17
+ *
18
+ * @interface OneOfMessages
19
+ * @property {string} [required] - Message when field is required but empty
20
+ * @property {string} [invalid] - Message when value is not in the allowed list
21
+ */
22
+ export type OneOfMessages = {
23
+ required?: string
24
+ invalid?: string
25
+ }
26
+
27
+ /**
28
+ * Configuration options for oneOf validation
29
+ *
30
+ * @template IsRequired - Whether the field is required (affects return type)
31
+ * @template T - The type of allowed values
32
+ *
33
+ * @interface OneOfOptions
34
+ * @property {T[]} values - Array of allowed values
35
+ * @property {T | null} [defaultValue] - Default value when input is empty
36
+ * @property {boolean} [caseSensitive=true] - Whether string matching is case-sensitive
37
+ * @property {Function} [transform] - Custom transformation function applied after validation
38
+ * @property {Record<Locale, OneOfMessages>} [i18n] - Custom error messages for different locales
39
+ */
40
+ export type OneOfOptions<IsRequired extends boolean = true, T extends string | number = string | number> = {
41
+ values: T[]
42
+ defaultValue?: IsRequired extends true ? T : T | null
43
+ caseSensitive?: boolean
44
+ transform?: (value: T) => T
45
+ i18n?: Partial<Record<Locale, Partial<OneOfMessages>>>
46
+ }
47
+
48
+ /**
49
+ * Type alias for oneOf validation schema based on required flag
50
+ *
51
+ * @template IsRequired - Whether the field is required
52
+ * @template T - The type of allowed values
53
+ */
54
+ export type OneOfSchema<IsRequired extends boolean, T> = IsRequired extends true ? ZodType<T> : ZodType<T | null>
55
+
56
+ /**
57
+ * Creates a Zod schema for single-select validation that restricts values to a predefined set
58
+ *
59
+ * @template IsRequired - Whether the field is required (affects return type)
60
+ * @template T - The type of allowed values (string | number)
61
+ * @param {IsRequired} [required=false] - Whether the field is required
62
+ * @param {OneOfOptions<IsRequired, T>} options - Configuration options (values is required)
63
+ * @returns {OneOfSchema<IsRequired, T>} Zod schema for oneOf validation
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * // Basic single-select validation (optional by default)
68
+ * const roleSchema = oneOf(false, { values: ["admin", "editor", "viewer"] })
69
+ * roleSchema.parse("admin") // ✓ "admin"
70
+ * roleSchema.parse(null) // ✓ null
71
+ *
72
+ * // Required
73
+ * const statusSchema = oneOf(true, { values: ["active", "inactive", "pending"] })
74
+ * statusSchema.parse("active") // ✓ "active"
75
+ * statusSchema.parse(null) // ✗ Required
76
+ * statusSchema.parse("banned") // ✗ Invalid
77
+ *
78
+ * // Numeric values
79
+ * const prioritySchema = oneOf(true, { values: [1, 2, 3, 4, 5] })
80
+ * prioritySchema.parse(3) // ✓ 3
81
+ * prioritySchema.parse(10) // ✗ Invalid
82
+ *
83
+ * // Case-insensitive matching
84
+ * const colorSchema = oneOf(true, {
85
+ * values: ["red", "green", "blue"],
86
+ * caseSensitive: false
87
+ * })
88
+ * colorSchema.parse("RED") // ✓ "red" (normalized to match original)
89
+ * colorSchema.parse("Green") // ✓ "green"
90
+ *
91
+ * // With default value
92
+ * const tierSchema = oneOf(false, {
93
+ * values: ["free", "pro", "enterprise"],
94
+ * defaultValue: "free"
95
+ * })
96
+ * tierSchema.parse(null) // ✓ "free"
97
+ *
98
+ * // With transform
99
+ * const sizeSchema = oneOf(true, {
100
+ * values: ["s", "m", "l", "xl"],
101
+ * transform: (val) => val.toUpperCase() as any
102
+ * })
103
+ * sizeSchema.parse("m") // ✓ "M"
104
+ * ```
105
+ */
106
+ export function oneOf<IsRequired extends boolean = false, T extends string | number = string | number>(
107
+ required?: IsRequired,
108
+ options?: Omit<OneOfOptions<IsRequired, T>, "required">,
109
+ ): OneOfSchema<IsRequired, T> {
110
+ const { values = [] as unknown as T[], defaultValue = null, caseSensitive = true, transform, i18n } = options ?? {}
111
+
112
+ const isRequired = required ?? (false as IsRequired)
113
+
114
+ const getMessage = (key: keyof OneOfMessages, params?: Record<string, any>) => {
115
+ if (i18n) {
116
+ const currentLocale = getLocale()
117
+ const customMessages = i18n[currentLocale]
118
+ if (customMessages && customMessages[key]) {
119
+ const template = customMessages[key]!
120
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
121
+ }
122
+ }
123
+ return t(`common.oneOf.${key}`, params)
124
+ }
125
+
126
+ const preprocessFn = (val: unknown) => {
127
+ if (val === "" || val === null || val === undefined) {
128
+ return defaultValue
129
+ }
130
+
131
+ // Coerce number strings to numbers when values contains numbers
132
+ const hasNumbers = values.some((v) => typeof v === "number")
133
+ if (hasNumbers && typeof val === "string" && !isNaN(Number(val)) && val.trim() !== "") {
134
+ const numVal = Number(val)
135
+ if ((values as number[]).includes(numVal)) return numVal
136
+ }
137
+
138
+ // Case-insensitive normalization for string values
139
+ if (!caseSensitive && typeof val === "string") {
140
+ const match = values.find((v) => typeof v === "string" && v.toLowerCase() === val.toLowerCase())
141
+ if (match !== undefined) return match
142
+ return val
143
+ }
144
+
145
+ return val
146
+ }
147
+
148
+ const baseSchema = z.preprocess(preprocessFn, z.any())
149
+
150
+ const schema = baseSchema
151
+ .superRefine((val, ctx) => {
152
+ if (val === null) {
153
+ if (isRequired) {
154
+ ctx.addIssue({ code: "custom", message: getMessage("required") })
155
+ }
156
+ return
157
+ }
158
+
159
+ if (!values.includes(val as T)) {
160
+ ctx.addIssue({
161
+ code: "custom",
162
+ message: getMessage("invalid", { values: values.join(", ") }),
163
+ })
164
+ }
165
+ })
166
+ .transform((val) => {
167
+ if (val === null || !transform) return val
168
+ return transform(val as T)
169
+ })
170
+
171
+ return schema as unknown as OneOfSchema<IsRequired, T>
172
+ }
@@ -11,6 +11,9 @@ const locales = [
11
11
  maxLength: "Must be at most 10 characters",
12
12
  numeric: "Must be a numeric ID",
13
13
  uuid: "Must be a valid UUID",
14
+ uuidv1: "Must be a valid UUID v1",
15
+ uuidv4: "Must be a valid UUID v4",
16
+ uuidv7: "Must be a valid UUID v7",
14
17
  objectId: "Must be a valid MongoDB ObjectId",
15
18
  nanoid: "Must be a valid Nano ID",
16
19
  snowflake: "Must be a valid Snowflake ID",
@@ -33,6 +36,9 @@ const locales = [
33
36
  maxLength: "長度最多 10 字元",
34
37
  numeric: "必須為數字 ID",
35
38
  uuid: "必須為有效的 UUID",
39
+ uuidv1: "必須為有效的 UUID v1",
40
+ uuidv4: "必須為有效的 UUID v4",
41
+ uuidv7: "必須為有效的 UUID v7",
36
42
  objectId: "必須為有效的 MongoDB ObjectId",
37
43
  nanoid: "必須為有效的 Nano ID",
38
44
  snowflake: "必須為有效的 Snowflake ID",
@@ -51,7 +57,20 @@ const locales = [
51
57
  // Valid test IDs for different formats
52
58
  const validIds = {
53
59
  numeric: ["1", "123", "999999", "0"],
54
- uuid: ["550e8400-e29b-41d4-a716-446655440000", "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "f47ac10b-58cc-4372-a567-0e02b2c3d479"],
60
+ uuid: [
61
+ "550e8400-e29b-41d4-a716-446655440000", // v4
62
+ "6ba7b810-9dad-11d1-80b4-00c04fd430c8", // v1
63
+ "f47ac10b-58cc-4372-a567-0e02b2c3d479", // v4
64
+ "01939fee-4b0b-7da3-a9e4-79fb4c267eb8", // v7
65
+ "320c3d4d-cc00-875b-8ec9-32d5f69181c0", // v8
66
+ ],
67
+ uuidv1: ["6ba7b810-9dad-11d1-80b4-00c04fd430c8", "f47ac10b-58cc-1372-a567-0e02b2c3d479"],
68
+ uuidv3: ["a3bb189e-8bf9-3888-9912-ace4e6543002"],
69
+ uuidv4: ["550e8400-e29b-41d4-a716-446655440000", "f47ac10b-58cc-4372-a567-0e02b2c3d479"],
70
+ uuidv5: ["886313e1-3b8a-5372-9b90-0c9aee199e5d"],
71
+ uuidv6: ["1ef21d2f-1207-6b90-8b50-a2e4100c5480"],
72
+ uuidv7: ["01939fee-4b0b-7da3-a9e4-79fb4c267eb8", "018fce07-42dc-7dc0-ba2c-9f7e777caee3"],
73
+ uuidv8: ["320c3d4d-cc00-875b-8ec9-32d5f69181c0"],
55
74
  objectId: ["507f1f77bcf86cd799439011", "507f191e810c19729de860ea", "5a9427648b0beebeb69579cc"],
56
75
  nanoid: ["V1StGXR8_Z5jdHi6B-myT", "3IBBoOd_b1YSlnKdvQ8fK", "9_xnJ2QZt8vKl3_Kj5f7N"],
57
76
  snowflake: ["1234567890123456789", "9876543210987654321", "5555555555555555555"],
@@ -65,11 +84,19 @@ const invalidIds = {
65
84
  numeric: ["abc", "12a", ""],
66
85
  uuid: [
67
86
  "550e8400-e29b-41d4-a716-44665544000", // too short
68
- "550e8400-e29b-41d4-a716-44665544000g", // too long
69
- "550e8400-e29b-61d4-a716-446655440000", // wrong version (6 instead of 1-5)
87
+ "550e8400-e29b-41d4-a716-44665544000g", // invalid character
88
+ "550e8400-e29b-91d4-a716-446655440000", // wrong version (9)
70
89
  "550e8400-e29b-41d4-c716-446655440000", // wrong variant (c instead of 8,9,a,b)
71
90
  "not-a-uuid-at-all",
72
91
  ],
92
+ uuidv4: [
93
+ "6ba7b810-9dad-11d1-80b4-00c04fd430c8", // v1, not v4
94
+ "01939fee-4b0b-7da3-a9e4-79fb4c267eb8", // v7, not v4
95
+ ],
96
+ uuidv7: [
97
+ "550e8400-e29b-41d4-a716-446655440000", // v4, not v7
98
+ "6ba7b810-9dad-11d1-80b4-00c04fd430c8", // v1, not v7
99
+ ],
73
100
  objectId: [
74
101
  "507f1f77bcf86cd79943901", // too short
75
102
  "507f1f77bcf86cd799439011g", // invalid character
@@ -192,6 +219,44 @@ describe.each(locales)("id(true) locale: $locale", ({ locale, messages }) => {
192
219
  })
193
220
  })
194
221
 
222
+ describe("UUID version-specific validation", () => {
223
+ it("should accept valid UUIDv4 and reject other versions", () => {
224
+ const schema = id(true, { type: "uuidv4" })
225
+ validIds.uuidv4.forEach((v) => expect(schema.parse(v)).toBe(v))
226
+ invalidIds.uuidv4.forEach((v) => expect(() => schema.parse(v)).toThrow(messages.uuidv4))
227
+ })
228
+
229
+ it("should accept valid UUIDv7 and reject other versions", () => {
230
+ const schema = id(true, { type: "uuidv7" })
231
+ validIds.uuidv7.forEach((v) => expect(schema.parse(v)).toBe(v))
232
+ invalidIds.uuidv7.forEach((v) => expect(() => schema.parse(v)).toThrow(messages.uuidv7))
233
+ })
234
+
235
+ it("should accept valid UUIDv1", () => {
236
+ const schema = id(true, { type: "uuidv1" })
237
+ validIds.uuidv1.forEach((v) => expect(schema.parse(v)).toBe(v))
238
+ })
239
+
240
+ it("should accept valid UUIDv6", () => {
241
+ const schema = id(true, { type: "uuidv6" })
242
+ validIds.uuidv6.forEach((v) => expect(schema.parse(v)).toBe(v))
243
+ })
244
+
245
+ it("should accept any version with generic uuid type", () => {
246
+ const schema = id(true, { type: "uuid" })
247
+ // All version-specific IDs should pass generic uuid validation
248
+ ;[...validIds.uuidv1, ...validIds.uuidv4, ...validIds.uuidv7, ...validIds.uuidv8].forEach((v) => expect(schema.parse(v)).toBe(v))
249
+ })
250
+
251
+ it("should work with allowedTypes for specific versions", () => {
252
+ const schema = id(true, { allowedTypes: ["uuidv4", "uuidv7"] })
253
+ validIds.uuidv4.forEach((v) => expect(schema.parse(v)).toBe(v))
254
+ validIds.uuidv7.forEach((v) => expect(schema.parse(v)).toBe(v))
255
+ // v1 should be rejected
256
+ expect(() => schema.parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8")).toThrow()
257
+ })
258
+ })
259
+
195
260
  describe("ObjectId validation", () => {
196
261
  it("should accept valid ObjectIds", () => {
197
262
  const schema = id(true, { type: "objectId" })
@@ -0,0 +1,198 @@
1
+ import { describe, it, expect, beforeEach } from "vitest"
2
+ import { manyOf, setLocale, Locale } from "../../src"
3
+
4
+ const locales = [
5
+ {
6
+ locale: "en-US",
7
+ messages: {
8
+ required: "Required",
9
+ invalid: "Must be from: react, vue, angular, svelte",
10
+ minSelect: "Must select at least 2 item(s)",
11
+ maxSelect: "Must select at most 3 item(s)",
12
+ duplicate: "Duplicate values are not allowed",
13
+ },
14
+ },
15
+ {
16
+ locale: "zh-TW",
17
+ messages: {
18
+ required: "必填",
19
+ invalid: "必須為以下值之一:react, vue, angular, svelte",
20
+ minSelect: "至少需選擇 2 項",
21
+ maxSelect: "最多只能選擇 3 項",
22
+ duplicate: "不允許重複的值",
23
+ },
24
+ },
25
+ ] as const
26
+
27
+ const frameworks = ["react", "vue", "angular", "svelte"] as const
28
+
29
+ describe.each(locales)("manyOf(true) locale: $locale", ({ locale, messages }) => {
30
+ beforeEach(() => setLocale(locale as Locale))
31
+
32
+ describe("basic functionality", () => {
33
+ it("should accept valid arrays", () => {
34
+ const schema = manyOf(true, { values: [...frameworks] })
35
+ expect(schema.parse(["react"])).toEqual(["react"])
36
+ expect(schema.parse(["react", "vue"])).toEqual(["react", "vue"])
37
+ expect(schema.parse(["react", "vue", "angular", "svelte"])).toEqual(["react", "vue", "angular", "svelte"])
38
+ })
39
+
40
+ it("should reject invalid values in array", () => {
41
+ const schema = manyOf(true, { values: [...frameworks] })
42
+ expect(() => schema.parse(["react", "jquery"])).toThrow(messages.invalid)
43
+ })
44
+
45
+ it("should fail with empty when required", () => {
46
+ const schema = manyOf(true, { values: [...frameworks] })
47
+ expect(() => schema.parse(null)).toThrow(messages.required)
48
+ expect(() => schema.parse(undefined)).toThrow(messages.required)
49
+ expect(() => schema.parse("")).toThrow(messages.required)
50
+ })
51
+
52
+ it("should pass with null when not required", () => {
53
+ const schema = manyOf(false, { values: [...frameworks] })
54
+ expect(schema.parse(null)).toBe(null)
55
+ expect(schema.parse(undefined)).toBe(null)
56
+ expect(schema.parse("")).toBe(null)
57
+ })
58
+
59
+ it("should wrap single value in array", () => {
60
+ const schema = manyOf(true, { values: [...frameworks] })
61
+ expect(schema.parse("react")).toEqual(["react"])
62
+ })
63
+ })
64
+
65
+ describe("min/max selection", () => {
66
+ it("should enforce minimum selections", () => {
67
+ const schema = manyOf(true, { values: [...frameworks], min: 2 })
68
+ expect(() => schema.parse(["react"])).toThrow(messages.minSelect)
69
+ expect(schema.parse(["react", "vue"])).toEqual(["react", "vue"])
70
+ })
71
+
72
+ it("should enforce maximum selections", () => {
73
+ const schema = manyOf(true, { values: [...frameworks], max: 3 })
74
+ expect(() => schema.parse(["react", "vue", "angular", "svelte"])).toThrow(messages.maxSelect)
75
+ expect(schema.parse(["react", "vue", "angular"])).toEqual(["react", "vue", "angular"])
76
+ })
77
+
78
+ it("should enforce both min and max", () => {
79
+ const schema = manyOf(true, { values: [...frameworks], min: 2, max: 3 })
80
+ expect(() => schema.parse(["react"])).toThrow(messages.minSelect)
81
+ expect(() => schema.parse(["react", "vue", "angular", "svelte"])).toThrow(messages.maxSelect)
82
+ expect(schema.parse(["react", "vue"])).toEqual(["react", "vue"])
83
+ expect(schema.parse(["react", "vue", "angular"])).toEqual(["react", "vue", "angular"])
84
+ })
85
+ })
86
+
87
+ describe("duplicate handling", () => {
88
+ it("should reject duplicates by default", () => {
89
+ const schema = manyOf(true, { values: [...frameworks] })
90
+ expect(() => schema.parse(["react", "react"])).toThrow(messages.duplicate)
91
+ })
92
+
93
+ it("should allow duplicates when configured", () => {
94
+ const schema = manyOf(true, { values: [...frameworks], allowDuplicates: true })
95
+ expect(schema.parse(["react", "react"])).toEqual(["react", "react"])
96
+ })
97
+ })
98
+
99
+ describe("numeric values", () => {
100
+ it("should accept valid numeric arrays", () => {
101
+ const schema = manyOf(true, { values: [1, 2, 3, 4, 5] })
102
+ expect(schema.parse([1, 3, 5])).toEqual([1, 3, 5])
103
+ })
104
+
105
+ it("should reject invalid numeric values", () => {
106
+ const schema = manyOf(true, { values: [1, 2, 3] })
107
+ expect(() => schema.parse([1, 10])).toThrow()
108
+ })
109
+
110
+ it("should coerce number strings", () => {
111
+ const schema = manyOf(true, { values: [1, 2, 3] })
112
+ expect(schema.parse(["1", "2"])).toEqual([1, 2])
113
+ })
114
+ })
115
+
116
+ describe("case sensitivity", () => {
117
+ it("should be case sensitive by default", () => {
118
+ const schema = manyOf(true, { values: [...frameworks] })
119
+ expect(() => schema.parse(["React"])).toThrow()
120
+ })
121
+
122
+ it("should match case-insensitively when configured", () => {
123
+ const schema = manyOf(true, { values: [...frameworks], caseSensitive: false })
124
+ expect(schema.parse(["REACT", "Vue"])).toEqual(["react", "vue"])
125
+ })
126
+ })
127
+
128
+ describe("default value", () => {
129
+ it("should use default value when input is empty", () => {
130
+ const schema = manyOf(false, { values: [...frameworks], defaultValue: ["react"] })
131
+ expect(schema.parse(null)).toEqual(["react"])
132
+ expect(schema.parse(undefined)).toEqual(["react"])
133
+ expect(schema.parse("")).toEqual(["react"])
134
+ })
135
+
136
+ it("should use provided value over default", () => {
137
+ const schema = manyOf(false, { values: [...frameworks], defaultValue: ["react"] })
138
+ expect(schema.parse(["vue", "angular"])).toEqual(["vue", "angular"])
139
+ })
140
+ })
141
+
142
+ describe("transform", () => {
143
+ it("should apply transform to valid arrays", () => {
144
+ const schema = manyOf(true, {
145
+ values: [...frameworks],
146
+ transform: (vals) => vals.map((v) => v.toUpperCase()) as any,
147
+ })
148
+ expect(schema.parse(["react", "vue"])).toEqual(["REACT", "VUE"])
149
+ })
150
+ })
151
+
152
+ describe("custom i18n", () => {
153
+ it("should use custom messages when provided", () => {
154
+ const schema = manyOf(true, {
155
+ values: [...frameworks],
156
+ i18n: {
157
+ "en-US": { required: "Pick frameworks!", duplicate: "No repeats!" },
158
+ "zh-TW": { required: "請選擇框架!", duplicate: "不能重複!" },
159
+ },
160
+ })
161
+
162
+ if (locale === "en-US") {
163
+ expect(() => schema.parse(null)).toThrow("Pick frameworks!")
164
+ expect(() => schema.parse(["react", "react"])).toThrow("No repeats!")
165
+ } else {
166
+ expect(() => schema.parse(null)).toThrow("請選擇框架!")
167
+ expect(() => schema.parse(["react", "react"])).toThrow("不能重複!")
168
+ }
169
+ })
170
+ })
171
+
172
+ describe("complex scenarios", () => {
173
+ it("should work with all options combined", () => {
174
+ const schema = manyOf(true, {
175
+ values: [...frameworks],
176
+ min: 1,
177
+ max: 3,
178
+ caseSensitive: false,
179
+ })
180
+
181
+ expect(schema.parse(["REACT", "Vue"])).toEqual(["react", "vue"])
182
+ expect(() => schema.parse([])).toThrow() // min 1
183
+ expect(() => schema.parse(["react", "vue", "angular", "svelte"])).toThrow() // max 3
184
+ expect(() => schema.parse(["jquery"])).toThrow() // invalid
185
+ })
186
+
187
+ it("should handle empty array when required with no min", () => {
188
+ const schema = manyOf(true, { values: [...frameworks] })
189
+ // Empty array is a valid array, just has 0 items — not null
190
+ expect(schema.parse([])).toEqual([])
191
+ })
192
+
193
+ it("should handle empty array with min constraint", () => {
194
+ const schema = manyOf(true, { values: [...frameworks], min: 1 })
195
+ expect(() => schema.parse([])).toThrow()
196
+ })
197
+ })
198
+ })
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect, beforeEach } from "vitest"
2
+ import { oneOf, setLocale, Locale } from "../../src"
3
+
4
+ const locales = [
5
+ {
6
+ locale: "en-US",
7
+ messages: {
8
+ required: "Required",
9
+ invalid: "Must be one of: admin, editor, viewer",
10
+ },
11
+ },
12
+ {
13
+ locale: "zh-TW",
14
+ messages: {
15
+ required: "必填",
16
+ invalid: "必須為以下其中一個值:admin, editor, viewer",
17
+ },
18
+ },
19
+ ] as const
20
+
21
+ const stringValues = ["admin", "editor", "viewer"] as const
22
+ const numericValues = [1, 2, 3, 4, 5] as const
23
+
24
+ describe.each(locales)("oneOf(true) locale: $locale", ({ locale, messages }) => {
25
+ beforeEach(() => setLocale(locale as Locale))
26
+
27
+ describe("basic functionality", () => {
28
+ it("should accept valid values", () => {
29
+ const schema = oneOf(true, { values: [...stringValues] })
30
+ expect(schema.parse("admin")).toBe("admin")
31
+ expect(schema.parse("editor")).toBe("editor")
32
+ expect(schema.parse("viewer")).toBe("viewer")
33
+ })
34
+
35
+ it("should reject invalid values", () => {
36
+ const schema = oneOf(true, { values: [...stringValues] })
37
+ expect(() => schema.parse("hacker")).toThrow(messages.invalid)
38
+ expect(() => schema.parse("superadmin")).toThrow(messages.invalid)
39
+ })
40
+
41
+ it("should fail with empty when required", () => {
42
+ const schema = oneOf(true, { values: [...stringValues] })
43
+ expect(() => schema.parse("")).toThrow(messages.required)
44
+ expect(() => schema.parse(null)).toThrow(messages.required)
45
+ expect(() => schema.parse(undefined)).toThrow(messages.required)
46
+ })
47
+
48
+ it("should pass with null when not required", () => {
49
+ const schema = oneOf(false, { values: [...stringValues] })
50
+ expect(schema.parse("")).toBe(null)
51
+ expect(schema.parse(null)).toBe(null)
52
+ expect(schema.parse(undefined)).toBe(null)
53
+ })
54
+ })
55
+
56
+ describe("numeric values", () => {
57
+ it("should accept valid numeric values", () => {
58
+ const schema = oneOf(true, { values: [...numericValues] })
59
+ expect(schema.parse(1)).toBe(1)
60
+ expect(schema.parse(3)).toBe(3)
61
+ expect(schema.parse(5)).toBe(5)
62
+ })
63
+
64
+ it("should reject invalid numeric values", () => {
65
+ const schema = oneOf(true, { values: [...numericValues] })
66
+ expect(() => schema.parse(10)).toThrow()
67
+ expect(() => schema.parse(0)).toThrow()
68
+ })
69
+
70
+ it("should coerce number strings to numbers", () => {
71
+ const schema = oneOf(true, { values: [...numericValues] })
72
+ expect(schema.parse("3")).toBe(3)
73
+ expect(schema.parse("5")).toBe(5)
74
+ })
75
+ })
76
+
77
+ describe("case sensitivity", () => {
78
+ it("should be case sensitive by default", () => {
79
+ const schema = oneOf(true, { values: [...stringValues] })
80
+ expect(() => schema.parse("Admin")).toThrow()
81
+ expect(() => schema.parse("EDITOR")).toThrow()
82
+ })
83
+
84
+ it("should match case-insensitively when configured", () => {
85
+ const schema = oneOf(true, { values: [...stringValues], caseSensitive: false })
86
+ expect(schema.parse("Admin")).toBe("admin")
87
+ expect(schema.parse("EDITOR")).toBe("editor")
88
+ expect(schema.parse("VIEWER")).toBe("viewer")
89
+ })
90
+ })
91
+
92
+ describe("default value", () => {
93
+ it("should use default value when input is empty", () => {
94
+ const schema = oneOf(false, { values: [...stringValues], defaultValue: "viewer" })
95
+ expect(schema.parse("")).toBe("viewer")
96
+ expect(schema.parse(null)).toBe("viewer")
97
+ expect(schema.parse(undefined)).toBe("viewer")
98
+ })
99
+
100
+ it("should use provided value over default", () => {
101
+ const schema = oneOf(false, { values: [...stringValues], defaultValue: "viewer" })
102
+ expect(schema.parse("admin")).toBe("admin")
103
+ })
104
+ })
105
+
106
+ describe("transform", () => {
107
+ it("should apply transform to valid values", () => {
108
+ const schema = oneOf(true, {
109
+ values: [...stringValues],
110
+ transform: (val) => val.toUpperCase() as any,
111
+ })
112
+ expect(schema.parse("admin")).toBe("ADMIN")
113
+ expect(schema.parse("editor")).toBe("EDITOR")
114
+ })
115
+ })
116
+
117
+ describe("custom i18n", () => {
118
+ it("should use custom messages when provided", () => {
119
+ const schema = oneOf(true, {
120
+ values: [...stringValues],
121
+ i18n: {
122
+ "en-US": { required: "Pick a role!", invalid: "Bad role!" },
123
+ "zh-TW": { required: "請選擇角色!", invalid: "無效角色!" },
124
+ },
125
+ })
126
+
127
+ if (locale === "en-US") {
128
+ expect(() => schema.parse("")).toThrow("Pick a role!")
129
+ expect(() => schema.parse("hacker")).toThrow("Bad role!")
130
+ } else {
131
+ expect(() => schema.parse("")).toThrow("請選擇角色!")
132
+ expect(() => schema.parse("hacker")).toThrow("無效角色!")
133
+ }
134
+ })
135
+ })
136
+ })
package/tsup.config.ts CHANGED
@@ -9,7 +9,9 @@ export default defineConfig({
9
9
  "common/email": "src/validators/common/email.ts",
10
10
  "common/file": "src/validators/common/file.ts",
11
11
  "common/id": "src/validators/common/id.ts",
12
+ "common/many-of": "src/validators/common/many-of.ts",
12
13
  "common/number": "src/validators/common/number.ts",
14
+ "common/one-of": "src/validators/common/one-of.ts",
13
15
  "common/password": "src/validators/common/password.ts",
14
16
  "common/text": "src/validators/common/text.ts",
15
17
  "common/time": "src/validators/common/time.ts",