@hy_ong/zod-kit 0.2.2 → 0.2.4

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 (135) hide show
  1. package/dist/{chunk-JXY7APBU.js → chunk-2JGRV3JO.js} +1 -1
  2. package/dist/{chunk-FM3EZ72O.js → chunk-3QLUXIY2.js} +1 -1
  3. package/dist/chunk-3ZF5JO3F.js +61 -0
  4. package/dist/{chunk-STNHTRG7.cjs → chunk-4AQB4RSU.cjs} +3 -3
  5. package/dist/{chunk-OPQJWHXN.cjs → chunk-53EEWALQ.cjs} +3 -3
  6. package/dist/{chunk-ZNJLWJX3.cjs → chunk-5ZMTAI4G.cjs} +3 -3
  7. package/dist/{chunk-RKUQREMW.js → chunk-6DFP7XY2.js} +1 -1
  8. package/dist/{chunk-5LEXCVLX.js → chunk-6IAPM7BP.js} +1 -1
  9. package/dist/{chunk-RO47DKQG.js → chunk-7SKH66CM.js} +1 -1
  10. package/dist/chunk-7X3XPK6A.js +92 -0
  11. package/dist/{chunk-UBK3VCVH.cjs → chunk-A2GAEU4O.cjs} +3 -3
  12. package/dist/{chunk-KFOHKTFD.js → chunk-AI4U42JV.js} +1 -1
  13. package/dist/{chunk-ERH4NIMU.cjs → chunk-AYCXAJRA.cjs} +3 -3
  14. package/dist/{chunk-YDH3L27K.cjs → chunk-BZSPJJYT.cjs} +3 -3
  15. package/dist/chunk-BZWQPSHO.cjs +92 -0
  16. package/dist/{chunk-H2XTEM4M.js → chunk-D55YFP5R.js} +1 -1
  17. package/dist/{chunk-LL4ZWLGO.js → chunk-E52GRXYY.js} +1 -1
  18. package/dist/{chunk-RRPXIRTQ.cjs → chunk-EDTNS2XL.cjs} +3 -3
  19. package/dist/{chunk-RYFG2GKM.cjs → chunk-FEL432I2.cjs} +3 -3
  20. package/dist/chunk-FMPHW7ID.cjs +61 -0
  21. package/dist/{chunk-JOLSGZGN.cjs → chunk-FP4O2ICM.cjs} +3 -3
  22. package/dist/{chunk-EGHL277K.cjs → chunk-G747FHUZ.cjs} +3 -3
  23. package/dist/{chunk-5JGTDL3Y.js → chunk-G7FLGJYD.js} +1 -1
  24. package/dist/{chunk-HMSM6FFA.cjs → chunk-H25N5GP6.cjs} +3 -3
  25. package/dist/{chunk-MHJFYYGV.js → chunk-H6STFX4I.js} +1 -1
  26. package/dist/{chunk-EDHT4LPO.js → chunk-HQ4RAMSD.js} +1 -1
  27. package/dist/{chunk-ORFHDJII.cjs → chunk-HZ2WESSL.cjs} +3 -3
  28. package/dist/{chunk-ORVV4MCF.cjs → chunk-IWR3H7IH.cjs} +3 -3
  29. package/dist/{chunk-B4EZYZOK.cjs → chunk-JZEF5Q3W.cjs} +3 -3
  30. package/dist/{chunk-HTEHINI7.cjs → chunk-KIUO2HIR.cjs} +3 -3
  31. package/dist/{chunk-QICQ6YEY.js → chunk-LBH5U2DZ.js} +1 -1
  32. package/dist/{chunk-42C5OHRK.js → chunk-LC4RNKBM.js} +1 -1
  33. package/dist/{chunk-TFGS34VD.cjs → chunk-LNWEJED7.cjs} +3 -3
  34. package/dist/{chunk-32JI34CV.cjs → chunk-LXFRQLH4.cjs} +3 -3
  35. package/dist/{chunk-46VAH2BJ.js → chunk-NWQSOSNF.js} +1 -1
  36. package/dist/{chunk-WDI4QJMQ.js → chunk-OGU7AIZF.js} +1 -1
  37. package/dist/{chunk-YIM3D2AD.js → chunk-OKO6WO6M.js} +1 -1
  38. package/dist/{chunk-MINMXGW3.js → chunk-OSUPJCBA.js} +1 -1
  39. package/dist/{chunk-6AAP4LPF.js → chunk-POIDES2L.js} +110 -0
  40. package/dist/{chunk-YPSEIDUR.cjs → chunk-Q24GYUTO.cjs} +3 -3
  41. package/dist/{chunk-UCOXAZJF.cjs → chunk-Q7TUNJD4.cjs} +110 -0
  42. package/dist/{chunk-L4HSIKTU.cjs → chunk-QQWX3ICK.cjs} +3 -3
  43. package/dist/{chunk-COYKBWTI.js → chunk-RFWCYULE.js} +1 -1
  44. package/dist/{chunk-ZXUMK2RR.js → chunk-RHKBT3M2.js} +1 -1
  45. package/dist/{chunk-LH7ZB4BK.js → chunk-RVGCMQ4J.js} +1 -1
  46. package/dist/{chunk-K2UOY6TB.js → chunk-T7PG4JDW.js} +1 -1
  47. package/dist/{chunk-DFJZ3NS2.cjs → chunk-TDEXEIHH.cjs} +3 -3
  48. package/dist/{chunk-GJIRDBZJ.cjs → chunk-TPXRQT2H.cjs} +3 -3
  49. package/dist/{chunk-UQZKFAFX.js → chunk-TRQMRHFM.js} +1 -1
  50. package/dist/{chunk-WABKPFPK.js → chunk-U2PB6XEO.js} +1 -1
  51. package/dist/{chunk-ZTFCJCPO.cjs → chunk-UCPKW43K.cjs} +3 -3
  52. package/dist/{chunk-TQXDUMML.cjs → chunk-V2KKGSKQ.cjs} +3 -3
  53. package/dist/{chunk-VB2KV2ZM.cjs → chunk-VKBNKPFO.cjs} +3 -3
  54. package/dist/{chunk-MM7IL2RG.js → chunk-XAN4CAVH.js} +1 -1
  55. package/dist/{chunk-M6MTP3NY.cjs → chunk-ZCX22PY4.cjs} +3 -3
  56. package/dist/{chunk-2SWEVDFZ.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.js +2 -2
  75. package/dist/common/ip.cjs +3 -3
  76. package/dist/common/ip.js +2 -2
  77. package/dist/common/many-of.cjs +7 -0
  78. package/dist/common/many-of.d.cts +111 -0
  79. package/dist/common/many-of.d.ts +111 -0
  80. package/dist/common/many-of.js +7 -0
  81. package/dist/common/number.cjs +3 -3
  82. package/dist/common/number.js +2 -2
  83. package/dist/common/one-of.cjs +7 -0
  84. package/dist/common/one-of.d.cts +104 -0
  85. package/dist/common/one-of.d.ts +104 -0
  86. package/dist/common/one-of.js +7 -0
  87. package/dist/common/password.cjs +3 -3
  88. package/dist/common/password.js +2 -2
  89. package/dist/common/text.cjs +3 -3
  90. package/dist/common/text.js +2 -2
  91. package/dist/common/time.cjs +3 -3
  92. package/dist/common/time.js +2 -2
  93. package/dist/common/url.cjs +3 -3
  94. package/dist/common/url.js +2 -2
  95. package/dist/index.cjs +35 -27
  96. package/dist/index.d.cts +2 -0
  97. package/dist/index.d.ts +2 -0
  98. package/dist/index.js +46 -38
  99. package/dist/taiwan/bank-account.cjs +3 -3
  100. package/dist/taiwan/bank-account.js +2 -2
  101. package/dist/taiwan/business-id.cjs +3 -3
  102. package/dist/taiwan/business-id.js +2 -2
  103. package/dist/taiwan/fax.cjs +3 -3
  104. package/dist/taiwan/fax.js +2 -2
  105. package/dist/taiwan/invoice.cjs +3 -3
  106. package/dist/taiwan/invoice.js +2 -2
  107. package/dist/taiwan/license-plate.cjs +3 -3
  108. package/dist/taiwan/license-plate.js +2 -2
  109. package/dist/taiwan/mobile.cjs +3 -3
  110. package/dist/taiwan/mobile.js +2 -2
  111. package/dist/taiwan/national-id.cjs +3 -3
  112. package/dist/taiwan/national-id.js +2 -2
  113. package/dist/taiwan/passport.cjs +3 -3
  114. package/dist/taiwan/passport.js +2 -2
  115. package/dist/taiwan/postal-code.cjs +3 -3
  116. package/dist/taiwan/postal-code.js +2 -2
  117. package/dist/taiwan/tel.cjs +3 -3
  118. package/dist/taiwan/tel.js +2 -2
  119. package/package.json +11 -1
  120. package/src/i18n/locales/en-GB.json +11 -0
  121. package/src/i18n/locales/en-US.json +11 -0
  122. package/src/i18n/locales/id-ID.json +11 -0
  123. package/src/i18n/locales/ja-JP.json +11 -0
  124. package/src/i18n/locales/ko-KR.json +11 -0
  125. package/src/i18n/locales/ms-MY.json +11 -0
  126. package/src/i18n/locales/th-TH.json +11 -0
  127. package/src/i18n/locales/vi-VN.json +11 -0
  128. package/src/i18n/locales/zh-CN.json +11 -0
  129. package/src/i18n/locales/zh-TW.json +11 -0
  130. package/src/index.ts +2 -0
  131. package/src/validators/common/many-of.ts +219 -0
  132. package/src/validators/common/one-of.ts +172 -0
  133. package/tests/common/many-of.test.ts +198 -0
  134. package/tests/common/one-of.test.ts +136 -0
  135. 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, V extends readonly (string | number)[] = readonly (string | number)[]> = {
41
+ values: V
42
+ defaultValue?: IsRequired extends true ? V[number] : V[number] | null
43
+ caseSensitive?: boolean
44
+ transform?: (value: V[number]) => V[number]
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, V extends readonly (string | number)[]> = IsRequired extends true ? ZodType<V[number]> : ZodType<V[number] | 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, const V extends readonly (string | number)[] = readonly (string | number)[]>(
107
+ required?: IsRequired,
108
+ options?: Omit<OneOfOptions<IsRequired, V>, "required">,
109
+ ): OneOfSchema<IsRequired, V> {
110
+ const { values = [] as unknown as V, 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 readonly 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 as readonly (string | number)[]).includes(val as V[number])) {
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 V[number])
169
+ })
170
+
171
+ return schema as unknown as OneOfSchema<IsRequired, V>
172
+ }
@@ -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",