taiwan-validator 1.1.0 → 1.2.0

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.
@@ -1,45 +1,14 @@
1
- import type { ValidationResult, NationalIdType } from "../types";
1
+ import type { ValidationResult, NationalIdType, NationalIdInfo } from "../types";
2
+ import { LETTER_MAPPING, REGION_MAPPING } from "./shared";
2
3
 
3
4
  /**
4
- * 字母對應數字表(用於身分證字號驗證)
5
- */
6
- const LETTER_MAPPING: Record<string, number> = {
7
- A: 10,
8
- B: 11,
9
- C: 12,
10
- D: 13,
11
- E: 14,
12
- F: 15,
13
- G: 16,
14
- H: 17,
15
- I: 34,
16
- J: 18,
17
- K: 19,
18
- L: 20,
19
- M: 21,
20
- N: 22,
21
- O: 35,
22
- P: 23,
23
- Q: 24,
24
- R: 25,
25
- S: 26,
26
- T: 27,
27
- U: 28,
28
- V: 29,
29
- W: 32,
30
- X: 30,
31
- Y: 31,
32
- Z: 33,
33
- };
34
-
35
- /**
36
- * 驗證舊式身分證字號格式(1個字母 + 9個數字)
5
+ * 驗證台灣身分證字號格式與檢查碼(1個字母 + 9個數字)
37
6
  * 格式:A123456789
38
7
  * - 第一個字元:地區代碼(字母)
39
8
  * - 第二個字元:性別(1 = 男性,2 = 女性)
40
9
  * - 最後一個字元:檢查碼
41
10
  */
42
- function validateOldFormat(id: string): boolean {
11
+ function validateFormatAndChecksum(id: string): boolean {
43
12
  const pattern = /^[A-Z][12]\d{8}$/;
44
13
  if (!pattern.test(id)) {
45
14
  return false;
@@ -50,7 +19,6 @@ function validateOldFormat(id: string): boolean {
50
19
 
51
20
  const letterValue = LETTER_MAPPING[letter] as number;
52
21
 
53
- // 計算檢查碼
54
22
  const d1 = Math.floor(letterValue / 10);
55
23
  const d2 = letterValue % 10;
56
24
 
@@ -66,70 +34,19 @@ function validateOldFormat(id: string): boolean {
66
34
  }
67
35
 
68
36
  /**
69
- * 驗證新式身分證字號格式(2個字母 + 8個數字)
70
- * 格式:AA12345678
71
- * - 第一個字元:地區代碼(字母)
72
- * - 第二個字元:性別(8 = 男性,9 = 女性)以字母表示
73
- * - 最後一個字元:檢查碼
74
- */
75
- function validateNewFormat(id: string): boolean {
76
- const pattern = /^[A-Z]{2}\d{8}$/;
77
- if (!pattern.test(id)) {
78
- return false;
79
- }
80
-
81
- const firstLetter = id[0] as string;
82
- const secondLetter = id[1] as string;
83
- const numbers = id.slice(2);
84
-
85
- const firstLetterValue = LETTER_MAPPING[firstLetter] as number;
86
- const secondLetterValue = LETTER_MAPPING[secondLetter] as number;
87
-
88
- // 計算新式格式的檢查碼
89
- const d1 = Math.floor(firstLetterValue / 10);
90
- const d2 = firstLetterValue % 10;
91
- const d3 = Math.floor(secondLetterValue / 10);
92
- const d4 = secondLetterValue % 10;
93
-
94
- const weights = [1, 9, 8, 7, 6, 5, 4, 3, 2, 1, 1, 1];
95
- const digits = [d1, d2, d3, d4, ...numbers.split("").map(Number)];
96
-
97
- const sum = digits.reduce(
98
- (acc, digit, index) => acc + digit * weights[index]!,
99
- 0,
100
- );
101
-
102
- return sum % 10 === 0;
103
- }
104
-
105
- /**
106
- * 偵測身分證字號格式類型
107
- */
108
- function detectFormat(id: string): NationalIdType | null {
109
- if (/^[A-Z][12]\d{8}$/.test(id)) {
110
- return "old";
111
- }
112
- if (/^[A-Z]{2}\d{8}$/.test(id)) {
113
- return "new";
114
- }
115
- return null;
116
- }
117
-
118
- /**
119
- * 驗證台灣身分證字號(支援新舊格式)
37
+ * 驗證台灣身分證字號(身分證字號皆為「1個字母 + 9個數字」格式)
120
38
  * @param id - 要驗證的身分證字號
121
- * @param format - 可選:指定格式類型('old' 或 'new')
39
+ * @param format - 可選(為維持相容性保留):指定格式類型
122
40
  * @returns 驗證結果
123
41
  *
124
42
  * @example
125
43
  * ```typescript
126
- * validateNationalId('A123456789'); // 舊式格式
127
- * validateNationalId('AA12345678'); // 新式格式
44
+ * validateNationalId('A123456789');
128
45
  * ```
129
46
  */
130
47
  export function validateNationalId(
131
48
  id: string,
132
- format?: NationalIdType,
49
+ _format?: NationalIdType,
133
50
  ): ValidationResult {
134
51
  if (!id || typeof id !== "string") {
135
52
  return {
@@ -140,42 +57,52 @@ export function validateNationalId(
140
57
 
141
58
  const normalizedId = id.trim().toUpperCase();
142
59
 
143
- // 如果指定了格式,只驗證該格式
144
- if (format === "old") {
145
- const isValid = validateOldFormat(normalizedId);
146
- return {
147
- isValid,
148
- message: isValid ? undefined : "無效的舊式身分證字號",
149
- };
150
- }
60
+ const isValid = validateFormatAndChecksum(normalizedId);
61
+
62
+ return {
63
+ isValid,
64
+ message: isValid ? undefined : "無效的身分證字號",
65
+ };
66
+ }
151
67
 
152
- if (format === "new") {
153
- const isValid = validateNewFormat(normalizedId);
68
+ /**
69
+ * 解析台灣身分證字號資訊(性別、發證地區)
70
+ * @param id - 要解析的身分證字號
71
+ * @returns 解析結果,包含驗證狀態及相關欄位
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * parseNationalId('A123456789');
76
+ * // 輸出: { isValid: true, gender: 'male', region: '臺北市' }
77
+ * ```
78
+ */
79
+ export function parseNationalId(id: string): NationalIdInfo {
80
+ if (!id || typeof id !== "string") {
154
81
  return {
155
- isValid,
156
- message: isValid ? undefined : "無效的新式身分證字號",
82
+ isValid: false,
83
+ message: "身分證字號必須為非空字串",
157
84
  };
158
85
  }
159
86
 
160
- // 自動偵測格式
161
- const detectedFormat = detectFormat(normalizedId);
87
+ const normalizedId = id.trim().toUpperCase();
162
88
 
163
- if (!detectedFormat) {
89
+ const isValid = validateFormatAndChecksum(normalizedId);
90
+ if (!isValid) {
164
91
  return {
165
92
  isValid: false,
166
- message: "無效的身分證字號格式",
93
+ message: "無效的身分證字號",
167
94
  };
168
95
  }
169
96
 
170
- const isValid =
171
- detectedFormat === "old"
172
- ? validateOldFormat(normalizedId)
173
- : validateNewFormat(normalizedId);
97
+ const firstLetter = normalizedId[0] as string;
98
+ const genderDigit = normalizedId[1] as string;
99
+
100
+ const gender = genderDigit === "1" ? "male" : "female";
101
+ const region = REGION_MAPPING[firstLetter] as string;
174
102
 
175
103
  return {
176
- isValid,
177
- message: isValid
178
- ? undefined
179
- : `無效的${detectedFormat === "old" ? "舊式" : "新式"}身分證字號`,
104
+ isValid: true,
105
+ gender,
106
+ region,
180
107
  };
181
108
  }
@@ -0,0 +1,21 @@
1
+ import { validateNHICard } from "./nhi-card";
2
+
3
+ describe("validateNHICard", () => {
4
+ test("should validate correct NHI card numbers", () => {
5
+ expect(validateNHICard("0000 1234 5678").isValid).toBe(true);
6
+ expect(validateNHICard("000012345678").isValid).toBe(true);
7
+ expect(validateNHICard("0000-1234-5678").isValid).toBe(true);
8
+ });
9
+
10
+ test("should reject invalid NHI card numbers", () => {
11
+ expect(validateNHICard("00001234567").isValid).toBe(false); // Too short (11 digits)
12
+ expect(validateNHICard("0000123456789").isValid).toBe(false); // Too long (13 digits)
13
+ expect(validateNHICard("00001234567a").isValid).toBe(false); // Non-numeric
14
+ });
15
+
16
+ test("should reject empty or non-string inputs", () => {
17
+ expect(validateNHICard("").isValid).toBe(false);
18
+ // @ts-expect-error Testing invalid input type
19
+ expect(validateNHICard(null).isValid).toBe(false);
20
+ });
21
+ });
@@ -0,0 +1,39 @@
1
+ import type { ValidationResult } from "../types";
2
+
3
+ /**
4
+ * 驗證台灣國民健康保險卡(健保卡)卡號格式
5
+ * 格式為 12 碼數字(可包含減號或空格,如 0000 1234 5678)
6
+ *
7
+ * @param cardNumber - 要驗證的健保卡卡號
8
+ * @returns 驗證結果
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * validateNHICard('0000 1234 5678'); // { isValid: true }
13
+ * validateNHICard('000012345678'); // { isValid: true }
14
+ * ```
15
+ */
16
+ export function validateNHICard(cardNumber: string): ValidationResult {
17
+ if (!cardNumber || typeof cardNumber !== "string") {
18
+ return {
19
+ isValid: false,
20
+ message: "健保卡號必須為非空字串",
21
+ };
22
+ }
23
+
24
+ // 去除空格與減號
25
+ const normalized = cardNumber.replace(/[-\s]/g, "");
26
+
27
+ const pattern = /^\d{12}$/;
28
+
29
+ if (!pattern.test(normalized)) {
30
+ return {
31
+ isValid: false,
32
+ message: "健保卡號格式必須為12碼數字",
33
+ };
34
+ }
35
+
36
+ return {
37
+ isValid: true,
38
+ };
39
+ }
@@ -0,0 +1,20 @@
1
+ import { validatePassport } from "./passport";
2
+
3
+ describe("validatePassport", () => {
4
+ test("should validate correct passport numbers", () => {
5
+ expect(validatePassport("312345678").isValid).toBe(true);
6
+ expect(validatePassport("123456789").isValid).toBe(true);
7
+ });
8
+
9
+ test("should reject invalid passport numbers", () => {
10
+ expect(validatePassport("12345678").isValid).toBe(false); // Too short (8 digits)
11
+ expect(validatePassport("1234567890").isValid).toBe(false); // Too long (10 digits)
12
+ expect(validatePassport("12345678a").isValid).toBe(false); // Non-numeric
13
+ });
14
+
15
+ test("should reject empty or non-string inputs", () => {
16
+ expect(validatePassport("").isValid).toBe(false);
17
+ // @ts-expect-error Testing invalid input type
18
+ expect(validatePassport(null).isValid).toBe(false);
19
+ });
20
+ });
@@ -0,0 +1,38 @@
1
+ import type { ValidationResult } from "../types";
2
+
3
+ /**
4
+ * 驗證中華民國(台灣)護照號碼格式
5
+ * 晶片護照與一般護照為 9 碼數字格式(晶片護照通常以 3 開頭)
6
+ *
7
+ * @param passport - 要驗證的護照號碼
8
+ * @returns 驗證結果
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * validatePassport('312345678'); // { isValid: true }
13
+ * ```
14
+ */
15
+ export function validatePassport(passport: string): ValidationResult {
16
+ if (!passport || typeof passport !== "string") {
17
+ return {
18
+ isValid: false,
19
+ message: "護照號碼必須為非空字串",
20
+ };
21
+ }
22
+
23
+ const normalized = passport.trim();
24
+
25
+ // 驗證 9 碼數字
26
+ const pattern = /^\d{9}$/;
27
+
28
+ if (!pattern.test(normalized)) {
29
+ return {
30
+ isValid: false,
31
+ message: "護照號碼格式必須為9位數字",
32
+ };
33
+ }
34
+
35
+ return {
36
+ isValid: true,
37
+ };
38
+ }
@@ -0,0 +1,31 @@
1
+ import { validatePostalCode } from "./postal-code";
2
+
3
+ describe("validatePostalCode", () => {
4
+ test("should validate correct postal codes", () => {
5
+ // 3 digits
6
+ expect(validatePostalCode("100").isValid).toBe(true);
7
+ expect(validatePostalCode(100).isValid).toBe(true);
8
+
9
+ // 5 digits
10
+ expect(validatePostalCode("100-01").isValid).toBe(true);
11
+ expect(validatePostalCode("10001").isValid).toBe(true);
12
+
13
+ // 6 digits
14
+ expect(validatePostalCode("100001").isValid).toBe(true);
15
+ expect(validatePostalCode("100-001").isValid).toBe(true);
16
+ });
17
+
18
+ test("should reject invalid postal codes", () => {
19
+ expect(validatePostalCode("001").isValid).toBe(false); // First digit cannot be 0
20
+ expect(validatePostalCode("10").isValid).toBe(false); // Too short
21
+ expect(validatePostalCode("1000").isValid).toBe(false); // 4 digits is not valid
22
+ expect(validatePostalCode("1000001").isValid).toBe(false); // Too long (7 digits)
23
+ });
24
+
25
+ test("should reject empty or null inputs", () => {
26
+ // @ts-expect-error Testing invalid input type
27
+ expect(validatePostalCode(null).isValid).toBe(false);
28
+ // @ts-expect-error Testing invalid input type
29
+ expect(validatePostalCode(undefined).isValid).toBe(false);
30
+ });
31
+ });
@@ -0,0 +1,41 @@
1
+ import type { ValidationResult } from "../types";
2
+
3
+ /**
4
+ * 驗證台灣郵遞區號格式
5
+ * 支援 3 碼、5 碼 (3+2) 及新式 6 碼 (3+3) 格式(可包含減號,如 100-001 或 100001)
6
+ * 第一碼必須為 1-9
7
+ *
8
+ * @param code - 要驗證的郵遞區號
9
+ * @returns 驗證結果
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * validatePostalCode('100'); // { isValid: true }
14
+ * validatePostalCode('100-01'); // { isValid: true }
15
+ * validatePostalCode('100001'); // { isValid: true }
16
+ * ```
17
+ */
18
+ export function validatePostalCode(code: string | number): ValidationResult {
19
+ if (code === null || code === undefined) {
20
+ return {
21
+ isValid: false,
22
+ message: "郵遞區號必須為非空字串或數字",
23
+ };
24
+ }
25
+
26
+ const strCode = String(code).trim().replace(/[-\s]/g, "");
27
+
28
+ // 驗證格式:3 碼、5 碼或 6 碼,且首碼為 1-9
29
+ const pattern = /^[1-9]\d{2}$|^[1-9]\d{4}$|^[1-9]\d{5}$/;
30
+
31
+ if (!pattern.test(strCode)) {
32
+ return {
33
+ isValid: false,
34
+ message: "郵遞區號格式必須為首碼非0的3碼、5碼或6碼數字",
35
+ };
36
+ }
37
+
38
+ return {
39
+ isValid: true,
40
+ };
41
+ }
@@ -1,101 +1,62 @@
1
- import { validateResidentCertificate } from "./resident-certificate";
1
+ import { validateResidentCertificate, parseResidentCertificate } from "./resident-certificate";
2
2
 
3
3
  describe("validateResidentCertificate", () => {
4
- describe("Old Format", () => {
5
- test("should validate correct old format Resident Certificates", () => {
6
- // These are example format IDs (not real person IDs)
7
- expect(validateResidentCertificate("A823456783", "old").isValid).toBe(
8
- true,
9
- );
10
- expect(validateResidentCertificate("B923456786", "old").isValid).toBe(
11
- true,
12
- );
13
- expect(validateResidentCertificate("C823456785", "old").isValid).toBe(
14
- true,
15
- );
16
- expect(validateResidentCertificate("D923456788", "old").isValid).toBe(
17
- true,
18
- );
4
+ describe("New Format (1 letter + 9 digits, e.g. A823456783)", () => {
5
+ test("should validate correct new format Resident Certificates", () => {
6
+ expect(validateResidentCertificate("A823456783", "new").isValid).toBe(true);
7
+ expect(validateResidentCertificate("B923456786", "new").isValid).toBe(true);
19
8
  });
20
9
 
21
- test("should reject invalid old format Resident Certificates", () => {
22
- expect(validateResidentCertificate("A823456780", "old").isValid).toBe(
23
- false,
24
- ); // Wrong checksum
25
- expect(validateResidentCertificate("E823456783", "old").isValid).toBe(
26
- false,
27
- ); // Invalid first letter
28
- expect(validateResidentCertificate("A723456783", "old").isValid).toBe(
29
- false,
30
- ); // Invalid second digit (must be 8 or 9)
10
+ test("should reject invalid new format Resident Certificates", () => {
11
+ expect(validateResidentCertificate("A823456780", "new").isValid).toBe(false); // Wrong checksum
12
+ expect(validateResidentCertificate("E723456783", "new").isValid).toBe(false); // Invalid second digit (must be 8 or 9)
31
13
  });
32
14
 
33
15
  test("should handle case insensitive input", () => {
34
- expect(validateResidentCertificate("a823456783", "old").isValid).toBe(
35
- true,
36
- );
37
- expect(validateResidentCertificate("b923456786", "old").isValid).toBe(
38
- true,
39
- );
16
+ expect(validateResidentCertificate("a823456783", "new").isValid).toBe(true);
17
+ expect(validateResidentCertificate("b923456786", "new").isValid).toBe(true);
40
18
  });
41
19
 
42
20
  test("should handle whitespace", () => {
43
- expect(validateResidentCertificate(" A823456783 ", "old").isValid).toBe(
44
- true,
45
- );
21
+ expect(validateResidentCertificate(" A823456783 ", "new").isValid).toBe(true);
46
22
  });
47
23
  });
48
24
 
49
- describe("New Format", () => {
50
- test("should validate correct new format Resident Certificates", () => {
51
- // These are example format IDs (not real person IDs)
52
- expect(validateResidentCertificate("AA23456786", "new").isValid).toBe(
53
- true,
54
- );
55
- expect(validateResidentCertificate("AB23456789", "new").isValid).toBe(
56
- true,
57
- );
25
+ describe("Old Format (2 letters + 8 digits, e.g. AB12345677)", () => {
26
+ test("should validate correct old format Resident Certificates", () => {
27
+ expect(validateResidentCertificate("AB12345677", "old").isValid).toBe(true); // Female non-citizen
28
+ expect(validateResidentCertificate("AC12345679", "old").isValid).toBe(true); // Male foreigner
29
+ expect(validateResidentCertificate("AD12345671", "old").isValid).toBe(true); // Female foreigner
30
+ expect(validateResidentCertificate("AA12345675", "old").isValid).toBe(true); // Male non-citizen
58
31
  });
59
32
 
60
- test("should reject invalid new format Resident Certificates", () => {
61
- expect(validateResidentCertificate("AA23456780", "new").isValid).toBe(
62
- false,
63
- ); // Wrong checksum
64
- expect(validateResidentCertificate("AA2345678", "new").isValid).toBe(
65
- false,
66
- ); // Too short
33
+ test("should reject invalid old format Resident Certificates", () => {
34
+ expect(validateResidentCertificate("AB12345678", "old").isValid).toBe(false); // Wrong checksum
35
+ expect(validateResidentCertificate("AE12345678", "old").isValid).toBe(false); // Invalid second letter (must be A-D)
67
36
  });
68
37
 
69
38
  test("should handle case insensitive input", () => {
70
- expect(validateResidentCertificate("aa23456786", "new").isValid).toBe(
71
- true,
72
- );
73
- });
74
-
75
- test("should handle whitespace", () => {
76
- expect(validateResidentCertificate(" AB23456789 ", "new").isValid).toBe(
77
- true,
78
- );
39
+ expect(validateResidentCertificate("ab12345677", "old").isValid).toBe(true);
79
40
  });
80
41
  });
81
42
 
82
43
  describe("Auto-detect Format", () => {
83
44
  test("should auto-detect and validate old format", () => {
84
- expect(validateResidentCertificate("A823456783").isValid).toBe(true);
45
+ expect(validateResidentCertificate("AB12345677").isValid).toBe(true);
85
46
  });
86
47
 
87
48
  test("should auto-detect and reject invalid old format", () => {
88
- const result = validateResidentCertificate("A823456780");
49
+ const result = validateResidentCertificate("AB12345678");
89
50
  expect(result.isValid).toBe(false);
90
51
  expect(result.message).toBe("無效的舊式居留證號");
91
52
  });
92
53
 
93
54
  test("should auto-detect and validate new format", () => {
94
- expect(validateResidentCertificate("AA23456786").isValid).toBe(true);
55
+ expect(validateResidentCertificate("A823456783").isValid).toBe(true);
95
56
  });
96
57
 
97
58
  test("should auto-detect and reject invalid new format", () => {
98
- const result = validateResidentCertificate("AA23456780");
59
+ const result = validateResidentCertificate("A823456780");
99
60
  expect(result.isValid).toBe(false);
100
61
  expect(result.message).toBe("無效的新式居留證號");
101
62
  });
@@ -106,6 +67,40 @@ describe("validateResidentCertificate", () => {
106
67
  });
107
68
  });
108
69
 
70
+ describe("Parser (parseResidentCertificate)", () => {
71
+ test("should parse new format Resident Certificates", () => {
72
+ const parsed = parseResidentCertificate("A823456783");
73
+ expect(parsed.isValid).toBe(true);
74
+ expect(parsed.format).toBe("new");
75
+ expect(parsed.gender).toBe("male");
76
+ expect(parsed.region).toBe("臺北市");
77
+ expect(parsed.identityType).toBe("foreigner");
78
+ });
79
+
80
+ test("should parse old format Resident Certificates", () => {
81
+ const parsed1 = parseResidentCertificate("AB12345677");
82
+ expect(parsed1.isValid).toBe(true);
83
+ expect(parsed1.format).toBe("old");
84
+ expect(parsed1.gender).toBe("female");
85
+ expect(parsed1.region).toBe("臺北市");
86
+ expect(parsed1.identityType).toBe("non-citizen");
87
+
88
+ const parsed2 = parseResidentCertificate("AC12345679");
89
+ expect(parsed2.isValid).toBe(true);
90
+ expect(parsed2.format).toBe("old");
91
+ expect(parsed2.gender).toBe("male");
92
+ expect(parsed2.region).toBe("臺北市");
93
+ expect(parsed2.identityType).toBe("foreigner");
94
+ });
95
+
96
+ test("should return isValid: false for invalid resident certificates", () => {
97
+ const parsed = parseResidentCertificate("A823456780");
98
+ expect(parsed.isValid).toBe(false);
99
+ expect(parsed.format).toBeUndefined();
100
+ expect(parsed.message).toBe("無效的新式居留證號");
101
+ });
102
+ });
103
+
109
104
  describe("Edge Cases", () => {
110
105
  test("should reject empty or invalid input", () => {
111
106
  expect(validateResidentCertificate("").isValid).toBe(false);
@@ -117,7 +112,7 @@ describe("validateResidentCertificate", () => {
117
112
  });
118
113
 
119
114
  test("should provide meaningful error messages", () => {
120
- const result1 = validateResidentCertificate("A823456780", "old");
115
+ const result1 = validateResidentCertificate("A823456780", "new");
121
116
  expect(result1.isValid).toBe(false);
122
117
  expect(result1.message).toBeDefined();
123
118