@hy_ong/zod-kit 0.0.4 → 0.0.6

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 (59) hide show
  1. package/.claude/settings.local.json +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +465 -97
  4. package/debug.js +21 -0
  5. package/debug.ts +16 -0
  6. package/dist/index.cjs +3127 -146
  7. package/dist/index.d.cts +3021 -25
  8. package/dist/index.d.ts +3021 -25
  9. package/dist/index.js +3081 -144
  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 +161 -25
  14. package/src/i18n/locales/zh-TW.json +165 -26
  15. package/src/index.ts +17 -7
  16. package/src/validators/common/boolean.ts +191 -0
  17. package/src/validators/common/date.ts +299 -0
  18. package/src/validators/common/datetime.ts +673 -0
  19. package/src/validators/common/email.ts +313 -0
  20. package/src/validators/common/file.ts +384 -0
  21. package/src/validators/common/id.ts +471 -0
  22. package/src/validators/common/number.ts +319 -0
  23. package/src/validators/common/password.ts +386 -0
  24. package/src/validators/common/text.ts +271 -0
  25. package/src/validators/common/time.ts +600 -0
  26. package/src/validators/common/url.ts +347 -0
  27. package/src/validators/taiwan/business-id.ts +262 -0
  28. package/src/validators/taiwan/fax.ts +327 -0
  29. package/src/validators/taiwan/mobile.ts +242 -0
  30. package/src/validators/taiwan/national-id.ts +425 -0
  31. package/src/validators/taiwan/postal-code.ts +1049 -0
  32. package/src/validators/taiwan/tel.ts +330 -0
  33. package/tests/common/boolean.test.ts +340 -92
  34. package/tests/common/date.test.ts +458 -0
  35. package/tests/common/datetime.test.ts +693 -0
  36. package/tests/common/email.test.ts +232 -60
  37. package/tests/common/file.test.ts +479 -0
  38. package/tests/common/id.test.ts +535 -0
  39. package/tests/common/number.test.ts +230 -60
  40. package/tests/common/password.test.ts +271 -44
  41. package/tests/common/text.test.ts +210 -13
  42. package/tests/common/time.test.ts +528 -0
  43. package/tests/common/url.test.ts +492 -67
  44. package/tests/taiwan/business-id.test.ts +240 -0
  45. package/tests/taiwan/fax.test.ts +463 -0
  46. package/tests/taiwan/mobile.test.ts +373 -0
  47. package/tests/taiwan/national-id.test.ts +435 -0
  48. package/tests/taiwan/postal-code.test.ts +705 -0
  49. package/tests/taiwan/tel.test.ts +467 -0
  50. package/eslint.config.mjs +0 -10
  51. package/src/common/boolean.ts +0 -36
  52. package/src/common/date.ts +0 -43
  53. package/src/common/email.ts +0 -44
  54. package/src/common/integer.ts +0 -46
  55. package/src/common/number.ts +0 -37
  56. package/src/common/password.ts +0 -33
  57. package/src/common/text.ts +0 -34
  58. package/src/common/url.ts +0 -37
  59. package/tests/common/integer.test.ts +0 -90
@@ -0,0 +1,1049 @@
1
+ /**
2
+ * @fileoverview Taiwan Postal Code validator for Zod Kit
3
+ *
4
+ * Provides comprehensive validation for Taiwan postal codes with support for
5
+ * 3-digit, 5-digit (legacy), and 6-digit (current) formats based on official
6
+ * Chunghwa Post specifications.
7
+ *
8
+ * @author Ong Hoe Yuan
9
+ * @version 0.0.5
10
+ */
11
+
12
+ import { z, ZodNullable, ZodString } from "zod"
13
+ import { t } from "../../i18n"
14
+ import { getLocale, type Locale } from "../../config"
15
+
16
+ /**
17
+ * Type definition for postal code validation error messages
18
+ *
19
+ * @interface PostalCodeMessages
20
+ * @property {string} [required] - Message when field is required but empty
21
+ * @property {string} [invalid] - Message when postal code format is invalid
22
+ * @property {string} [invalidFormat] - Message when format doesn't match expected pattern
23
+ * @property {string} [invalidRange] - Message when postal code is outside valid range
24
+ * @property {string} [legacy5DigitWarning] - Warning message for 5-digit legacy format
25
+ * @property {string} [format3Only] - Message when only 3-digit format is allowed
26
+ * @property {string} [format5Only] - Message when only 5-digit format is allowed
27
+ * @property {string} [format6Only] - Message when only 6-digit format is allowed
28
+ */
29
+ export type PostalCodeMessages = {
30
+ required?: string
31
+ invalid?: string
32
+ invalidFormat?: string
33
+ invalidRange?: string
34
+ legacy5DigitWarning?: string
35
+ format3Only?: string
36
+ format5Only?: string
37
+ format6Only?: string
38
+ invalidSuffix?: string
39
+ deprecated5Digit?: string
40
+ }
41
+
42
+ /**
43
+ * Postal code format types supported in Taiwan
44
+ *
45
+ * @typedef {"3" | "5" | "6" | "3+5" | "3+6" | "5+6" | "all"} PostalCodeFormat
46
+ *
47
+ * Available formats:
48
+ * - "3": 3-digit basic postal codes (100-982)
49
+ * - "5": 5-digit postal codes (3+2 format, legacy)
50
+ * - "6": 6-digit postal codes (3+3 format, current standard)
51
+ * - "3+5": Accept both 3-digit and 5-digit formats
52
+ * - "3+6": Accept both 3-digit and 6-digit formats (recommended)
53
+ * - "5+6": Accept both 5-digit and 6-digit formats
54
+ * - "all": Accept all formats (3, 5, and 6 digits)
55
+ */
56
+ export type PostalCodeFormat =
57
+ | "3" // 3-digit only
58
+ | "5" // 5-digit only (legacy)
59
+ | "6" // 6-digit only (current)
60
+ | "3+5" // 3-digit or 5-digit
61
+ | "3+6" // 3-digit or 6-digit (recommended)
62
+ | "5+6" // 5-digit or 6-digit
63
+ | "all" // All formats accepted
64
+
65
+ /**
66
+ * Configuration options for Taiwan postal code validation
67
+ *
68
+ * @template IsRequired - Whether the field is required (affects return type)
69
+ *
70
+ * @interface PostalCodeOptions
71
+ * @property {IsRequired} [required=true] - Whether the field is required
72
+ * @property {PostalCodeFormat} [format="3+6"] - Which postal code formats to accept
73
+ * @property {boolean} [strictValidation=true] - Enable strict validation against known postal code ranges
74
+ * @property {boolean} [allowDashes=true] - Whether to allow dashes in postal codes (e.g., "100-01" or "100-001")
75
+ * @property {boolean} [warn5Digit=true] - Whether to show warning for 5-digit legacy format
76
+ * @property {string[]} [allowedPrefixes] - Specific 3-digit prefixes to allow (if strictValidation is true)
77
+ * @property {string[]} [blockedPrefixes] - Specific 3-digit prefixes to block
78
+ * @property {Function} [transform] - Custom transformation function for postal codes
79
+ * @property {string | null} [defaultValue] - Default value when input is empty
80
+ * @property {Record<Locale, PostalCodeMessages>} [i18n] - Custom error messages for different locales
81
+ */
82
+ export type PostalCodeOptions<IsRequired extends boolean = true> = {
83
+ required?: IsRequired
84
+ format?: PostalCodeFormat
85
+ strictValidation?: boolean
86
+ allowDashes?: boolean
87
+ warn5Digit?: boolean
88
+ allowedPrefixes?: string[]
89
+ blockedPrefixes?: string[]
90
+ transform?: (value: string) => string
91
+ defaultValue?: IsRequired extends true ? string : string | null
92
+ i18n?: Record<Locale, PostalCodeMessages>
93
+ strictSuffixValidation?: boolean
94
+ deprecate5Digit?: boolean
95
+ }
96
+
97
+ /**
98
+ * Type alias for postal code validation schema based on required flag
99
+ *
100
+ * @template IsRequired - Whether the field is required
101
+ * @typedef PostalCodeSchema
102
+ * @description Returns ZodString if required, ZodNullable<ZodString> if optional
103
+ */
104
+ export type PostalCodeSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
105
+
106
+ /**
107
+ * Valid 3-digit postal code prefixes for Taiwan
108
+ * Based on official Chunghwa Post data (2024)
109
+ */
110
+ const VALID_3_DIGIT_PREFIXES = [
111
+ // Taipei City (台北市) - 100-116
112
+ "100",
113
+ "103",
114
+ "104",
115
+ "105",
116
+ "106",
117
+ "108",
118
+ "110",
119
+ "111",
120
+ "112",
121
+ "114",
122
+ "115",
123
+ "116",
124
+
125
+ // New Taipei City (新北市) - 200-253
126
+ "200",
127
+ "201",
128
+ "202",
129
+ "203",
130
+ "204",
131
+ "205",
132
+ "206",
133
+ "207",
134
+ "208",
135
+ "220",
136
+ "221",
137
+ "222",
138
+ "223",
139
+ "224",
140
+ "226",
141
+ "227",
142
+ "228",
143
+ "231",
144
+ "232",
145
+ "233",
146
+ "234",
147
+ "235",
148
+ "236",
149
+ "237",
150
+ "238",
151
+ "239",
152
+ "241",
153
+ "242",
154
+ "243",
155
+ "244",
156
+ "247",
157
+ "248",
158
+ "249",
159
+ "251",
160
+ "252",
161
+ "253",
162
+
163
+ // Keelung City (基隆市) - 200-206
164
+ "200",
165
+ "201",
166
+ "202",
167
+ "203",
168
+ "204",
169
+ "205",
170
+ "206",
171
+
172
+ // Taoyuan City (桃園市) - 300-338
173
+ "300",
174
+ "302",
175
+ "303",
176
+ "304",
177
+ "305",
178
+ "306",
179
+ "307",
180
+ "308",
181
+ "310",
182
+ "311",
183
+ "312",
184
+ "313",
185
+ "314",
186
+ "315",
187
+ "316",
188
+ "317",
189
+ "318",
190
+ "320",
191
+ "324",
192
+ "325",
193
+ "326",
194
+ "327",
195
+ "328",
196
+ "330",
197
+ "333",
198
+ "334",
199
+ "335",
200
+ "336",
201
+ "337",
202
+ "338",
203
+
204
+ // Hsinchu County (新竹縣) - 300-315
205
+ "300",
206
+ "302",
207
+ "303",
208
+ "304",
209
+ "305",
210
+ "306",
211
+ "307",
212
+ "308",
213
+ "310",
214
+ "311",
215
+ "312",
216
+ "313",
217
+ "314",
218
+ "315",
219
+
220
+ // Hsinchu City (新竹市) - 300
221
+ "300",
222
+
223
+ // Miaoli County (苗栗縣) - 350-369
224
+ "350",
225
+ "351",
226
+ "352",
227
+ "353",
228
+ "354",
229
+ "356",
230
+ "357",
231
+ "358",
232
+ "360",
233
+ "361",
234
+ "362",
235
+ "363",
236
+ "364",
237
+ "365",
238
+ "366",
239
+ "367",
240
+ "368",
241
+ "369",
242
+
243
+ // Taichung City (台中市) - 400-439
244
+ "400",
245
+ "401",
246
+ "402",
247
+ "403",
248
+ "404",
249
+ "406",
250
+ "407",
251
+ "408",
252
+ "411",
253
+ "412",
254
+ "413",
255
+ "414",
256
+ "420",
257
+ "421",
258
+ "422",
259
+ "423",
260
+ "424",
261
+ "426",
262
+ "427",
263
+ "428",
264
+ "429",
265
+ "432",
266
+ "433",
267
+ "434",
268
+ "435",
269
+ "436",
270
+ "437",
271
+ "438",
272
+ "439",
273
+
274
+ // Changhua County (彰化縣) - 500-530
275
+ "500",
276
+ "502",
277
+ "503",
278
+ "504",
279
+ "505",
280
+ "506",
281
+ "507",
282
+ "508",
283
+ "509",
284
+ "510",
285
+ "511",
286
+ "512",
287
+ "513",
288
+ "514",
289
+ "515",
290
+ "516",
291
+ "520",
292
+ "521",
293
+ "522",
294
+ "523",
295
+ "524",
296
+ "525",
297
+ "526",
298
+ "527",
299
+ "528",
300
+ "530",
301
+
302
+ // Nantou County (南投縣) - 540-558
303
+ "540",
304
+ "541",
305
+ "542",
306
+ "544",
307
+ "545",
308
+ "546",
309
+ "551",
310
+ "552",
311
+ "553",
312
+ "555",
313
+ "556",
314
+ "557",
315
+ "558",
316
+
317
+ // Yunlin County (雲林縣) - 630-655
318
+ "630",
319
+ "631",
320
+ "632",
321
+ "633",
322
+ "634",
323
+ "635",
324
+ "636",
325
+ "637",
326
+ "638",
327
+ "640",
328
+ "643",
329
+ "646",
330
+ "647",
331
+ "648",
332
+ "649",
333
+ "651",
334
+ "652",
335
+ "653",
336
+ "654",
337
+ "655",
338
+
339
+ // Chiayi County (嘉義縣) - 600-625
340
+ "600",
341
+ "602",
342
+ "603",
343
+ "604",
344
+ "605",
345
+ "606",
346
+ "607",
347
+ "608",
348
+ "611",
349
+ "612",
350
+ "613",
351
+ "614",
352
+ "615",
353
+ "616",
354
+ "621",
355
+ "622",
356
+ "623",
357
+ "624",
358
+ "625",
359
+
360
+ // Chiayi City (嘉義市) - 600
361
+ "600",
362
+
363
+ // Tainan City (台南市) - 700-745
364
+ "700",
365
+ "701",
366
+ "702",
367
+ "704",
368
+ "708",
369
+ "709",
370
+ "710",
371
+ "711",
372
+ "712",
373
+ "713",
374
+ "714",
375
+ "715",
376
+ "716",
377
+ "717",
378
+ "718",
379
+ "719",
380
+ "720",
381
+ "721",
382
+ "722",
383
+ "723",
384
+ "724",
385
+ "725",
386
+ "726",
387
+ "727",
388
+ "730",
389
+ "731",
390
+ "732",
391
+ "733",
392
+ "734",
393
+ "735",
394
+ "736",
395
+ "737",
396
+ "741",
397
+ "742",
398
+ "743",
399
+ "744",
400
+ "745",
401
+
402
+ // Kaohsiung City (高雄市) - 800-852
403
+ "800",
404
+ "801",
405
+ "802",
406
+ "803",
407
+ "804",
408
+ "805",
409
+ "806",
410
+ "807",
411
+ "811",
412
+ "812",
413
+ "813",
414
+ "814",
415
+ "815",
416
+ "820",
417
+ "821",
418
+ "822",
419
+ "823",
420
+ "824",
421
+ "825",
422
+ "826",
423
+ "827",
424
+ "828",
425
+ "829",
426
+ "830",
427
+ "831",
428
+ "832",
429
+ "833",
430
+ "840",
431
+ "842",
432
+ "843",
433
+ "844",
434
+ "845",
435
+ "846",
436
+ "847",
437
+ "848",
438
+ "849",
439
+ "851",
440
+ "852",
441
+
442
+ // Pingtung County (屏東縣) - 900-947
443
+ "900",
444
+ "901",
445
+ "902",
446
+ "903",
447
+ "904",
448
+ "905",
449
+ "906",
450
+ "907",
451
+ "908",
452
+ "909",
453
+ "911",
454
+ "912",
455
+ "913",
456
+ "920",
457
+ "921",
458
+ "922",
459
+ "923",
460
+ "924",
461
+ "925",
462
+ "926",
463
+ "927",
464
+ "928",
465
+ "929",
466
+ "931",
467
+ "932",
468
+ "940",
469
+ "941",
470
+ "942",
471
+ "943",
472
+ "944",
473
+ "945",
474
+ "946",
475
+ "947",
476
+
477
+ // Yilan County (宜蘭縣) - 260-269
478
+ "260",
479
+ "261",
480
+ "262",
481
+ "263",
482
+ "264",
483
+ "265",
484
+ "266",
485
+ "267",
486
+ "268",
487
+ "269",
488
+
489
+ // Hualien County (花蓮縣) - 970-983
490
+ "970",
491
+ "971",
492
+ "972",
493
+ "973",
494
+ "974",
495
+ "975",
496
+ "976",
497
+ "977",
498
+ "978",
499
+ "979",
500
+ "981",
501
+ "982",
502
+ "983",
503
+
504
+ // Taitung County (台東縣) - 950-966
505
+ "950",
506
+ "951",
507
+ "952",
508
+ "953",
509
+ "954",
510
+ "955",
511
+ "956",
512
+ "957",
513
+ "958",
514
+ "959",
515
+ "961",
516
+ "962",
517
+ "963",
518
+ "964",
519
+ "965",
520
+ "966",
521
+
522
+ // Penghu County (澎湖縣) - 880-885
523
+ "880",
524
+ "881",
525
+ "882",
526
+ "883",
527
+ "884",
528
+ "885",
529
+
530
+ // Kinmen County (金門縣) - 890-896
531
+ "890",
532
+ "891",
533
+ "892",
534
+ "893",
535
+ "894",
536
+ "895",
537
+ "896",
538
+
539
+ // Lienchiang County (連江縣/馬祖) - 209-212
540
+ "209",
541
+ "210",
542
+ "211",
543
+ "212",
544
+ ]
545
+
546
+ /**
547
+ * Detailed postal code range mappings for specific validation
548
+ * Based on Taiwan postal system structure and known patterns
549
+ *
550
+ * Structure:
551
+ * - 5-digit codes: Generally use 01-99 for delivery segments
552
+ * - 6-digit codes: Use 001-999 for more precise delivery segments
553
+ * - Some areas may have restricted ranges based on actual usage
554
+ */
555
+ const POSTAL_CODE_RANGES: Record<string, { range5: [number, number]; range6: [number, number] }> = {
556
+ // Major cities with extensive postal networks
557
+ "100": { range5: [1, 99], range6: [1, 999] }, // Taipei City - Zhongzheng
558
+ "103": { range5: [1, 99], range6: [1, 999] }, // Taipei City - Datong
559
+ "104": { range5: [1, 99], range6: [1, 999] }, // Taipei City - Zhongshan
560
+ "105": { range5: [1, 99], range6: [1, 999] }, // Taipei City - Songshan
561
+ "106": { range5: [1, 99], range6: [1, 999] }, // Taipei City - Da'an
562
+ "108": { range5: [1, 99], range6: [1, 999] }, // Taipei City - Wanhua
563
+ "110": { range5: [1, 99], range6: [1, 999] }, // Taipei City - Xinyi
564
+ "111": { range5: [1, 99], range6: [1, 999] }, // Taipei City - Shilin
565
+ "112": { range5: [1, 99], range6: [1, 999] }, // Taipei City - Beitou
566
+ "114": { range5: [1, 99], range6: [1, 999] }, // Taipei City - Neihu
567
+ "115": { range5: [1, 99], range6: [1, 999] }, // Taipei City - Nangang
568
+ "116": { range5: [1, 99], range6: [1, 999] }, // Taipei City - Wenshan
569
+
570
+ // New Taipei City major areas
571
+ "220": { range5: [1, 99], range6: [1, 999] }, // Banqiao
572
+ "221": { range5: [1, 99], range6: [1, 999] }, // Xizhi
573
+ "222": { range5: [1, 99], range6: [1, 999] }, // Shenkeng
574
+ "223": { range5: [1, 99], range6: [1, 999] }, // Shiding
575
+ "224": { range5: [1, 99], range6: [1, 999] }, // Ruifang
576
+
577
+ // Taoyuan City
578
+ "320": { range5: [1, 99], range6: [1, 999] }, // Zhongli
579
+ "324": { range5: [1, 99], range6: [1, 999] }, // Pingzhen
580
+ "330": { range5: [1, 99], range6: [1, 999] }, // Taoyuan
581
+
582
+ // Taichung City
583
+ "400": { range5: [1, 99], range6: [1, 999] }, // Central District
584
+ "401": { range5: [1, 99], range6: [1, 999] }, // East District
585
+ "402": { range5: [1, 99], range6: [1, 999] }, // South District
586
+ "403": { range5: [1, 99], range6: [1, 999] }, // West District
587
+ "404": { range5: [1, 99], range6: [1, 999] }, // North District
588
+
589
+ // Tainan City
590
+ "700": { range5: [1, 99], range6: [1, 999] }, // Central District
591
+ "701": { range5: [1, 99], range6: [1, 999] }, // East District
592
+ "702": { range5: [1, 99], range6: [1, 999] }, // South District
593
+
594
+ // Kaohsiung City
595
+ "800": { range5: [1, 99], range6: [1, 999] }, // Xinxing
596
+ "801": { range5: [1, 99], range6: [1, 999] }, // Qianjin
597
+ "802": { range5: [1, 99], range6: [1, 999] }, // Lingya
598
+ "803": { range5: [1, 99], range6: [1, 999] }, // Yancheng
599
+
600
+ // Smaller areas with more limited ranges
601
+ "880": { range5: [1, 50], range6: [1, 500] }, // Penghu (smaller population)
602
+ "890": { range5: [1, 30], range6: [1, 300] }, // Kinmen (smaller population)
603
+ "209": { range5: [1, 20], range6: [1, 200] }, // Lienchiang/Matsu (smallest population)
604
+ }
605
+
606
+ /**
607
+ * Get valid suffix ranges for a given prefix
608
+ * Returns default ranges if specific mapping not found
609
+ */
610
+ const getPostalCodeRanges = (prefix: string): { range5: [number, number]; range6: [number, number] } => {
611
+ return (
612
+ POSTAL_CODE_RANGES[prefix] || {
613
+ range5: [1, 99], // Default range for 5-digit
614
+ range6: [1, 999], // Default range for 6-digit
615
+ }
616
+ )
617
+ }
618
+
619
+ /**
620
+ * Validates 3-digit Taiwan postal code
621
+ *
622
+ * @param {string} value - The 3-digit postal code to validate
623
+ * @param {boolean} strictValidation - Whether to validate against known postal code list
624
+ * @param {string[]} allowedPrefixes - Specific prefixes to allow (overrides strictValidation)
625
+ * @param {string[]} blockedPrefixes - Specific prefixes to block
626
+ * @returns {boolean} True if the postal code is valid
627
+ */
628
+ const validate3DigitPostalCode = (value: string, strictValidation: boolean = true, allowedPrefixes?: string[], blockedPrefixes?: string[]): boolean => {
629
+ // Basic format check
630
+ if (!/^\d{3}$/.test(value)) {
631
+ return false
632
+ }
633
+
634
+ // Check blocked prefixes first
635
+ if (blockedPrefixes && blockedPrefixes.includes(value)) {
636
+ return false
637
+ }
638
+
639
+ // Check allowed prefixes (overrides strict validation)
640
+ if (allowedPrefixes) {
641
+ return allowedPrefixes.includes(value)
642
+ }
643
+
644
+ // Strict validation against known postal codes
645
+ if (strictValidation) {
646
+ return VALID_3_DIGIT_PREFIXES.includes(value)
647
+ }
648
+
649
+ // Basic range check (100-999)
650
+ const num = parseInt(value, 10)
651
+ return num >= 100 && num <= 999
652
+ }
653
+
654
+ /**
655
+ * Validates 5-digit Taiwan postal code (legacy format)
656
+ *
657
+ * @param {string} value - The 5-digit postal code to validate
658
+ * @param {boolean} strictValidation - Whether to validate the 3-digit prefix
659
+ * @param {boolean} strictSuffixValidation - Whether to validate the 2-digit suffix
660
+ * @param {string[]} allowedPrefixes - Specific prefixes to allow
661
+ * @param {string[]} blockedPrefixes - Specific prefixes to block
662
+ * @returns {boolean} True if the postal code is valid
663
+ */
664
+ const validate5DigitPostalCode = (value: string, strictValidation: boolean = true, strictSuffixValidation: boolean = false, allowedPrefixes?: string[], blockedPrefixes?: string[]): boolean => {
665
+ // Basic format check
666
+ if (!/^\d{5}$/.test(value)) {
667
+ return false
668
+ }
669
+
670
+ const prefix = value.substring(0, 3)
671
+ const suffix = value.substring(3, 5)
672
+
673
+ // Validate the 3-digit prefix using the same logic
674
+ if (!validate3DigitPostalCode(prefix, strictValidation, allowedPrefixes, blockedPrefixes)) {
675
+ return false
676
+ }
677
+
678
+ // Validate the 2-digit suffix if strict suffix validation is enabled
679
+ if (strictSuffixValidation) {
680
+ const suffixNum = parseInt(suffix, 10)
681
+ const ranges = getPostalCodeRanges(prefix)
682
+ // Use specific range for this prefix, or fall back to general rule
683
+ if (suffixNum < ranges.range5[0] || suffixNum > ranges.range5[1]) {
684
+ return false
685
+ }
686
+ }
687
+
688
+ return true
689
+ }
690
+
691
+ /**
692
+ * Validates 6-digit Taiwan postal code (current format)
693
+ *
694
+ * @param {string} value - The 6-digit postal code to validate
695
+ * @param {boolean} strictValidation - Whether to validate the 3-digit prefix
696
+ * @param {boolean} strictSuffixValidation - Whether to validate the 3-digit suffix
697
+ * @param {string[]} allowedPrefixes - Specific prefixes to allow
698
+ * @param {string[]} blockedPrefixes - Specific prefixes to block
699
+ * @returns {boolean} True if the postal code is valid
700
+ */
701
+ const validate6DigitPostalCode = (value: string, strictValidation: boolean = true, strictSuffixValidation: boolean = false, allowedPrefixes?: string[], blockedPrefixes?: string[]): boolean => {
702
+ // Basic format check
703
+ if (!/^\d{6}$/.test(value)) {
704
+ return false
705
+ }
706
+
707
+ const prefix = value.substring(0, 3)
708
+ const suffix = value.substring(3, 6)
709
+
710
+ // Validate the 3-digit prefix using the same logic
711
+ if (!validate3DigitPostalCode(prefix, strictValidation, allowedPrefixes, blockedPrefixes)) {
712
+ return false
713
+ }
714
+
715
+ // Validate the 3-digit suffix if strict suffix validation is enabled
716
+ if (strictSuffixValidation) {
717
+ const suffixNum = parseInt(suffix, 10)
718
+ const ranges = getPostalCodeRanges(prefix)
719
+ // Use specific range for this prefix, or fall back to general rule
720
+ if (suffixNum < ranges.range6[0] || suffixNum > ranges.range6[1]) {
721
+ return false
722
+ }
723
+ }
724
+
725
+ return true
726
+ }
727
+
728
+ /**
729
+ * Main validation function for Taiwan postal codes
730
+ *
731
+ * @param {string} value - The postal code to validate
732
+ * @param {PostalCodeFormat} format - Which formats to accept
733
+ * @param {boolean} strictValidation - Whether to validate against known postal codes
734
+ * @param {boolean} strictSuffixValidation - Whether to validate suffix ranges
735
+ * @param {boolean} allowDashes - Whether dashes/spaces are allowed and should be removed
736
+ * @param {string[]} allowedPrefixes - Specific prefixes to allow
737
+ * @param {string[]} blockedPrefixes - Specific prefixes to block
738
+ * @returns {boolean} True if the postal code is valid
739
+ */
740
+ const validateTaiwanPostalCode = (
741
+ value: string,
742
+ format: PostalCodeFormat = "3+6",
743
+ strictValidation: boolean = true,
744
+ strictSuffixValidation: boolean = false,
745
+ allowDashes: boolean = true,
746
+ allowedPrefixes?: string[],
747
+ blockedPrefixes?: string[]
748
+ ): boolean => {
749
+ if (!value || typeof value !== "string") {
750
+ return false
751
+ }
752
+
753
+ // Only remove dashes and spaces if they are allowed
754
+ const cleanValue = allowDashes ? value.replace(/[-\s]/g, "") : value
755
+
756
+ // If dashes are not allowed and the value contains them, it's invalid
757
+ if (!allowDashes && /[-\s]/.test(value)) {
758
+ return false
759
+ }
760
+
761
+ switch (format) {
762
+ case "3":
763
+ return cleanValue.length === 3 && validate3DigitPostalCode(cleanValue, strictValidation, allowedPrefixes, blockedPrefixes)
764
+
765
+ case "5":
766
+ return cleanValue.length === 5 && validate5DigitPostalCode(cleanValue, strictValidation, strictSuffixValidation, allowedPrefixes, blockedPrefixes)
767
+
768
+ case "6":
769
+ return cleanValue.length === 6 && validate6DigitPostalCode(cleanValue, strictValidation, strictSuffixValidation, allowedPrefixes, blockedPrefixes)
770
+
771
+ case "3+5":
772
+ return (
773
+ (cleanValue.length === 3 && validate3DigitPostalCode(cleanValue, strictValidation, allowedPrefixes, blockedPrefixes)) ||
774
+ (cleanValue.length === 5 && validate5DigitPostalCode(cleanValue, strictValidation, strictSuffixValidation, allowedPrefixes, blockedPrefixes))
775
+ )
776
+
777
+ case "3+6":
778
+ return (
779
+ (cleanValue.length === 3 && validate3DigitPostalCode(cleanValue, strictValidation, allowedPrefixes, blockedPrefixes)) ||
780
+ (cleanValue.length === 6 && validate6DigitPostalCode(cleanValue, strictValidation, strictSuffixValidation, allowedPrefixes, blockedPrefixes))
781
+ )
782
+
783
+ case "5+6":
784
+ return (
785
+ (cleanValue.length === 5 && validate5DigitPostalCode(cleanValue, strictValidation, strictSuffixValidation, allowedPrefixes, blockedPrefixes)) ||
786
+ (cleanValue.length === 6 && validate6DigitPostalCode(cleanValue, strictValidation, strictSuffixValidation, allowedPrefixes, blockedPrefixes))
787
+ )
788
+
789
+ case "all":
790
+ return (
791
+ (cleanValue.length === 3 && validate3DigitPostalCode(cleanValue, strictValidation, allowedPrefixes, blockedPrefixes)) ||
792
+ (cleanValue.length === 5 && validate5DigitPostalCode(cleanValue, strictValidation, strictSuffixValidation, allowedPrefixes, blockedPrefixes)) ||
793
+ (cleanValue.length === 6 && validate6DigitPostalCode(cleanValue, strictValidation, strictSuffixValidation, allowedPrefixes, blockedPrefixes))
794
+ )
795
+
796
+ default:
797
+ return false
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Creates a Zod schema for Taiwan postal code validation
803
+ *
804
+ * @template IsRequired - Whether the field is required (affects return type)
805
+ * @param {PostalCodeOptions<IsRequired>} [options] - Configuration options for postal code validation
806
+ * @returns {PostalCodeSchema<IsRequired>} Zod schema for postal code validation
807
+ *
808
+ * @description
809
+ * Creates a comprehensive Taiwan postal code validator that supports multiple formats
810
+ * and provides extensive configuration options for different validation scenarios.
811
+ *
812
+ * Features:
813
+ * - 3-digit, 5-digit, and 6-digit postal code format support
814
+ * - Strict validation against official postal code ranges
815
+ * - Legacy 5-digit format with optional warnings
816
+ * - Custom prefix allowlist/blocklist support
817
+ * - Dash and space handling in postal codes
818
+ * - Automatic normalization and formatting
819
+ * - Custom transformation functions
820
+ * - Comprehensive internationalization
821
+ * - Optional field support
822
+ *
823
+ * @example
824
+ * ```typescript
825
+ * // Accept 3-digit or 6-digit formats (recommended)
826
+ * const modernSchema = postalCode()
827
+ * modernSchema.parse("100") // ✓ Valid 3-digit
828
+ * modernSchema.parse("100001") // ✓ Valid 6-digit
829
+ * modernSchema.parse("10001") // ✗ Invalid (5-digit not allowed)
830
+ *
831
+ * // Accept all formats
832
+ * const flexibleSchema = postalCode({ format: "all" })
833
+ * flexibleSchema.parse("100") // ✓ Valid
834
+ * flexibleSchema.parse("10001") // ✓ Valid
835
+ * flexibleSchema.parse("100001") // ✓ Valid
836
+ *
837
+ * // Only 6-digit format (current standard)
838
+ * const modernOnlySchema = postalCode({ format: "6" })
839
+ * modernOnlySchema.parse("100001") // ✓ Valid
840
+ * modernOnlySchema.parse("100") // ✗ Invalid
841
+ *
842
+ * // With dashes allowed
843
+ * const dashSchema = postalCode({ allowDashes: true })
844
+ * dashSchema.parse("100-001") // ✓ Valid (normalized to "100001")
845
+ * dashSchema.parse("100-01") // ✓ Valid if 5-digit format allowed
846
+ *
847
+ * // Specific areas only
848
+ * const taipeiSchema = postalCode({
849
+ * allowedPrefixes: ["100", "103", "104", "105", "106"]
850
+ * })
851
+ * taipeiSchema.parse("100001") // ✓ Valid (Taipei area)
852
+ * taipeiSchema.parse("200001") // ✗ Invalid (not in allowlist)
853
+ *
854
+ * // Block specific areas
855
+ * const blockedSchema = postalCode({
856
+ * blockedPrefixes: ["999"] // Block test codes
857
+ * })
858
+ *
859
+ * // With warning for legacy format
860
+ * const warnSchema = postalCode({
861
+ * format: "all",
862
+ * warn5Digit: true
863
+ * })
864
+ * // Will validate but may show warning for 5-digit codes
865
+ *
866
+ * // Optional with custom transformation
867
+ * const optionalSchema = postalCode({
868
+ * required: false,
869
+ * transform: (value) => value.replace(/\D/g, '') // Remove non-digits
870
+ * })
871
+ *
872
+ * // Strict suffix validation for real postal codes
873
+ * const strictSchema = postalCode({
874
+ * format: "6",
875
+ * strictSuffixValidation: true // Validates suffix range 001-999
876
+ * })
877
+ * strictSchema.parse("100001") // ✓ Valid
878
+ * strictSchema.parse("100000") // ✗ Invalid (suffix 000 not allowed)
879
+ *
880
+ * // Deprecate 5-digit codes entirely
881
+ * const modern2024Schema = postalCode({
882
+ * format: "all",
883
+ * deprecate5Digit: true // Throws error for any 5-digit code
884
+ * })
885
+ * modern2024Schema.parse("100001") // ✓ Valid 6-digit
886
+ * modern2024Schema.parse("10001") // ✗ Error: deprecated format
887
+ * ```
888
+ *
889
+ * @throws {z.ZodError} When validation fails with specific error messages
890
+ * @see {@link PostalCodeOptions} for all available configuration options
891
+ * @see {@link PostalCodeFormat} for supported formats
892
+ * @see {@link validateTaiwanPostalCode} for validation logic details
893
+ */
894
+ export function postalCode<IsRequired extends boolean = true>(options?: PostalCodeOptions<IsRequired>): PostalCodeSchema<IsRequired> {
895
+ const {
896
+ required = true,
897
+ format = "3+6",
898
+ strictValidation = true,
899
+ allowDashes = true,
900
+ warn5Digit = true,
901
+ allowedPrefixes,
902
+ blockedPrefixes,
903
+ transform,
904
+ defaultValue,
905
+ i18n,
906
+ strictSuffixValidation = false,
907
+ deprecate5Digit = false,
908
+ } = options ?? {}
909
+
910
+ // Set appropriate default value based on required flag
911
+ const actualDefaultValue = defaultValue ?? (required ? "" : null)
912
+
913
+ // Helper function to get custom message or fallback to default i18n
914
+ const getMessage = (key: keyof PostalCodeMessages, params?: Record<string, any>) => {
915
+ if (i18n) {
916
+ const currentLocale = getLocale()
917
+ const customMessages = i18n[currentLocale]
918
+ if (customMessages && customMessages[key]) {
919
+ const template = customMessages[key]!
920
+ return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
921
+ }
922
+ }
923
+ return t(`taiwan.postalCode.${key}`, params)
924
+ }
925
+
926
+ // Preprocessing function
927
+ const preprocessFn = (val: unknown) => {
928
+ if (val === "" || val === null || val === undefined) {
929
+ return actualDefaultValue
930
+ }
931
+
932
+ let processed = String(val).trim()
933
+
934
+ // Remove dashes and spaces if allowDashes is true
935
+ if (allowDashes) {
936
+ processed = processed.replace(/[-\s]/g, "")
937
+ }
938
+
939
+ // If after processing we have an empty string and the field is optional, return null
940
+ if (processed === "" && !required) {
941
+ return null
942
+ }
943
+
944
+ if (transform) {
945
+ processed = transform(processed)
946
+ }
947
+
948
+ return processed
949
+ }
950
+
951
+ const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
952
+
953
+ const schema = baseSchema.refine((val) => {
954
+ if (val === null) return true
955
+
956
+ // Required check
957
+ if (required && (val === "" || val === "null" || val === "undefined")) {
958
+ throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
959
+ }
960
+
961
+ if (val === null) return true
962
+ if (!required && val === "") return true
963
+
964
+ // Format-specific validation
965
+ const cleanValue = val.replace(/[-\s]/g, "")
966
+
967
+ // Check if format matches expected pattern
968
+ if (format === "3" && cleanValue.length !== 3) {
969
+ throw new z.ZodError([{ code: "custom", message: getMessage("format3Only"), path: [] }])
970
+ }
971
+ if (format === "5" && cleanValue.length !== 5) {
972
+ throw new z.ZodError([{ code: "custom", message: getMessage("format5Only"), path: [] }])
973
+ }
974
+ if (format === "6" && cleanValue.length !== 6) {
975
+ throw new z.ZodError([{ code: "custom", message: getMessage("format6Only"), path: [] }])
976
+ }
977
+
978
+ // Check for deprecated 5-digit format
979
+ if (deprecate5Digit && cleanValue.length === 5) {
980
+ throw new z.ZodError([{ code: "custom", message: getMessage("deprecated5Digit"), path: [] }])
981
+ }
982
+
983
+ // Pre-validate suffix for better error messages before main validation
984
+ if (strictSuffixValidation) {
985
+ if (cleanValue.length === 5) {
986
+ const prefix = cleanValue.substring(0, 3)
987
+ const suffix = cleanValue.substring(3, 5)
988
+ const suffixNum = parseInt(suffix, 10)
989
+ const ranges = getPostalCodeRanges(prefix)
990
+ if (suffixNum < ranges.range5[0] || suffixNum > ranges.range5[1]) {
991
+ throw new z.ZodError([{ code: "custom", message: getMessage("invalidSuffix"), path: [] }])
992
+ }
993
+ } else if (cleanValue.length === 6) {
994
+ const prefix = cleanValue.substring(0, 3)
995
+ const suffix = cleanValue.substring(3, 6)
996
+ const suffixNum = parseInt(suffix, 10)
997
+ const ranges = getPostalCodeRanges(prefix)
998
+ if (suffixNum < ranges.range6[0] || suffixNum > ranges.range6[1]) {
999
+ throw new z.ZodError([{ code: "custom", message: getMessage("invalidSuffix"), path: [] }])
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ // Main postal code validation (only validates prefix if strictSuffixValidation already passed)
1005
+ if (!validateTaiwanPostalCode(val, format, strictValidation, strictSuffixValidation, allowDashes, allowedPrefixes, blockedPrefixes)) {
1006
+ throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
1007
+ }
1008
+
1009
+ // Warning for 5-digit legacy format (doesn't fail validation)
1010
+ if (warn5Digit && cleanValue.length === 5 && format !== "5" && !deprecate5Digit) {
1011
+ console.warn(getMessage("legacy5DigitWarning"))
1012
+ }
1013
+
1014
+ return true
1015
+ })
1016
+
1017
+ return schema as unknown as PostalCodeSchema<IsRequired>
1018
+ }
1019
+
1020
+ /**
1021
+ * Utility functions exported for external use
1022
+ *
1023
+ * @description
1024
+ * These validation functions can be used independently for postal code validation
1025
+ * without creating a full Zod schema. Useful for custom validation logic.
1026
+ *
1027
+ * @example
1028
+ * ```typescript
1029
+ * import {
1030
+ * validateTaiwanPostalCode,
1031
+ * validate3DigitPostalCode,
1032
+ * validate5DigitPostalCode,
1033
+ * validate6DigitPostalCode,
1034
+ * VALID_3_DIGIT_PREFIXES
1035
+ * } from './postal-code'
1036
+ *
1037
+ * // General validation
1038
+ * const isValid = validateTaiwanPostalCode("100001", "6") // boolean
1039
+ *
1040
+ * // Specific format validation
1041
+ * const is3Digit = validate3DigitPostalCode("100") // boolean
1042
+ * const is5Digit = validate5DigitPostalCode("10001") // boolean
1043
+ * const is6Digit = validate6DigitPostalCode("100001") // boolean
1044
+ *
1045
+ * // Check if prefix is valid
1046
+ * const isValidPrefix = VALID_3_DIGIT_PREFIXES.includes("100") // boolean
1047
+ * ```
1048
+ */
1049
+ export { validateTaiwanPostalCode, validate3DigitPostalCode, validate5DigitPostalCode, validate6DigitPostalCode, VALID_3_DIGIT_PREFIXES }