Functional Weave
Code in Python

validation.uk-postcode@1.0.0

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;
}