south-african-id-validator
Version:
Validate South African ID numbers and extract date of birth, gender, and citizenship. TypeScript-native, zero dependencies, runs anywhere.
106 lines (80 loc) β’ 4.51 kB
Markdown
# πΏπ¦ south-african-id-validator
Validate South African ID numbers and extract date of birth, gender, and citizenship.
- β
Verifies the Luhn checksum (the digit at the end of every SA ID)
- β
Rejects impossible calendar dates (Feb 30, Feb 29 of a non-leap year)
- β
TypeScript-native β strict types, discriminated-union result
- β
Zero runtime dependencies
- β
Runs on Node, Bun, Deno, and modern browsers
- β
Ships ESM + CJS + `.d.ts`
## Install
```bash
pnpm add south-african-id-validator
# or
npm i south-african-id-validator
# or
yarn add south-african-id-validator
# or
bun add south-african-id-validator
```
## Usage
```ts
import { validate } from "south-african-id-validator";
const result = validate("7311190013080");
if (result.valid) {
// TypeScript narrows away the error branch β these are guaranteed to exist.
console.log(result.dateOfBirth); // Date β Mon Nov 19 1973 β¦
console.log(result.gender); // 'female'
console.log(result.citizenship); // 'citizen'
} else {
// And here the data branch is gone β only `error` is accessible.
console.error(result.error); // e.g. 'INVALID_CHECKSUM'
}
```
## API
### `validate(idNumber: string, options?: ValidateOptions): ValidationResult`
The single primary export. Returns a discriminated union:
```ts
type ValidationResult =
| { valid: true; dateOfBirth: Date; gender: Gender; citizenship: Citizenship }
| { valid: false; error: ValidationErrorCode };
type Gender = "male" | "female";
type Citizenship = "citizen" | "permanent_resident";
type ValidationErrorCode =
| "INVALID_LENGTH"
| "INVALID_FORMAT"
| "INVALID_CHECKSUM"
| "INVALID_DATE";
type ValidateOptions = {
/** "Today" for the 16-year century-inference rule. Defaults to `new Date()`. */
referenceDate?: Date;
};
```
### Error codes
The pipeline short-circuits on the first failure and reports the most fundamental problem first:
| Code | Meaning |
| ------------------ | ------------------------------------------------------------------------------------------------- |
| `INVALID_LENGTH` | The input is not exactly 13 characters (or is not a string). |
| `INVALID_FORMAT` | The input contains non-digit characters, or the citizenship digit is neither `0` nor `1`. |
| `INVALID_CHECKSUM` | The 13 digits don't satisfy the Luhn algorithm. |
| `INVALID_DATE` | The first six digits don't form a real calendar date β e.g. Feb 30, or Feb 29 of a non-leap year. |
## How South African ID numbers work
An SA ID is 13 digits encoded as `YYMMDDSSSSCAZ`:
- **`YYMMDD`** β date of birth. The century is inferred from a 16-year-old eligibility rule: a year that would make the holder younger than 16 today is treated as belonging to the previous century.
- **`SSSS`** β gender sequence. `0000β4999` is female, `5000β9999` is male.
- **`C`** β citizenship. `0` for citizen, `1` for permanent resident.
- **`A`** β historically a race indicator, now unused.
- **`Z`** β Luhn check digit over the preceding 12.
## Migrating from v1
v1 exported four functions (`validateIdNumber`, `parseGender`, `parseCitizenship`, `parseDOB`) and had a couple of correctness bugs β most importantly, no checksum validation. v2 collapses everything into a single `validate()` with a typed result. The standalone parse functions are gone; v2's `validate()` returns everything they returned, with narrower types.
| v1 | v2 |
| ----------------------- | ---------------------------------------------------------- |
| `validateIdNumber(id)` | `validate(id)` |
| `.DOB` | `.dateOfBirth` (only on `result.valid === true`) |
| `.gender` | `.gender` (only on `result.valid === true`) |
| `.isCitizen` (boolean) | `.citizenship` (`'citizen'` | `'permanent_resident'`) |
| `valid: false` (silent) | `valid: false, error: <code>` |
| `parseDOB(id)` | not exported β use `validate(id).dateOfBirth` |
| `parseGender(id)` | not exported β use `validate(id).gender` |
| `parseCitizenship(id)` | not exported β use `validate(id).citizenship` |
## License
MIT