@south-african/id
v1.0.3
Published
Parse, validate, and extract data from South African ID numbers
Downloads
344
Maintainers
Readme
@south-african/id
Parse, validate, and extract data from South African ID numbers — fully typed, zero dependencies.
Table of Contents
- Overview
- ID Number Format
- Installation
- Quick Start
- API Reference
- Types
- Validation Rules
- Examples
- Contributing
- License
Overview
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.
ID Number Format
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 yearExample: 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 ✓ |
Installation
pnpm (recommended)
pnpm add @south-african/idnpm
npm install @south-african/idyarn
yarn add @south-african/idbun
bun add @south-african/idQuick Start
import { 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"
}API Reference
parse(idNumber)
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).
ParsedID fields
| 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 |
InvalidID fields
| 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"isValid(idNumber)
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"); // falsegetDateOfBirth(idNumber)
Returns 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"); // nullgetGender(idNumber)
Returns "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"); // nullgetAge(idNumber)
Returns 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"); // nullgetCitizenship(idNumber)
Returns 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"); // nullluhn(digits)
Low-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"); // falseTypes
All types are exported from the package root.
import type {
IDResult,
ParsedID,
InvalidID,
InvalidReason,
Gender,
CitizenshipStatus,
RawSegments,
} from "@south-african/id";IDResult
type IDResult = ParsedID | InvalidID;Discriminated union returned by parse(). Narrow with result.valid.
ParsedID
interface ParsedID {
valid: true;
idNumber: string;
dateOfBirth: Date;
age: number;
gender: Gender;
citizenship: CitizenshipStatus;
isCitizen: boolean;
segments: RawSegments;
}InvalidID
interface InvalidID {
valid: false;
idNumber: string;
reason: InvalidReason;
}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 failsGender
type Gender = "male" | "female";CitizenshipStatus
type CitizenshipStatus = "citizen" | "permanent_resident";RawSegments
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"
}Validation Rules
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 |
Examples
Form validation
import { isValid } from "@south-african/id";
function validateForm(idNumber: string) {
if (!isValid(idNumber)) {
throw new Error("Please enter a valid South African ID number.");
}
}Detailed error handling
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()}.`;
}Displaying a person's profile
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 │
// └────────┴────────────────────────────┘
}Filtering a list
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;
});Using raw segments
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"
}CommonJS usage
const { parse, isValid } = require("@south-african/id");
const result = parse("9001049818080");
console.log(result.valid); // trueContributing
- 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!
Project structure
@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.jsonScripts
| 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 |
License
MIT © Mzantsi
