Functional Weave
Code in TypeScript

validation.uk-postcode

Validate a UK postcode and normalise it to canonical upper case with one space before the inward code.

1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 26 tests, run in TypeScript, Python and Rust.

What it does

Returns a result object rather than a boolean because the caller almost always wants both answers at once: whether to accept the value, and what to store. Storing what the user typed means "sw1a1aa" and "SW1A 1AA" are two different rows for one address; storing `normalised` means they are one. Splitting outward from inward is the difference between "can we deliver here" and "which delivery office is this", and callers that only want the yes or no have isUkPostcode / is_uk_postcode.

Malformed input returns valid=false with nulls rather than throwing, so `result.normalised ?? typed` is always safe and a bad address never becomes an exception in the middle of a form submission.

For example

  • ukPostcode(EC1A 1BB) → valid true, normalised EC1A 1BB, outward EC1A, inward 1BB AA9A 9AA, the London EC1 form
  • ukPostcode(W1A 0AX) → valid true, normalised W1A 0AX, outward W1A, inward 0AX A9A 9AA, the single-letter West End form
  • ukPostcode(M1 1AE) → valid true, normalised M1 1AE, outward M1, inward 1AE A9 9AA, the shortest postcode there is

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

export function ukPostcode(value: string): UkPostcode
valuestringA postcode in any case, with or without the space
returnsUkPostcode

The type it declares, generated into your project

/** The three strings are null when valid is false. */
export interface UkPostcode {
  readonly valid: boolean;
  readonly normalised: string | null;
  readonly outward: string | null;
  readonly inward: string | null;
}

Your code names it in one line, in the file that uses it

import { ukPostcode } from "#fune/validation.uk-postcode@^1";
impl/typescript.ts · 123 lines · open · 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;
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add validation.uk-postcode
Download for TypeScript validation.uk-postcode-1.0.0-typescript.fune · 13,850 bytes sha256 cf8ca111ccd9619931ed482e9ca2c2e05d679d9e094075844731db12d704f4a8

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./validation.uk-postcode-1.0.0-typescript.fune, or fetch it from a terminal with fune pull validation.uk-postcode@1.0.0:typescript.

The whole function, every language, is one file too: validation.uk-postcode-1.0.0.fune, 24,130 bytes, sha256 fc3e50b998f9065970ff5105dff00793c5a3284e5330da76cbb20a320751e3cf. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before validation.uk-postcode

after — your function gets the result and the arguments, and returns the final result.

// fune: after validation.uk-postcode

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show validation.uk-postcode --steps.

// fune: step validation.uk-postcode after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
AA9A 9AA, the London EC1 form EC1A 1BB → valid true, normalised EC1A 1BB, outward EC1A, inward 1BB
A9A 9AA, the single-letter West End form W1A 0AX → valid true, normalised W1A 0AX, outward W1A, inward 0AX
A9 9AA, the shortest postcode there is M1 1AE → valid true, normalised M1 1AE, outward M1, inward 1AE
A99 9AA B33 8TH → valid true, normalised B33 8TH, outward B33, inward 8TH
AA9 9AA CR2 6XH → valid true, normalised CR2 6XH, outward CR2, inward 6XH
AA99 9AA, the longest postcode there is DN55 1PT → valid true, normalised DN55 1PT, outward DN55, inward 1PT
lower case and no space at all is normalised sw1a1aa → valid true, normalised SW1A 1AA, outward SW1A, inward 1AA
stray spaces anywhere are normalised away sw1a 1aa → valid true, normalised SW1A 1AA, outward SW1A, inward 1AA
the Girobank special case, which fits none of the six shapes GIR 0AA → valid true, normalised GIR 0AA, outward GIR, inward 0AA
the Girobank special case, unspaced and lower case gir0aa → valid true, normalised GIR 0AA, outward GIR, inward 0AA
Show the other 16 tests
CaseArgumentsExpected
a Scottish postcode, to pin the area rules beyond London EH12 9DN → valid true, normalised EH12 9DN, outward EH12, inward 9DN
a Northern Irish postcode BT1 5GS → valid true, normalised BT1 5GS, outward BT1, inward 5GS
the empty string is not a postcode → valid false, normalised —, outward —, inward —
one character too long SW1A 1AAA → valid false, normalised —, outward —, inward —
one character too short M1 1A → valid false, normalised —, outward —, inward —
Q never starts a postcode area QW1A 1AA → valid false, normalised —, outward —, inward —
I is never the second letter of an area SI1A 1AA → valid false, normalised —, outward —, inward —
I is not one of the letters used in the AA9A fourth position SW1I 1AA → valid false, normalised —, outward —, inward —
I is not one of the letters used in the A9A third position W1I 0AX → valid false, normalised —, outward —, inward —
C is excluded from the first letter of the inward code SW1A 1CA → valid false, normalised —, outward —, inward —
C is excluded from the second letter of the inward code SW1A 1AC → valid false, normalised —, outward —, inward —
the inward code must end in two letters, not a digit SW1A 1A1 → valid false, normalised —, outward —, inward —
a postcode cannot start with a digit 1W1A 1AA → valid false, normalised —, outward —, inward —
all digits, the right length and still not a postcode 12345 → valid false, normalised —, outward —, inward —
a hyphen is not a separator this accepts SW1A-1AA → valid false, normalised —, outward —, inward —
a non-string argument is not a postcode 1 → valid false, normalised —, outward —, inward —

More from the author

FORMAT: the outward code is one of six shapes - A9, A99, A9A, AA9, AA99, AA9A - and the inward code is always 9AA. The letter restrictions are real rules, not decoration, and they are what rejects plausible-looking strings: Q, V and X never start an area; I, J and Z are never the second letter of a two-letter area; the third position of the A9A form is restricted to ABCDEFGHJKPSTUW; the fourth position of the AA9A form to ABEHMNPRVWXY; and the final two letters exclude C, I, K, M, O and V, which are the ones most easily misread by a person or a sorting machine.

GIR 0AA is accepted as a special case. It was issued to Girobank in 1968, fits none of the six shapes, and is still valid on an address label; a validator that rejects it is wrong in a way that is very hard for the affected user to argue with.

NORMALISATION: every ASCII space is stripped and exactly one is reinserted before the final three characters. People put the space in the wrong place far more often than they leave it out, so its original position carries no information worth preserving. Only the ASCII space is stripped - a hyphen or a non-breaking space makes the value invalid rather than being cleaned up, because those mean the value came from somewhere unexpected.

A WELL-FORMED POSTCODE IS NOT A REAL ADDRESS. This checks the shape, not existence: "AA1 1AA" passes every rule above and has never been issued. Only the Royal Mail Postcode Address File (PAF), or an address lookup service built on it, can tell you a postcode exists and what is at it. Use this to catch typing errors before the lookup, not instead of it.

OUT OF SCOPE: BFPO numbers for British Forces addresses, the overseas territory codes (Gibraltar's GX11 1AA, the Falklands' FIQQ 1ZZ, Ascension's ASCN 1ZZ and the rest) and Crown dependency codes are not special-cased; the ones that happen to fit the six shapes pass, and the ones that do not are rejected. If you accept forces or territory mail, test for those prefixes before calling this.

Files

PathBytes
README.md2,714
impl/python.py4,249
impl/rust.rs5,594
impl/typescript.ts4,301
vectors.json4,285