south-african-id-validator
v2.0.0
Published
Validate South African ID numbers and extract date of birth, gender, and citizenship. TypeScript-native, zero dependencies, runs anywhere.
Maintainers
Readme
🇿🇦 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
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-validatorUsage
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:
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–4999is female,5000–9999is male.C— citizenship.0for citizen,1for 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
