@hy_ong/zod-kit 0.0.2 → 0.0.5

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 (51) hide show
  1. package/.claude/settings.local.json +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +7 -7
  4. package/debug.js +21 -0
  5. package/debug.ts +16 -0
  6. package/dist/index.cjs +1663 -189
  7. package/dist/index.d.cts +324 -32
  8. package/dist/index.d.ts +324 -32
  9. package/dist/index.js +1634 -187
  10. package/eslint.config.mts +8 -0
  11. package/package.json +10 -9
  12. package/src/config.ts +1 -1
  13. package/src/i18n/locales/en.json +123 -49
  14. package/src/i18n/locales/zh-TW.json +123 -46
  15. package/src/index.ts +13 -7
  16. package/src/validators/common/boolean.ts +97 -0
  17. package/src/validators/common/date.ts +171 -0
  18. package/src/validators/common/email.ts +200 -0
  19. package/src/validators/common/id.ts +259 -0
  20. package/src/validators/common/number.ts +194 -0
  21. package/src/validators/common/password.ts +214 -0
  22. package/src/validators/common/text.ts +151 -0
  23. package/src/validators/common/url.ts +207 -0
  24. package/src/validators/taiwan/business-id.ts +140 -0
  25. package/src/validators/taiwan/fax.ts +182 -0
  26. package/src/validators/taiwan/mobile.ts +110 -0
  27. package/src/validators/taiwan/national-id.ts +208 -0
  28. package/src/validators/taiwan/tel.ts +182 -0
  29. package/tests/common/boolean.test.ts +340 -92
  30. package/tests/common/date.test.ts +458 -0
  31. package/tests/common/email.test.ts +232 -60
  32. package/tests/common/id.test.ts +535 -0
  33. package/tests/common/number.test.ts +230 -60
  34. package/tests/common/password.test.ts +281 -54
  35. package/tests/common/text.test.ts +227 -30
  36. package/tests/common/url.test.ts +492 -67
  37. package/tests/taiwan/business-id.test.ts +240 -0
  38. package/tests/taiwan/fax.test.ts +463 -0
  39. package/tests/taiwan/mobile.test.ts +373 -0
  40. package/tests/taiwan/national-id.test.ts +435 -0
  41. package/tests/taiwan/tel.test.ts +467 -0
  42. package/eslint.config.mjs +0 -10
  43. package/src/common/boolean.ts +0 -37
  44. package/src/common/date.ts +0 -44
  45. package/src/common/email.ts +0 -45
  46. package/src/common/integer.ts +0 -47
  47. package/src/common/number.ts +0 -38
  48. package/src/common/password.ts +0 -34
  49. package/src/common/text.ts +0 -35
  50. package/src/common/url.ts +0 -38
  51. package/tests/common/integer.test.ts +0 -90
@@ -0,0 +1,171 @@
1
+ import { z, ZodNullable, ZodString } from "zod"
2
+ import { t } from "../../i18n"
3
+ import { getLocale, type Locale } from "../../config"
4
+ import dayjs from "dayjs"
5
+ import customParseFormat from "dayjs/plugin/customParseFormat"
6
+ import isSameOrAfter from "dayjs/plugin/isSameOrAfter"
7
+ import isSameOrBefore from "dayjs/plugin/isSameOrBefore"
8
+ import isToday from "dayjs/plugin/isToday"
9
+ import weekday from "dayjs/plugin/weekday"
10
+
11
+ dayjs.extend(isSameOrAfter)
12
+ dayjs.extend(isSameOrBefore)
13
+ dayjs.extend(customParseFormat)
14
+ dayjs.extend(isToday)
15
+ dayjs.extend(weekday)
16
+
17
+ export type DateMessages = {
18
+ required?: string
19
+ invalid?: string
20
+ format?: string
21
+ min?: string
22
+ max?: string
23
+ includes?: string
24
+ excludes?: string
25
+ past?: string
26
+ future?: string
27
+ today?: string
28
+ notToday?: string
29
+ weekday?: string
30
+ notWeekday?: string
31
+ weekend?: string
32
+ notWeekend?: string
33
+ }
34
+
35
+ export type DateOptions<IsRequired extends boolean = true> = {
36
+ required?: IsRequired
37
+ min?: string
38
+ max?: string
39
+ format?: string
40
+ includes?: string
41
+ excludes?: string | string[]
42
+ mustBePast?: boolean
43
+ mustBeFuture?: boolean
44
+ mustBeToday?: boolean
45
+ mustNotBeToday?: boolean
46
+ weekdaysOnly?: boolean
47
+ weekendsOnly?: boolean
48
+ transform?: (value: string) => string
49
+ defaultValue?: IsRequired extends true ? string : string | null
50
+ i18n?: Record<Locale, DateMessages>
51
+ }
52
+
53
+ export type DateSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
54
+
55
+ export function date<IsRequired extends boolean = true>(options?: DateOptions<IsRequired>): DateSchema<IsRequired> {
56
+ const {
57
+ required = true,
58
+ min,
59
+ max,
60
+ format = "YYYY-MM-DD",
61
+ includes,
62
+ excludes,
63
+ mustBePast,
64
+ mustBeFuture,
65
+ mustBeToday,
66
+ mustNotBeToday,
67
+ weekdaysOnly,
68
+ weekendsOnly,
69
+ transform,
70
+ defaultValue = null,
71
+ i18n,
72
+ } = options ?? {}
73
+
74
+ const actualDefaultValue = defaultValue ?? (required ? "" : null)
75
+
76
+ // Helper function to get custom message or fallback to default i18n
77
+ const getMessage = (key: keyof DateMessages, params?: Record<string, any>) => {
78
+ if (i18n) {
79
+ const currentLocale = getLocale()
80
+ const customMessages = i18n[currentLocale]
81
+ if (customMessages && customMessages[key]) {
82
+ const template = customMessages[key]!
83
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
84
+ }
85
+ }
86
+ return t(`common.date.${key}`, params)
87
+ }
88
+
89
+ // Preprocessing function with transformations
90
+ const preprocessFn = (val: unknown) => {
91
+ if (val === "" || val === null || val === undefined) {
92
+ return actualDefaultValue
93
+ }
94
+
95
+ let processed = String(val).trim()
96
+
97
+ if (transform) {
98
+ processed = transform(processed)
99
+ }
100
+
101
+ return processed
102
+ }
103
+
104
+ const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
105
+
106
+ const schema = baseSchema.refine((val) => {
107
+ if (val === null) return true
108
+
109
+ // Required check
110
+ if (required && (val === "" || val === "null" || val === "undefined")) {
111
+ throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
112
+ }
113
+
114
+ // Format validation
115
+ if (val !== null && !dayjs(val, format, true).isValid()) {
116
+ throw new z.ZodError([{ code: "custom", message: getMessage("format", { format }), path: [] }])
117
+ }
118
+
119
+ const dateObj = dayjs(val, format)
120
+
121
+ // Range checks
122
+ if (val !== null && min !== undefined && !dateObj.isSameOrAfter(dayjs(min, format))) {
123
+ throw new z.ZodError([{ code: "custom", message: getMessage("min", { min }), path: [] }])
124
+ }
125
+ if (val !== null && max !== undefined && !dateObj.isSameOrBefore(dayjs(max, format))) {
126
+ throw new z.ZodError([{ code: "custom", message: getMessage("max", { max }), path: [] }])
127
+ }
128
+
129
+ // String content checks
130
+ if (val !== null && includes !== undefined && !val.includes(includes)) {
131
+ throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes }), path: [] }])
132
+ }
133
+ if (val !== null && excludes !== undefined) {
134
+ const excludeList = Array.isArray(excludes) ? excludes : [excludes]
135
+ for (const exclude of excludeList) {
136
+ if (val.includes(exclude)) {
137
+ throw new z.ZodError([{ code: "custom", message: getMessage("excludes", { excludes: exclude }), path: [] }])
138
+ }
139
+ }
140
+ }
141
+
142
+ // Time-based validations
143
+ const today = dayjs().startOf('day')
144
+ const targetDate = dateObj.startOf('day')
145
+
146
+ if (val !== null && mustBePast && !targetDate.isBefore(today)) {
147
+ throw new z.ZodError([{ code: "custom", message: getMessage("past"), path: [] }])
148
+ }
149
+ if (val !== null && mustBeFuture && !targetDate.isAfter(today)) {
150
+ throw new z.ZodError([{ code: "custom", message: getMessage("future"), path: [] }])
151
+ }
152
+ if (val !== null && mustBeToday && !targetDate.isSame(today)) {
153
+ throw new z.ZodError([{ code: "custom", message: getMessage("today"), path: [] }])
154
+ }
155
+ if (val !== null && mustNotBeToday && targetDate.isSame(today)) {
156
+ throw new z.ZodError([{ code: "custom", message: getMessage("notToday"), path: [] }])
157
+ }
158
+
159
+ // Weekday/weekend validations
160
+ if (val !== null && weekdaysOnly && (dateObj.day() === 0 || dateObj.day() === 6)) {
161
+ throw new z.ZodError([{ code: "custom", message: getMessage("weekday"), path: [] }])
162
+ }
163
+ if (val !== null && weekendsOnly && dateObj.day() !== 0 && dateObj.day() !== 6) {
164
+ throw new z.ZodError([{ code: "custom", message: getMessage("weekend"), path: [] }])
165
+ }
166
+
167
+ return true
168
+ })
169
+
170
+ return schema as unknown as DateSchema<IsRequired>
171
+ }
@@ -0,0 +1,200 @@
1
+ import { z, ZodNullable, ZodString } from "zod"
2
+ import { t } from "../../i18n"
3
+ import { getLocale, type Locale } from "../../config"
4
+
5
+ export type EmailMessages = {
6
+ required?: string
7
+ invalid?: string
8
+ minLength?: string
9
+ maxLength?: string
10
+ includes?: string
11
+ domain?: string
12
+ domainBlacklist?: string
13
+ businessOnly?: string
14
+ noDisposable?: string
15
+ }
16
+
17
+ export type EmailOptions<IsRequired extends boolean = true> = {
18
+ required?: IsRequired
19
+ domain?: string | string[]
20
+ domainBlacklist?: string[]
21
+ minLength?: number
22
+ maxLength?: number
23
+ includes?: string
24
+ excludes?: string | string[]
25
+ allowSubdomains?: boolean
26
+ businessOnly?: boolean
27
+ noDisposable?: boolean
28
+ lowercase?: boolean
29
+ transform?: (value: string) => string
30
+ defaultValue?: IsRequired extends true ? string : string | null
31
+ i18n?: Record<Locale, EmailMessages>
32
+ }
33
+
34
+ export type EmailSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
35
+
36
+ export function email<IsRequired extends boolean = true>(options?: EmailOptions<IsRequired>): EmailSchema<IsRequired> {
37
+ const {
38
+ required = true,
39
+ domain,
40
+ domainBlacklist,
41
+ minLength,
42
+ maxLength,
43
+ includes,
44
+ excludes,
45
+ allowSubdomains = true,
46
+ businessOnly = false,
47
+ noDisposable = false,
48
+ lowercase = true,
49
+ transform,
50
+ defaultValue,
51
+ i18n,
52
+ } = options ?? {}
53
+
54
+ // Helper function to get custom message or fallback to default i18n
55
+ const getMessage = (key: keyof EmailMessages, params?: Record<string, any>) => {
56
+ if (i18n) {
57
+ const currentLocale = getLocale()
58
+ const customMessages = i18n[currentLocale]
59
+ if (customMessages && customMessages[key]) {
60
+ const template = customMessages[key]!
61
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
62
+ }
63
+ }
64
+ return t(`common.email.${key}`, params)
65
+ }
66
+
67
+ // Common disposable email domains
68
+ const disposableDomains = ["10minutemail.com", "tempmail.org", "guerrillamail.com", "mailinator.com", "yopmail.com", "temp-mail.org", "throwaway.email", "getnada.com", "maildrop.cc"]
69
+
70
+ // Common business email patterns (not free providers)
71
+ const freeEmailDomains = ["gmail.com", "yahoo.com", "hotmail.com", "outlook.com", "icloud.com", "aol.com", "protonmail.com", "zoho.com"]
72
+
73
+ const actualDefaultValue = defaultValue ?? null
74
+
75
+ const baseSchema = z.preprocess(
76
+ (val) => {
77
+ if (val === "" || val === null || val === undefined) {
78
+ return actualDefaultValue
79
+ }
80
+
81
+ let processed = String(val).trim()
82
+
83
+ if (lowercase) {
84
+ processed = processed.toLowerCase()
85
+ }
86
+
87
+ if (transform) {
88
+ processed = transform(processed)
89
+ }
90
+
91
+ return processed
92
+ },
93
+ z.union([z.string().email(), z.null()])
94
+ )
95
+
96
+ const schema = baseSchema.refine((val) => {
97
+ // Required check first
98
+ if (required && val === null) {
99
+ throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
100
+ }
101
+
102
+ if (val === null) return true
103
+
104
+ // Invalid email check
105
+ if (typeof val !== "string") {
106
+ throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
107
+ }
108
+
109
+ // Length checks
110
+ if (minLength !== undefined && val.length < minLength) {
111
+ throw new z.ZodError([{ code: "custom", message: getMessage("minLength", { minLength }), path: [] }])
112
+ }
113
+ if (maxLength !== undefined && val.length > maxLength) {
114
+ throw new z.ZodError([{ code: "custom", message: getMessage("maxLength", { maxLength }), path: [] }])
115
+ }
116
+
117
+ // Content checks
118
+ if (includes !== undefined && !val.includes(includes)) {
119
+ throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes }), path: [] }])
120
+ }
121
+
122
+ if (excludes !== undefined) {
123
+ const excludeList = Array.isArray(excludes) ? excludes : [excludes]
124
+ for (const exclude of excludeList) {
125
+ if (val.includes(exclude)) {
126
+ throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes: exclude }), path: [] }])
127
+ }
128
+ }
129
+ }
130
+
131
+ // Extract domain from email
132
+ const emailDomain = val.split("@")[1]?.toLowerCase()
133
+ if (!emailDomain) {
134
+ throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
135
+ }
136
+
137
+ // Business email check (should come before domain validation)
138
+ if (businessOnly) {
139
+ const isFreeProvider = freeEmailDomains.some((freeDomain) => {
140
+ if (allowSubdomains) {
141
+ return emailDomain === freeDomain || emailDomain.endsWith("." + freeDomain)
142
+ }
143
+ return emailDomain === freeDomain
144
+ })
145
+
146
+ if (isFreeProvider) {
147
+ throw new z.ZodError([{ code: "custom", message: getMessage("businessOnly"), path: [] }])
148
+ }
149
+ }
150
+
151
+ // Domain blacklist
152
+ if (domainBlacklist && domainBlacklist.length > 0) {
153
+ const isBlacklisted = domainBlacklist.some((blacklistedDomain) => {
154
+ const lowerDomain = blacklistedDomain.toLowerCase()
155
+ if (allowSubdomains) {
156
+ return emailDomain === lowerDomain || emailDomain.endsWith("." + lowerDomain)
157
+ }
158
+ return emailDomain === lowerDomain
159
+ })
160
+
161
+ if (isBlacklisted) {
162
+ throw new z.ZodError([{ code: "custom", message: getMessage("domainBlacklist", { domain: emailDomain }), path: [] }])
163
+ }
164
+ }
165
+
166
+ // Domain validation
167
+ if (domain !== undefined) {
168
+ const allowedDomains = Array.isArray(domain) ? domain : [domain]
169
+ const isAllowed = allowedDomains.some((allowedDomain) => {
170
+ const lowerDomain = allowedDomain.toLowerCase()
171
+ if (allowSubdomains) {
172
+ return emailDomain === lowerDomain || emailDomain.endsWith("." + lowerDomain)
173
+ }
174
+ return emailDomain === lowerDomain
175
+ })
176
+
177
+ if (!isAllowed) {
178
+ throw new z.ZodError([{ code: "custom", message: getMessage("domain", { domain: Array.isArray(domain) ? domain.join(", ") : domain }), path: [] }])
179
+ }
180
+ }
181
+
182
+ // Disposable email check
183
+ if (noDisposable) {
184
+ const isDisposable = disposableDomains.some((disposableDomain) => {
185
+ if (allowSubdomains) {
186
+ return emailDomain === disposableDomain || emailDomain.endsWith("." + disposableDomain)
187
+ }
188
+ return emailDomain === disposableDomain
189
+ })
190
+
191
+ if (isDisposable) {
192
+ throw new z.ZodError([{ code: "custom", message: getMessage("noDisposable"), path: [] }])
193
+ }
194
+ }
195
+
196
+ return true
197
+ })
198
+
199
+ return schema as unknown as EmailSchema<IsRequired>
200
+ }
@@ -0,0 +1,259 @@
1
+ import { z, ZodNullable, ZodString } from "zod"
2
+ import { t } from "../../i18n"
3
+ import { getLocale, type Locale } from "../../config"
4
+
5
+ export type IdMessages = {
6
+ required?: string
7
+ invalid?: string
8
+ minLength?: string
9
+ maxLength?: string
10
+ numeric?: string
11
+ uuid?: string
12
+ objectId?: string
13
+ nanoid?: string
14
+ snowflake?: string
15
+ cuid?: string
16
+ ulid?: string
17
+ shortid?: string
18
+ customFormat?: string
19
+ includes?: string
20
+ excludes?: string
21
+ startsWith?: string
22
+ endsWith?: string
23
+ }
24
+
25
+ export type IdType =
26
+ | "numeric" // 純數字 ID (1, 123, 999999)
27
+ | "uuid" // UUID v4 格式
28
+ | "objectId" // MongoDB ObjectId (24位16進制)
29
+ | "nanoid" // Nano ID
30
+ | "snowflake" // Twitter Snowflake (19位數字)
31
+ | "cuid" // CUID 格式
32
+ | "ulid" // ULID 格式
33
+ | "shortid" // ShortId 格式
34
+ | "auto" // 自動檢測格式
35
+
36
+ export type IdOptions<IsRequired extends boolean = true> = {
37
+ required?: IsRequired
38
+ type?: IdType
39
+ minLength?: number
40
+ maxLength?: number
41
+ allowedTypes?: IdType[]
42
+ customRegex?: RegExp
43
+ includes?: string
44
+ excludes?: string | string[]
45
+ startsWith?: string
46
+ endsWith?: string
47
+ caseSensitive?: boolean
48
+ transform?: (value: string) => string
49
+ defaultValue?: IsRequired extends true ? string : string | null
50
+ i18n?: Record<Locale, IdMessages>
51
+ }
52
+
53
+ export type IdSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
54
+
55
+ // ID 格式的正則表達式
56
+ const ID_PATTERNS = {
57
+ numeric: /^\d+$/,
58
+ uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
59
+ objectId: /^[0-9a-f]{24}$/i,
60
+ nanoid: /^[A-Za-z0-9_-]{21}$/,
61
+ snowflake: /^\d{19}$/,
62
+ cuid: /^c[a-z0-9]{24}$/,
63
+ ulid: /^[0-9A-HJKMNP-TV-Z]{26}$/,
64
+ shortid: /^[A-Za-z0-9_-]{7,14}$/,
65
+ } as const
66
+
67
+ // 檢測 ID 類型(按照特異性排序,避免誤判)
68
+ const detectIdType = (value: string): IdType | null => {
69
+ // 按優先順序檢查(從最具體到最通用)
70
+ const orderedTypes: Array<[IdType, RegExp]> = [
71
+ ["uuid", ID_PATTERNS.uuid],
72
+ ["objectId", ID_PATTERNS.objectId],
73
+ ["snowflake", ID_PATTERNS.snowflake],
74
+ ["cuid", ID_PATTERNS.cuid],
75
+ ["ulid", ID_PATTERNS.ulid],
76
+ ["nanoid", ID_PATTERNS.nanoid],
77
+ ["numeric", ID_PATTERNS.numeric],
78
+ ["shortid", ID_PATTERNS.shortid], // 放最後,因為最通用
79
+ ]
80
+
81
+ for (const [type, pattern] of orderedTypes) {
82
+ if (pattern.test(value)) {
83
+ return type
84
+ }
85
+ }
86
+ return null
87
+ }
88
+
89
+ // 驗證特定 ID 類型
90
+ const validateIdType = (value: string, type: IdType): boolean => {
91
+ if (type === "auto") {
92
+ return detectIdType(value) !== null
93
+ }
94
+ const pattern = ID_PATTERNS[type as keyof typeof ID_PATTERNS]
95
+ return pattern ? pattern.test(value) : false
96
+ }
97
+
98
+ export function id<IsRequired extends boolean = true>(options?: IdOptions<IsRequired>): IdSchema<IsRequired> {
99
+ const {
100
+ required = true,
101
+ type = "auto",
102
+ minLength,
103
+ maxLength,
104
+ allowedTypes,
105
+ customRegex,
106
+ includes,
107
+ excludes,
108
+ startsWith,
109
+ endsWith,
110
+ caseSensitive = true,
111
+ transform,
112
+ defaultValue,
113
+ i18n,
114
+ } = options ?? {}
115
+
116
+ // Set appropriate default value based on required flag
117
+ const actualDefaultValue = defaultValue ?? (required ? "" : null)
118
+
119
+ // Helper function to get custom message or fallback to default i18n
120
+ const getMessage = (key: keyof IdMessages, params?: Record<string, any>) => {
121
+ if (i18n) {
122
+ const currentLocale = getLocale()
123
+ const customMessages = i18n[currentLocale]
124
+ if (customMessages && customMessages[key]) {
125
+ const template = customMessages[key]!
126
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
127
+ }
128
+ }
129
+ return t(`common.id.${key}`, params)
130
+ }
131
+
132
+ // Preprocessing function
133
+ const preprocessFn = (val: unknown) => {
134
+ if (val === "" || val === null || val === undefined) {
135
+ return actualDefaultValue
136
+ }
137
+
138
+ let processed = String(val)
139
+
140
+ if (transform) {
141
+ processed = transform(processed)
142
+ }
143
+
144
+ return processed
145
+ }
146
+
147
+ const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
148
+
149
+ const schema = baseSchema
150
+ .refine((val) => {
151
+ if (val === null) return true
152
+
153
+ // Required check
154
+ if (required && (val === "" || val === "null" || val === "undefined")) {
155
+ throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
156
+ }
157
+
158
+ // Create comparison value for case-insensitive checks
159
+ const comparisonVal = !caseSensitive ? val.toLowerCase() : val
160
+
161
+ // Length checks
162
+ if (val !== null && minLength !== undefined && val.length < minLength) {
163
+ throw new z.ZodError([{ code: "custom", message: getMessage("minLength", { minLength }), path: [] }])
164
+ }
165
+ if (val !== null && maxLength !== undefined && val.length > maxLength) {
166
+ throw new z.ZodError([{ code: "custom", message: getMessage("maxLength", { maxLength }), path: [] }])
167
+ }
168
+
169
+ // Check if we have content-based validations that override format checking
170
+ const hasContentValidations = customRegex !== undefined || startsWith !== undefined || endsWith !== undefined || includes !== undefined || excludes !== undefined
171
+
172
+ // Custom regex validation (overrides ID format validation)
173
+ if (val !== null && customRegex !== undefined) {
174
+ if (!customRegex.test(val)) {
175
+ throw new z.ZodError([{ code: "custom", message: getMessage("customFormat"), path: [] }])
176
+ }
177
+ } else if (val !== null && !hasContentValidations) {
178
+ // ID type validation (only if no custom regex or content validations)
179
+ let isValidId: boolean
180
+
181
+ if (allowedTypes && allowedTypes.length > 0) {
182
+ // Check if ID matches any of the allowed types
183
+ isValidId = allowedTypes.some((allowedType) => validateIdType(val, allowedType))
184
+ if (!isValidId) {
185
+ const typeNames = allowedTypes.join(", ")
186
+ throw new z.ZodError([{ code: "custom", message: getMessage("invalid") + ` (allowed types: ${typeNames})`, path: [] }])
187
+ }
188
+ } else if (type !== "auto") {
189
+ // Validate specific type
190
+ isValidId = validateIdType(val, type)
191
+ if (!isValidId) {
192
+ throw new z.ZodError([{ code: "custom", message: getMessage(type as keyof IdMessages) || getMessage("invalid"), path: [] }])
193
+ }
194
+ } else {
195
+ // Auto-detection - must match at least one known pattern
196
+ isValidId = detectIdType(val) !== null
197
+ if (!isValidId) {
198
+ throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
199
+ }
200
+ }
201
+ } else if (val !== null && hasContentValidations && type !== "auto" && !customRegex) {
202
+ // Still validate specific types even with content validations (but not auto)
203
+ if (allowedTypes && allowedTypes.length > 0) {
204
+ const isValidType = allowedTypes.some((allowedType) => validateIdType(val, allowedType))
205
+ if (!isValidType) {
206
+ const typeNames = allowedTypes.join(", ")
207
+ throw new z.ZodError([{ code: "custom", message: getMessage("invalid") + ` (allowed types: ${typeNames})`, path: [] }])
208
+ }
209
+ } else {
210
+ if (!validateIdType(val, type)) {
211
+ throw new z.ZodError([{ code: "custom", message: getMessage(type as keyof IdMessages) || getMessage("invalid"), path: [] }])
212
+ }
213
+ }
214
+ }
215
+
216
+ // String content checks (using comparisonVal for case sensitivity)
217
+ const searchStartsWith = !caseSensitive && startsWith ? startsWith.toLowerCase() : startsWith
218
+ const searchEndsWith = !caseSensitive && endsWith ? endsWith.toLowerCase() : endsWith
219
+ const searchIncludes = !caseSensitive && includes ? includes.toLowerCase() : includes
220
+
221
+ if (val !== null && startsWith !== undefined && !comparisonVal.startsWith(searchStartsWith!)) {
222
+ throw new z.ZodError([{ code: "custom", message: getMessage("startsWith", { startsWith }), path: [] }])
223
+ }
224
+ if (val !== null && endsWith !== undefined && !comparisonVal.endsWith(searchEndsWith!)) {
225
+ throw new z.ZodError([{ code: "custom", message: getMessage("endsWith", { endsWith }), path: [] }])
226
+ }
227
+ if (val !== null && includes !== undefined && !comparisonVal.includes(searchIncludes!)) {
228
+ throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes }), path: [] }])
229
+ }
230
+ if (val !== null && excludes !== undefined) {
231
+ const excludeList = Array.isArray(excludes) ? excludes : [excludes]
232
+ for (const exclude of excludeList) {
233
+ const searchExclude = !caseSensitive ? exclude.toLowerCase() : exclude
234
+ if (comparisonVal.includes(searchExclude)) {
235
+ throw new z.ZodError([{ code: "custom", message: getMessage("excludes", { excludes: exclude }), path: [] }])
236
+ }
237
+ }
238
+ }
239
+
240
+ return true
241
+ })
242
+ .transform((val) => {
243
+ if (val === null) return val
244
+
245
+ // Handle case transformations
246
+ const shouldPreserveCase = type === "uuid" || type === "objectId"
247
+
248
+ if (!caseSensitive && !shouldPreserveCase) {
249
+ return val.toLowerCase()
250
+ }
251
+
252
+ return val // preserve the original case for UUID/ObjectId or when case-sensitive
253
+ })
254
+
255
+ return schema as unknown as IdSchema<IsRequired>
256
+ }
257
+
258
+ // Export utility functions for external use
259
+ export { detectIdType, validateIdType, ID_PATTERNS }