south-african-id

Parse, validate, and extract data from South African ID numbers


Keywords
south africa, south african, id, id number, id parser, id validator, za, rsa
License
MIT
Install
npm install south-african-id@1.0.0

Documentation

@south-african/id

Parse, validate, and extract data from South African ID numbers — fully typed, zero dependencies.

npm version license types


Table of Contents


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 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 ✓

Installation

pnpm (recommended)

pnpm add @south-african/id

npm

npm install @south-african/id

yarn

yarn add @south-african/id

bun

bun add @south-african/id

Quick 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.

ReturnsParsedID (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"); // false

getDateOfBirth(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"); // null

getGender(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"); // null

getAge(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"); // null

getCitizenship(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"); // null

luhn(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"); // false

Types

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 fails

Gender

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); // true

Contributing

  1. Fork the repo and clone locally.
  2. Install dependencies: pnpm install
  3. Run tests in watch mode: pnpm test:watch
  4. Build: pnpm build
  5. 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.json

Scripts

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