south-african-id-validator
Version:
Validate South African ID numbers and extract date of birth, gender, and citizenship. TypeScript-native, zero dependencies, runs anywhere.
143 lines (142 loc) • 4.82 kB
JavaScript
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
//#region src/luhn.ts
/**
* Verify that the final digit of a numeric string is the correct Luhn check
* digit over the preceding digits.
*
* Knows nothing about South African ID numbers — it is the bare algorithm.
*
* @param digits A string of ASCII digits. Anything else (empty string,
* non-digit characters) returns `false`.
* @returns `true` when the Luhn checksum is valid, `false` otherwise.
*/
function isValidLuhn(digits) {
if (digits.length < 2) return false;
if (!/^\d+$/.test(digits)) return false;
let sum = 0;
let shouldDouble = false;
for (let i = digits.length - 1; i >= 0; i--) {
let value = digits.charCodeAt(i) - 48;
if (shouldDouble) {
value *= 2;
if (value > 9) value -= 9;
}
sum += value;
shouldDouble = !shouldDouble;
}
return sum % 10 === 0;
}
//#endregion
//#region src/parse-citizenship.ts
/**
* Extract the citizenship status from a South African ID number.
*
* The `C` digit (index 11) is `0` for a citizen and `1` for a permanent
* resident. Any other value indicates a structurally invalid ID and yields
* `null`, which `validate()` maps to `INVALID_FORMAT`.
*/
function parseCitizenship(idNumber) {
const code = idNumber.charAt(10);
if (code === "0") return "citizen";
if (code === "1") return "permanent_resident";
return null;
}
//#endregion
//#region src/parse-date-of-birth.ts
const ELIGIBILITY_AGE_YEARS = 16;
/**
* Extract and validate the date of birth from a South African ID number.
*
* The first six digits encode `YYMMDD`. The century is inferred using the
* 16-year eligibility rule: a year that would make the holder younger than 16
* on `referenceDate` is treated as belonging to the previous century.
*
* @param idNumber A 13-digit ID number. The caller is responsible for length
* and character-class validation.
* @param referenceDate The date used as "today" for the eligibility rule.
* Inject a fixed value for deterministic tests; defaults to `new Date()`.
* @returns The parsed `Date`, or `null` if the encoded date is not a real
* calendar date (e.g. Feb 30, Feb 29 of a non-leap year).
*/
function parseDateOfBirth(idNumber, referenceDate = /* @__PURE__ */ new Date()) {
const yy = Number(idNumber.slice(0, 2));
const mm = Number(idNumber.slice(2, 4));
const dd = Number(idNumber.slice(4, 6));
const refYear = referenceDate.getFullYear();
let year = Math.floor(refYear / 100) * 100 + yy;
if (new Date(year, mm - 1, dd) > new Date(refYear - ELIGIBILITY_AGE_YEARS, referenceDate.getMonth(), referenceDate.getDate())) year -= 100;
const dateOfBirth = new Date(year, mm - 1, dd);
if (dateOfBirth.getFullYear() !== year || dateOfBirth.getMonth() !== mm - 1 || dateOfBirth.getDate() !== dd) return null;
return dateOfBirth;
}
//#endregion
//#region src/parse-gender.ts
/**
* Extract the gender from a South African ID number.
*
* The `SSSS` block (digits 7–10) is `< 5000` for female and `>= 5000` for
* male. The caller is responsible for length and character-class validation.
*/
function parseGender(idNumber) {
return Number(idNumber.slice(6, 10)) < 5e3 ? "female" : "male";
}
//#endregion
//#region src/validate.ts
const ID_LENGTH = 13;
/**
* Validate a South African ID number and, on success, extract the encoded
* date of birth, gender, and citizenship.
*
* The result is a discriminated union — narrow with `result.valid` to access
* the data fields, or with `!result.valid` to access the typed `error` code.
*
* The pipeline short-circuits on the first failure and reports the most
* fundamental problem first: length, then character class, then Luhn
* checksum, then date validity, then citizenship digit.
*
* @example
* ```ts
* const result = validate('7311190013080');
* if (result.valid) {
* console.log(result.dateOfBirth, result.gender, result.citizenship);
* } else {
* console.error(result.error);
* }
* ```
*
* @param idNumber The 13-digit ID number to validate.
* @param options Optional overrides — see {@link ValidateOptions}.
*/
function validate(idNumber, options = {}) {
if (typeof idNumber !== "string" || idNumber.length !== ID_LENGTH) return {
valid: false,
error: "INVALID_LENGTH"
};
if (!/^\d{13}$/.test(idNumber)) return {
valid: false,
error: "INVALID_FORMAT"
};
if (!isValidLuhn(idNumber)) return {
valid: false,
error: "INVALID_CHECKSUM"
};
const dateOfBirth = parseDateOfBirth(idNumber, options.referenceDate);
if (dateOfBirth === null) return {
valid: false,
error: "INVALID_DATE"
};
const citizenship = parseCitizenship(idNumber);
if (citizenship === null) return {
valid: false,
error: "INVALID_FORMAT"
};
return {
valid: true,
dateOfBirth,
gender: parseGender(idNumber),
citizenship
};
}
//#endregion
exports.validate = validate;
//# sourceMappingURL=index.cjs.map