Parse, validate, and extract data from South African ID numbers — fully typed, zero dependencies.
- Overview
- ID Number Format
- Installation
- Quick Start
- API Reference
- Types
- Validation Rules
- Examples
- Contributing
- License
South African ID numbers encode a person's date of birth, gender, and citizenship status in a structured 13-digit format. @south-african/id gives you a clean, fully-typed API to:
- Validate an ID number (format, date, citizenship digit, Luhn checksum)
- Parse all encoded fields in one call
- Extract individual fields — DOB, age, gender, citizenship — with dedicated helpers
Zero runtime dependencies. Ships as both ESM and CJS with TypeScript declarations included.
Y Y M M D D S S S S C A Z
│ │ │ │ │ │ │ │ │ │ │ │ └─ Z Luhn checksum digit
│ │ │ │ │ │ │ │ │ │ │ └─── A Legacy digit (formerly race — now unused)
│ │ │ │ │ │ │ │ │ │ └───── C Citizenship: 0 = SA citizen, 1 = permanent resident
│ │ │ │ │ │ └─┴─┴─┴─────── SSSS Gender sequence: 0000–4999 = female, 5000–9999 = male
│ │ │ │ └─┴─────────────── DD Birth day (01–31)
│ │ └─┴───────────────────── MM Birth month (01–12)
└─┴─────────────────────────── YY Last two digits of birth year
Example: 9001049818080
| Segment | Value | Meaning |
|---|---|---|
YY |
90 |
Born in 1990 |
MM |
01 |
January |
DD |
04 |
4th |
SSSS |
9818 |
≥ 5000 → Male |
C |
0 |
South African citizen |
A |
8 |
Legacy (ignore) |
Z |
0 |
Luhn checksum ✓ |
pnpm add @south-african/idnpm install @south-african/idyarn add @south-african/idbun add @south-african/idimport { parse } from "@south-african/id";
const result = parse("9001049818080");
if (result.valid) {
console.log(result.dateOfBirth); // Date: 1990-01-04
console.log(result.age); // e.g. 35
console.log(result.gender); // "male"
console.log(result.citizenship); // "citizen"
console.log(result.isCitizen); // true
} else {
console.error(result.reason); // e.g. "INVALID_CHECKSUM"
}The main entry point. Runs all four validation checks and returns a discriminated union — either a fully populated ParsedID or an InvalidID with a reason code.
function parse(idNumber: string): IDResult;Parameters
| Name | Type | Description |
|---|---|---|
idNumber |
string |
A 13-digit South African ID number. Leading/trailing whitespace is trimmed automatically. |
Returns — ParsedID (when valid: true) or InvalidID (when valid: false).
| Field | Type | Description |
|---|---|---|
valid |
true |
Discriminant |
idNumber |
string |
The original input string |
dateOfBirth |
Date |
Birth date encoded in the ID |
age |
number |
Age in full years as of today |
gender |
"male" | "female" |
Derived from digits 7–10 |
citizenship |
"citizen" | "permanent_resident" |
Derived from digit 11 |
isCitizen |
boolean |
true when citizenship digit is 0
|
segments |
RawSegments |
The raw sliced string segments |
| Field | Type | Description |
|---|---|---|
valid |
false |
Discriminant |
idNumber |
string |
The original input string |
reason |
InvalidReason |
Why validation failed |
import { parse } from "@south-african/id";
// ✅ Valid
const r1 = parse("9001049818080");
// r1.valid === true
// r1.gender === "male"
// r1.age === 35 (as of 2025)
// ❌ Invalid format
const r2 = parse("not-an-id");
// r2.valid === false
// r2.reason === "INVALID_FORMAT"
// ❌ Bad checksum
const r3 = parse("9001049818081");
// r3.valid === false
// r3.reason === "INVALID_CHECKSUM"Lightweight boolean check. Internally calls parse() and returns result.valid.
function isValid(idNumber: string): boolean;import { isValid } from "@south-african/id";
isValid("9001049818080"); // true
isValid("1234567890123"); // false
isValid("invalid"); // falseReturns the birth date encoded in the ID, or null if the ID is invalid.
function getDateOfBirth(idNumber: string): Date | null;import { getDateOfBirth } from "@south-african/id";
const dob = getDateOfBirth("9001049818080");
// dob instanceof Date === true
// dob.getFullYear() === 1990
// dob.getMonth() === 0 (January, 0-indexed)
// dob.getDate() === 4
getDateOfBirth("invalid"); // nullReturns "male" or "female" based on the gender-sequence digits (positions 7–10), or null for invalid IDs.
function getGender(idNumber: string): "male" | "female" | null;import { getGender } from "@south-african/id";
getGender("9001049818080"); // "male" (sequence 9818 ≥ 5000)
getGender("7805050050083"); // "female" (sequence 0050 < 5000)
getGender("invalid"); // nullReturns the person's age in completed years as of today, or null for invalid IDs.
function getAge(idNumber: string): number | null;import { getAge } from "@south-african/id";
getAge("9001049818080"); // e.g. 35
getAge("invalid"); // nullReturns the citizenship status, or null for invalid IDs.
function getCitizenship(
idNumber: string,
): "citizen" | "permanent_resident" | null;import { getCitizenship } from "@south-african/id";
getCitizenship("9001049818080"); // "citizen"
getCitizenship("8001015009087"); // "permanent_resident"
getCitizenship("invalid"); // nullLow-level Luhn (mod 10) checksum validator. Exposed for advanced use-cases; most consumers should use parse() or isValid() instead.
function luhn(digits: string): boolean;import { luhn } from "@south-african/id";
luhn("9001049818080"); // true
luhn("9001049818081"); // falseAll types are exported from the package root.
import type {
IDResult,
ParsedID,
InvalidID,
InvalidReason,
Gender,
CitizenshipStatus,
RawSegments,
} from "@south-african/id";type IDResult = ParsedID | InvalidID;Discriminated union returned by parse(). Narrow with result.valid.
interface ParsedID {
valid: true;
idNumber: string;
dateOfBirth: Date;
age: number;
gender: Gender;
citizenship: CitizenshipStatus;
isCitizen: boolean;
segments: RawSegments;
}interface InvalidID {
valid: false;
idNumber: string;
reason: InvalidReason;
}type InvalidReason =
| "INVALID_FORMAT" // Not exactly 13 numeric digits
| "INVALID_DATE" // Date portion is not a real calendar date
| "INVALID_CITIZENSHIP_DIGIT" // Citizenship digit is not 0, 1, or 2
| "INVALID_CHECKSUM"; // Luhn algorithm failstype Gender = "male" | "female";type CitizenshipStatus = "citizen" | "permanent_resident";The raw sliced string segments of the ID number. Available on ParsedID.segments.
interface RawSegments {
yearPart: string; // "90"
monthPart: string; // "01"
dayPart: string; // "04"
genderSequence: string; // "9818"
citizenshipDigit: string; // "0"
legacyDigit: string; // "8"
checksumDigit: string; // "0"
}parse() applies the following checks in order. The first failure short-circuits and returns the corresponding reason.
| # | Check | Reason on failure |
|---|---|---|
| 1 | Must be exactly 13 numeric digits (after trimming) | INVALID_FORMAT |
| 2 |
YYMMDD must be a real, non-future calendar date |
INVALID_DATE |
| 3 | Citizenship digit (position 11) must be 0, 1, or 2
|
INVALID_CITIZENSHIP_DIGIT |
| 4 | Full 13-digit string must satisfy the Luhn algorithm | INVALID_CHECKSUM |
import { isValid } from "@south-african/id";
function validateForm(idNumber: string) {
if (!isValid(idNumber)) {
throw new Error("Please enter a valid South African ID number.");
}
}import { parse } from "@south-african/id";
function processID(idNumber: string) {
const result = parse(idNumber);
if (!result.valid) {
switch (result.reason) {
case "INVALID_FORMAT":
return "ID must be exactly 13 digits.";
case "INVALID_DATE":
return "The date of birth in this ID is not valid.";
case "INVALID_CITIZENSHIP_DIGIT":
return "The citizenship indicator is not recognised.";
case "INVALID_CHECKSUM":
return "This ID number has been entered incorrectly.";
}
}
return `Welcome, ${result.gender === "male" ? "Mr" : "Ms"}. Born ${result.dateOfBirth.toDateString()}.`;
}import { parse } from "@south-african/id";
const id = parse("9001049818080");
if (id.valid) {
const profile = {
dob: id.dateOfBirth.toISOString().split("T")[0], // "1990-01-04"
age: id.age,
gender: id.gender,
status: id.isCitizen ? "South African Citizen" : "Permanent Resident",
};
console.table(profile);
// ┌────────┬────────────────────────────┐
// │ dob │ 1990-01-04 │
// │ age │ 35 │
// │ gender │ male │
// │ status │ South African Citizen │
// └────────┴────────────────────────────┘
}import { getGender, getAge } from "@south-african/id";
const ids = ["9001049818080", "7805050050083", "8001015009087"];
// Find all IDs belonging to people over 40
const over40 = ids.filter((id) => {
const age = getAge(id);
return age !== null && age > 40;
});import { parse } from "@south-african/id";
const result = parse("9001049818080");
if (result.valid) {
const { segments } = result;
console.log(segments.genderSequence); // "9818"
console.log(segments.citizenshipDigit); // "0"
}const { parse, isValid } = require("@south-african/id");
const result = parse("9001049818080");
console.log(result.valid); // true- Fork the repo and clone locally.
- Install dependencies:
pnpm install - Run tests in watch mode:
pnpm test:watch - Build:
pnpm build - Open a pull request — all submissions welcome!
@south-african/id/
├── src/
│ ├── index.ts # Public barrel — re-exports everything
│ ├── types.ts # All TypeScript types and interfaces
│ ├── luhn.ts # Luhn checksum algorithm
│ ├── parser.ts # Core parse() logic and convenience helpers
│ └── __tests__/
│ ├── luhn.test.ts
│ └── parser.test.ts
├── tsup.config.ts # Build configuration (ESM + CJS + .d.ts)
├── vitest.config.ts # Test configuration
├── tsconfig.json
└── package.json
| Command | Description |
|---|---|
pnpm build |
Compile to dist/ (ESM, CJS, types) |
pnpm dev |
Rebuild on file change |
pnpm test |
Run test suite once |
pnpm test:watch |
Run tests in watch mode |
pnpm test:coverage |
Run tests with coverage report |
pnpm typecheck |
Type-check without emitting |
pnpm lint |
Lint source files |
MIT © Mzantsi