npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@south-african/id

v1.0.3

Published

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

Downloads

344

Readme

@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