Functional Weave
Code in Rust

validation.uk-company-number

Check a Companies House company number's format and prefix (SC, NI, OC, LP...) and zero-pad it to eight characters.

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

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

What it does

Checks that a Companies House company registration number (CRN) has the shape Companies House issues, with a prefix it uses, and returns it as the eight characters the register and its API expect, with what the prefix means.

A PASS IS NOT A COMPANY. Company numbers carry no check digit. A pass means the number is well formed; whether it is on the register, and whether it is still active, is a lookup on the Companies House register or API.

For example

  • uk_company_number(02050399) → valid true, normalised 02050399, prefix , jurisdiction england-wales, company type Company registered in England and Wales, reason — Companies House's own example of a plain England and Wales number
  • uk_company_number(2050399) → valid true, normalised 02050399, prefix , jurisdiction england-wales, company type Company registered in England and Wales, reason — the same number with its leading zero dropped is padded back
  • uk_company_number(SC002180) → valid true, normalised SC002180, prefix SC, jurisdiction scotland, company type Company registered in Scotland, reason — Companies House's own Scottish example

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.

pub fn uk_company_number(value: &str) -> UkCompanyNumber
valuestringa company number in any case, optionally spaced; leading zeros may be missing
returnsUkCompanyNumber

The type it declares, generated into your project

/// Everything but valid and reason is null when valid is false.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UkCompanyNumber {
    pub valid: bool,
    /// eight characters, upper case: "SC002180", "02050399"
    pub normalised: Option<String>,
    /// the two-character prefix, or "" for a plain eight-digit number
    pub prefix: Option<String>,
    /// england-wales, scotland, northern-ireland or united-kingdom
    pub jurisdiction: Option<String>,
    /// what the prefix registers, in words
    pub company_type: Option<String>,
    /// null when valid; empty, bad-character, bad-length, bad-format or unknown-prefix
    pub reason: Option<String>,
}

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

fune!(validation.uk-company-number@^1);  // then call uk_company_number(…)
impl/rust.rs · 103 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::validation_uk_company_number_data::COMPANY_PREFIXES;  ← this capability’s own data, compiled from data/company-prefixes.json into the same file by fune build

/// Every company number is eight characters: eight digits, or a two-character
/// prefix and six digits.
const LENGTH: usize = 8;

fn invalid(reason: &str) -> UkCompanyNumber {
    UkCompanyNumber {
        valid: false,
        normalised: None,
        prefix: None,
        jurisdiction: None,
        company_type: None,
        reason: Some(reason.to_string()),
    }
}

/// Check a Companies House company number's shape and prefix.
///
/// There is no check digit, so a pass means the number is shaped like one
/// Companies House issues under a prefix it uses, not that the company exists:
/// that is a lookup on the register. Checks run in a fixed order (characters,
/// shape, length, prefix) so every language gives the same first reason.
pub fn uk_company_number(value: &str) -> UkCompanyNumber {
    let mut compact = String::new();
    for ch in value.chars() {
        if ch == ' ' {
            continue;
        }
        // ASCII-only upper-casing, so every language folds identically.
        let ch = ch.to_ascii_uppercase();
        if !ch.is_ascii_uppercase() && !ch.is_ascii_digit() {
            return invalid("bad-character");
        }
        compact.push(ch);
    }
    if compact.is_empty() {
        return invalid("empty");
    }

    // Everything is ASCII by now, so byte indexing is character indexing.
    let bytes = compact.as_bytes();
    // R0 (pre-partition Northern Ireland) is the one prefix containing a digit.
    let prefix_len = if compact.starts_with("R0")
        || (bytes.len() >= 2 && bytes[0].is_ascii_uppercase() && bytes[1].is_ascii_uppercase())
    {
        2
    } else if bytes[0].is_ascii_uppercase() {
        return invalid("bad-format");
    } else {
        0
    };
    let prefix = &compact[..prefix_len];
    let digits = &compact[prefix_len..];
    if !digits.bytes().all(|b| b.is_ascii_digit()) {
        return invalid("bad-format");
    }
    let width = LENGTH - prefix_len;
    if digits.is_empty() || digits.len() > width {
        return invalid("bad-length");
    }
    // Leading zeros are part of the number and are often dropped when typed,
    // so they are put back; a number of nothing but zeros is no company.
    if digits.bytes().all(|b| b == b'0') {
        return invalid("bad-format");
    }

    match COMPANY_PREFIXES.iter().find(|row| row.prefix == prefix) {
        Some(row) => UkCompanyNumber {
            valid: true,
            normalised: Some(format!("{}{}{}", prefix, "0".repeat(width - digits.len()), digits)),
            prefix: Some(prefix.to_string()),
            jurisdiction: Some(row.jurisdiction.to_string()),
            company_type: Some(row.company_type.to_string()),
            reason: None,
        },
        None => invalid("unknown-prefix"),
    }
}

/// Object keys are camelCase to match the shared vectors, and so that a
/// capability building on this one can reuse the same shape.
pub fn uk_company_number_to_value(result: &UkCompanyNumber) -> Value {
    let text = |field: &Option<String>| match field {
        Some(s) => Value::str(s),
        None => Value::Null,
    };
    Value::obj(vec![
        ("valid", Value::Bool(result.valid)),
        ("normalised", text(&result.normalised)),
        ("prefix", text(&result.prefix)),
        ("jurisdiction", text(&result.jurisdiction)),
        ("companyType", text(&result.company_type)),
        ("reason", text(&result.reason)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    // A non-string argument arrives here as an empty string, which is exactly
    // the answer TypeScript and Python give for a non-string: empty.
    uk_company_number_to_value(&uk_company_number(args[0].as_str()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add validation.uk-company-number
Download for Rust validation.uk-company-number-1.0.0-rust.fune · 18,010 bytes sha256 4b2a57ebf004a52f5bff5e48d24208825f872b940f8f827bf284f63be6a67c11

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

The whole function, every language, is one file too: validation.uk-company-number-1.0.0.fune, 23,490 bytes, sha256 74bc6328c07a7c0020b5f2e8564b98c5de6506b72a6ead13b6d7260fbebd260a. 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-company-number

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

// fune: after validation.uk-company-number

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-company-number --steps.

// fune: step validation.uk-company-number 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
Companies House's own example of a plain England and Wales number 02050399 → valid true, normalised 02050399, prefix , jurisdiction england-wales, company type Company registered in England and Wales, reason —
the same number with its leading zero dropped is padded back 2050399 → valid true, normalised 02050399, prefix , jurisdiction england-wales, company type Company registered in England and Wales, reason —
Companies House's own Scottish example SC002180 → valid true, normalised SC002180, prefix SC, jurisdiction scotland, company type Company registered in Scotland, reason —
lower case, spaced and short of its zeros sc 2180 → valid true, normalised SC002180, prefix SC, jurisdiction scotland, company type Company registered in Scotland, reason —
a Northern Ireland company NI012345 → valid true, normalised NI012345, prefix NI, jurisdiction northern-ireland, company type Company registered in Northern Ireland, reason —
an England and Wales LLP OC301234 → valid true, normalised OC301234, prefix OC, jurisdiction england-wales, company type Limited liability partnership, reason —
a Scottish limited partnership SL012345 → valid true, normalised SL012345, prefix SL, jurisdiction scotland, company type Limited partnership, reason —
an overseas company FC012345 → valid true, normalised FC012345, prefix FC, jurisdiction united-kingdom, company type Overseas company, reason —
R0, the one prefix with a digit in it, for pre-partition Northern Ireland companies R0000123 → valid true, normalised R0000123, prefix R0, jurisdiction northern-ireland, company type Northern Ireland company (pre-partition), reason —
a two-letter prefix Companies House does not use XX123456 → valid false, normalised —, prefix —, jurisdiction —, company type —, reason unknown-prefix
Show the other 8 tests
CaseArgumentsExpected
nine digits 123456789 → valid false, normalised —, prefix —, jurisdiction —, company type —, reason bad-length
a prefix followed by seven digits SC1234567 → valid false, normalised —, prefix —, jurisdiction —, company type —, reason bad-length
a prefix with no digits LP → valid false, normalised —, prefix —, jurisdiction —, company type —, reason bad-length
a single letter is not a prefix S1234567 → valid false, normalised —, prefix —, jurisdiction —, company type —, reason bad-format
letters in the middle of the number 12AB3456 → valid false, normalised —, prefix —, jurisdiction —, company type —, reason bad-format
all zeros is no company 00000000 → valid false, normalised —, prefix —, jurisdiction —, company type —, reason bad-format
a hyphen is not accepted SC-002180 → valid false, normalised —, prefix —, jurisdiction —, company type —, reason bad-character
the empty string → valid false, normalised —, prefix —, jurisdiction —, company type —, reason empty

More from the author

## The format

Eight characters: eight digits for a company registered in England and Wales, or a two-character prefix and six digits for everything else (SC Scotland, NI Northern Ireland, OC/SO/NC limited liability partnerships, LP/SL/NL limited partnerships, FC overseas companies, and so on). R0, for pre-partition Northern Ireland companies, is the one prefix with a digit in it.

Leading zeros are part of the number, and dropping them names a different company or none. Because people drop them when typing, a number short of its zeros is padded back: "2050399" becomes "02050399" and "sc 2180" becomes "SC002180". A number of nothing but zeros is refused.

## The prefix table (data)

`data/company-prefixes.json` holds every prefix in Companies House's list of company numbers and prefixes, including the ones for which Companies House holds only the name and number (industrial and provident societies, ICVCs, royal charter companies, credit unions). A prefix not in the table is `unknown-prefix`. Companies House does add prefixes from time to time, so an unknown prefix can be a new one. A prefix added in future is a data release of this capability.

## Input and result

Case is ignored and ASCII spaces are ignored anywhere. Anything else that is not an ASCII letter or digit, hyphens included, is `bad-character`. Validators answer rather than throw.

| reason | meaning (checked in this order) | |---|---| | `empty` | nothing but spaces, or not a string | | `bad-character` | a character other than an ASCII letter, digit or space | | `bad-format` | a single leading letter, letters after the prefix, or all zeros | | `bad-length` | no digits, or more than eight characters in all | | `unknown-prefix` | a prefix not in the table |

## Source

Companies House, "Uniform Resource Identifiers (URI) Customer Guide", version 1.1, section "List of Company Numbers and Prefixes": https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/426891/uniformResourceIdentifiersCustomerGuide.pdf

Files

PathBytes
README.md2,515
data/company-prefixes.json3,737
impl/python.py2,630
impl/rust.rs3,782
impl/typescript.ts2,582
vectors.json4,058