impl/typescript.ts
4,301 bytes · the TypeScript implementation · view raw
import { type UkPostcode } from "./validation_uk_postcode_types.ts";
/**
* Letters excluded from the two final characters of the inward code. They are
* the ones most easily misread in handwriting and on a sorting machine, so
* Royal Mail never issued them there.
*/
const INWARD_EXCLUDED = "CIKMOV";
/** Q, V and X never appear as the first letter of a postcode area. */
const AREA_FIRST_EXCLUDED = "QVX";
/** I, J and Z never appear as the second letter of a two-letter area. */
const AREA_SECOND_EXCLUDED = "IJZ";
/** The only letters used in the third position of the A9A district form. */
const THIRD_POSITION_ALLOWED = "ABCDEFGHJKPSTUW";
/** The only letters used in the fourth position of the AA9A district form. */
const FOURTH_POSITION_ALLOWED = "ABEHMNPRVWXY";
/**
* The one postcode that obeys no rule: Girobank's, kept in service since 1968
* and still valid on an address label.
*/
const GIROBANK = "GIR0AA";
const INVALID: UkPostcode = { valid: false, normalised: null, outward: null, inward: null };
function isDigit(ch: string): boolean {
return ch >= "0" && ch <= "9";
}
function isLetter(ch: string): boolean {
return ch >= "A" && ch <= "Z";
}
/**
* Parse and normalise a UK postcode.
*
* Returns a result rather than throwing, and rather than returning a bare
* boolean: the caller nearly always wants the canonical form to store, and
* splitting outward from inward is the difference between "can we deliver
* here" and "which delivery office is this". A malformed value comes back as
* valid=false with nulls, so `result.normalised ?? input` is safe.
*
* Normalisation is upper case with exactly one space before the final three
* characters: "sw1a1aa" and " SW1A 1AA " both become "SW1A 1AA". That is
* the form Royal Mail prints and the form two records should be compared in.
*/
export function ukPostcode(value: string): UkPostcode {
if (typeof value !== "string") return INVALID;
// Only the ASCII space is stripped, and it is stripped everywhere: people
// type the space in the wrong place far more often than they omit it, so
// position carries no information worth preserving.
let code = "";
for (const ch of value) {
if (ch === " ") continue;
code += ch >= "a" && ch <= "z" ? String.fromCharCode(ch.charCodeAt(0) - 32) : ch;
}
// Five is "M1 1AE", seven is "DN55 1PT"; nothing shorter or longer exists.
if (code.length < 5 || code.length > 7) return INVALID;
if (code === GIROBANK) {
return { valid: true, normalised: "GIR 0AA", outward: "GIR", inward: "0AA" };
}
const inward = code.slice(code.length - 3);
const outward = code.slice(0, code.length - 3);
if (!isInward(inward) || !isOutward(outward)) return INVALID;
return { valid: true, normalised: `${outward} ${inward}`, outward, inward };
}
/** The inward code is always a digit followed by two letters. */
function isInward(inward: string): boolean {
if (!isDigit(inward[0])) return false;
for (let i = 1; i < 3; i++) {
if (!isLetter(inward[i]) || INWARD_EXCLUDED.indexOf(inward[i]) >= 0) return false;
}
return true;
}
/**
* The outward code is one of six shapes: A9, A99, A9A, AA9, AA99, AA9A. The
* letter restrictions below are not cosmetic - they are what stops a plausible
* looking string such as "QW1A" from being accepted.
*/
function isOutward(outward: string): boolean {
if (!isLetter(outward[0]) || AREA_FIRST_EXCLUDED.indexOf(outward[0]) >= 0) return false;
if (outward.length === 2) {
// A9
return isDigit(outward[1]);
}
if (outward.length === 3) {
if (isLetter(outward[1])) {
// AA9
return AREA_SECOND_EXCLUDED.indexOf(outward[1]) < 0 && isDigit(outward[2]);
}
// A99 or A9A
if (!isDigit(outward[1])) return false;
return isDigit(outward[2]) || THIRD_POSITION_ALLOWED.indexOf(outward[2]) >= 0;
}
if (outward.length === 4) {
// AA99 or AA9A
if (!isLetter(outward[1]) || AREA_SECOND_EXCLUDED.indexOf(outward[1]) >= 0) return false;
if (!isDigit(outward[2])) return false;
return isDigit(outward[3]) || FOURTH_POSITION_ALLOWED.indexOf(outward[3]) >= 0;
}
return false;
}
/** Shorthand for callers that only need the yes or no. */
export function isUkPostcode(value: string): boolean {
return ukPostcode(value).valid;
}