Functional Weave
Code in Rust

validation.uk-utr

Check an HMRC Unique Taxpayer Reference's modulus 11 check digit, accepting the K and spacing people type.

2.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 the check digit of an HMRC Unique Taxpayer Reference (UTR), the ten digit reference for Self Assessment, partnerships and Corporation Tax, and returns it as ten plain digits.

A PASS IS NOT A TAXPAYER. The check digit catches most typing mistakes; it does not say HMRC issued the reference, or issued it to this person or company.

For example

  • validate_uk_utr(2234567890) → valid true, normalised 2234567890, reason — HMRC's own valid test reference, remainder 0 so the check digit is 2
  • validate_uk_utr(2108834503) → valid true, normalised 2108834503, reason — another HMRC test reference, remainder 9 so the check digit is 2 again
  • validate_uk_utr(1097172564) → valid true, normalised 1097172564, reason — HMRC test reference with remainder 1, so the check digit is 1

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 validate_uk_utr(value: &str) -> UkUtr
valuestringten digits, optionally spaced, optionally with a K at the start or end
returnsUkUtr

The type it declares, generated into your project

/// normalised is null when valid is false.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UkUtr {
    pub valid: bool,
    /// ten digits, no spaces and no K
    pub normalised: Option<String>,
    /// null when valid; empty, bad-character, bad-length, thirteen-digits or bad-check-digit
    pub reason: Option<String>,
}

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

fune!(validation.uk-utr@^2);  // then call validate_uk_utr(…)
impl/rust.rs · 81 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

/// Weights for the second to tenth digits; the first digit is the check digit.
/// From HMRC's own reference checker (hmrc/domain, UtrReferenceChecker).
const WEIGHTS: [i64; 9] = [6, 7, 8, 9, 10, 5, 4, 3, 2];

/// The check digit for each remainder of the weighted sum modulo 11. It is
/// 11 - remainder except that remainders 0 and 1 give 2 and 1, so a check
/// digit is always a single digit and no reference is impossible.
const CHECK_DIGITS: [i64; 11] = [2, 1, 9, 8, 7, 6, 5, 4, 3, 2, 1];

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

/// Check a Unique Taxpayer Reference (Self Assessment or Corporation Tax).
///
/// A pass means the reference is well formed, not that HMRC issued it to
/// this taxpayer. Checks run in a fixed order (characters, length, check
/// digit) so every language gives the same first reason.
pub fn validate_uk_utr(value: &str) -> UkUtr {
    let mut compact: Vec<char> = value.chars().filter(|ch| *ch != ' ').collect();
    if compact.is_empty() {
        return invalid("empty");
    }
    // Self Assessment shows the UTR with a K after it and some letters put
    // it in front; one K at either end is stripped, as HMRC's checker does.
    if compact[0] == 'K' || compact[0] == 'k' {
        compact.remove(0);
    } else if compact[compact.len() - 1] == 'K' || compact[compact.len() - 1] == 'k' {
        compact.pop();
    }

    let mut digits: Vec<i64> = Vec::new();
    for ch in &compact {
        if !ch.is_ascii_digit() {
            return invalid("bad-character");
        }
        digits.push(*ch as i64 - 48);
    }
    if digits.len() == 13 {
        return invalid("thirteen-digits");
    }
    if digits.len() != 10 {
        return invalid("bad-length");
    }

    let total: i64 = digits[1..].iter().zip(WEIGHTS.iter()).map(|(d, w)| d * w).sum();
    if CHECK_DIGITS[(total % 11) as usize] != digits[0] {
        return invalid("bad-check-digit");
    }
    UkUtr {
        valid: true,
        normalised: Some(compact.iter().collect()),
        reason: None,
    }
}

/// 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_utr_to_value(result: &UkUtr) -> 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)),
        ("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_utr_to_value(&validate_uk_utr(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-utr
Download for Rust validation.uk-utr-2.0.0-rust.fune · 10,673 bytes sha256 1b53684f901f46c9a036c36eceab339a067ccfd29b2a510b13df990075c7665a

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

The whole function, every language, is one file too: validation.uk-utr-2.0.0.fune, 14,818 bytes, sha256 f866c1a37886789ae2812344db45fc881ddf574927be7389c4fc7aa24e409948. 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-utr

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

// fune: after validation.uk-utr

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-utr --steps.

// fune: step validation.uk-utr 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
HMRC's own valid test reference, remainder 0 so the check digit is 2 2234567890 → valid true, normalised 2234567890, reason —
another HMRC test reference, remainder 9 so the check digit is 2 again 2108834503 → valid true, normalised 2108834503, reason —
HMRC test reference with remainder 1, so the check digit is 1 1097172564 → valid true, normalised 1097172564, reason —
remainder 2 gives check digit 9 9000000001 → valid true, normalised 9000000001, reason —
spaced as HMRC letters print it 22345 67890 → valid true, normalised 2234567890, reason —
a trailing K, as Self Assessment online shows it 2234567890K → valid true, normalised 2234567890, reason —
a leading lower-case k k1097172564 → valid true, normalised 1097172564, reason —
a trailing lower-case k and stray spaces 1097172564k → valid true, normalised 1097172564, reason —
HMRC's example 1234567890 is deliberately not a valid reference 1234567890 → valid false, normalised —, reason bad-check-digit
a valid reference with two digits transposed 2243567890 → valid false, normalised —, reason bad-check-digit
Show the other 8 tests
CaseArgumentsExpected
thirteen digits: HMRC does not publish which three are extra, so they are not guessed 1234567890123 → valid false, normalised —, reason thirteen-digits
eleven digits 12345678905 → valid false, normalised —, reason bad-length
six digits 123456 → valid false, normalised —, reason bad-length
a K alone K → valid false, normalised —, reason bad-length
a letter after the trailing K 1097172564KZ → valid false, normalised —, reason bad-character
K at both ends: only one is ever stripped K2234567890K → valid false, normalised —, reason bad-character
a hyphen is not accepted 22345-67890 → valid false, normalised —, reason bad-character
the empty string → valid false, normalised —, reason empty

More from the author

## The check

The first digit is the check digit. Multiply the second to tenth digits by 6, 7, 8, 9, 10, 5, 4, 3 and 2, add them up and take the remainder on dividing by 11. The check digit for remainders 0 to 10 is 2, 1, 9, 8, 7, 6, 5, 4, 3, 2, 1: that is 11 minus the remainder, except that remainders 0 and 1 give 2 and 1. The often-quoted shortcut "(11 - remainder) mod 11" gets those two remainders wrong and rejects real references such as HMRC's own test reference 2234567890.

HMRC has not published the algorithm as a specification. The weights and the remainder table here are taken from HMRC's own open-source reference checker, and its valid and invalid test references are vectors here.

## Input

HMRC's design pattern for asking for a UTR allows spaces and a K at the start or end ("1234567890K" is how Self Assessment online shows it). ASCII spaces are ignored anywhere, and one K (either case) is removed from the start or, failing that, the end. Anything else is `bad-character`.

The same pattern allows 13-digit forms and says to remove "extra digits", but does not say which three digits are extra. Rather than guess, a 13-digit value is refused with its own reason, `thirteen-digits`, so a form can ask for the ten-digit reference.

| reason | meaning (checked in this order) | |---|---| | `empty` | nothing but spaces, or not a string | | `bad-character` | a character other than a digit or space, after one K is removed | | `thirteen-digits` | a 13-digit form (see above) | | `bad-length` | any other length than ten digits | | `bad-check-digit` | the first digit does not match |

## Sources

- HMRC, hmrc/domain on GitHub, `referencechecker/ReferenceChecker.scala` (`UtrReferenceChecker`, `SelfAssessmentReferenceChecker`) and `ModulusCheckerSpec.scala`, read 23 September 2026: https://github.com/hmrc/domain - HMRC design patterns, "Unique Taxpayer Reference": https://design.tax.service.gov.uk/hmrc-design-patterns/unique-taxpayer-reference/

2.0.0 renames the function from `ukUtr` to `validateUkUtr` (`validate_uk_utr` in Python and Rust), so every validator that returns a result record is `validateX` and every one that returns a bool is `isX`. Nothing else changed; 1.0.0 stays published under the old name.

Files

PathBytes
README.md2,610
impl/python.py1,936
impl/rust.rs2,892
impl/typescript.ts2,005
vectors.json2,799