Sign-up flows, insurance quotes and event registrations in Taiwan often ask for a national ID number (身分證字號). The last character is a check digit, so a client-side check can stop most typos and made-up values before they reach your backend. This guide walks through the published format and algorithm, gives a TypeScript validator that we tested in Node.js, and shows how WebberUI's taiwan-id-input component handles validation and masking.
Every ID in this article is placeholder data that happens to satisfy the checksum. None of them belong to a real person.
Format
Article 3 of the regulations governing the national ID card format (國民身分證及戶口名簿格式內容製發相片影像檔建置管理辦法) defines the number as 10 characters:
- Position 1: a letter for the city or county.
- Position 2: a sex code, 1 for male and 2 for female.
- Positions 3 to 9: digits.
- Position 10: the check digit.
The letter table
Before you can compute anything, the leading letter has to be converted to a two-digit number. Most letters count up from A = 10 in alphabetical order, but I, O, W, X, Y and Z don't follow that order:
| Letter | A | B | C | D | E | F | G | H | I | J | K | L | M |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Value | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 34 | 18 | 19 | 20 | 21 |
| Letter | N | O | P | Q | R | S | T | U | V | W | X | Y | Z |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Value | 22 | 35 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 32 | 30 | 31 | 33 |
A typical bug is to generate this table with charCodeAt arithmetic. That gives I = 18 and O = 24, and every ID starting with I, O, W, X, Y or Z will then be rejected or accepted incorrectly.
The checksum
- Convert the letter to its value and split it into tens and ones. A = 10 becomes 1 and 0.
- Append the 9 remaining digits. You now have 11 digits.
- Multiply them by the weights
1, 9, 8, 7, 6, 5, 4, 3, 2, 1, 1and add the products. - The ID is valid if the sum is divisible by 10.
Using the placeholder A123456789:
| Position | A tens | A ones | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Digit | 1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| Weight | 1 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 1 |
| Product | 1 | 0 | 8 | 14 | 18 | 20 | 20 | 18 | 14 | 8 | 9 |
The products add up to 130, which is divisible by 10, so the check digit is correct.
To generate a check digit from the first nine characters (useful for building test fixtures), sum only the first 10 positions with weights 1,9,8,7,6,5,4,3,2,1. The check digit is (10 - sum % 10) % 10. It's the same rule seen from the other side: the last weight is 1, so the check digit tops the sum up to the next multiple of 10.
Resident certificate numbers since 2021
On January 2, 2021, Taiwan's National Immigration Agency started issuing a new resident certificate number (外來人口統一證號) for foreign residents. According to the agency's FAQ on the new format:
- It uses 1 letter followed by 9 digits, the same shape as a national ID.
- Position 1 is an area code assigned by where the person applied, following the national ID scheme.
- Position 2 is a sex code: 8 for male, 9 for female.
- Position 10 is a check digit.
A widely circulated attachment describing the check-digit logic for the new numbers (新式統一證號第 10 碼計算邏輯) uses the same letter table and the same weights as the national ID, with A800000014 as its worked example. That example passes the validator below.
In practice this means one function covers both: allow 1, 2, 8, 9 in the second position instead of just 1, 2. If your product has foreign users, this matters. A validator that only accepts 1 and 2 blocks anyone holding a new-format number at the sign-up step, which was the exact problem the format change was meant to solve.
The old format, 2 letters followed by 8 digits, is still in circulation. The agency's application notice says old numbers remain usable through December 31, 2030 and stop being used on January 1, 2031. If you need to accept them until then, that's a separate code path; the function below doesn't handle it.
A TypeScript implementation
const LETTER_CODE: Record<string, number> = {
A: 10, B: 11, C: 12, D: 13, E: 14, F: 15, G: 16, H: 17, I: 34,
J: 18, K: 19, L: 20, M: 21, N: 22, O: 35, P: 23, Q: 24, R: 25,
S: 26, T: 27, U: 28, V: 29, W: 32, X: 30, Y: 31, Z: 33,
};
const WEIGHTS = [1, 9, 8, 7, 6, 5, 4, 3, 2, 1, 1];
export function isValidTaiwanId(input: string): boolean {
const id = input.trim().toUpperCase();
// Position 2: 1 or 2 for national IDs, 8 or 9 for 2021+ resident certificates
if (!/^[A-Z][1289]\d{8}$/.test(id)) return false;
const code = LETTER_CODE[id[0]];
const digits = [
Math.floor(code / 10),
code % 10,
...[...id.slice(1)].map(Number),
];
const sum = digits.reduce((acc, d, i) => acc + d * WEIGHTS[i], 0);
return sum % 10 === 0;
}Results from running it in Node.js:
| Input | Result | Note |
|---|---|---|
A123456789 | valid | common placeholder |
A800000014 | valid | example from the resident certificate attachment |
a123456789 | valid | input is uppercased first |
A123456788 | invalid | wrong check digit |
A323456789 | invalid | position 2 isn't 1, 2, 8 or 9 |
AB12345678 | invalid | old resident certificate format, out of scope |
We also generated 200,000 random nine-character prefixes, appended each of the ten possible check digits, and confirmed that exactly one per prefix passes. The results matched the validateTaiwanId function bundled with WebberUI's component in every case.
Handling it as personal data
A national ID number is personal data under Taiwan's Personal Data Protection Act, and that applies on the client too:
- Mask the middle of the number by default and reveal it only when the user asks.
- Keep it out of URLs, client-side error reports and analytics events.
- A passing checksum only means the format is right. It doesn't prove the number exists or belongs to the person typing it; identity verification needs an official channel.
The WebberUI taiwan-id-input component
taiwan-id-input ships with the same check and validates as soon as the tenth character is entered. The middle four digits are masked by default (shown as A12****789) with an eye button to toggle, and a valid ID shows small badges for the region from the first letter and the sex code from the second digit.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add taiwan-id-input.enInstall it with the WebberUI CLI:
npx webberui@latest add https://webberui.com/r/taiwan-id-input.en.jsonYou can also set up registries in components.json and install it as @webberui/taiwan-id-input. WebberUI components are source-available, so the file lands in your components/ui/ directory and you can change it directly.
import { TaiwanIdInput, validateTaiwanId } from "@/components/ui/taiwan-id-input";
// Uncontrolled: use it as is
<TaiwanIdInput onValidChange={(valid) => console.log(valid)} />
// Controlled: manage the value yourself
const [id, setId] = React.useState("");
<TaiwanIdInput value={id} onChange={setId} masked />
// The validator can also be used on its own
validateTaiwanId("A123456789"); // true (demo data, not a real ID)Two things to know when you reuse it:
- The input uppercases what the user types, but the exported
validateTaiwanIddoesn't transform its argument. Passing a lowercase string returnsfalse, so calltoUpperCase()first if you use it on the server or in a form schema. - It accepts 1, 2, 8 and 9 in the second position, which covers national IDs and new-format resident certificates. Old two-letter resident numbers won't pass.
Masking only affects what's displayed. onChange and form submission always receive the full value. Results are announced through an aria-live="polite" region, aria-invalid is set on failure, and the shake and scale animations are turned off when the user prefers reduced motion.
See the taiwan-id-input docs for the full props table. If the same form also collects a company tax ID, the guide on validating Taiwan business tax IDs (UBN) in React covers the 2023 rule change.