validation.vin
Check a 17-character vehicle identification number and its check digit; enforced for North American VINs.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 17 tests, run in TypeScript, Python and Rust.
What it does
Checks a vehicle identification number: the 17-character code stamped on the chassis and printed on a V5C logbook. ISO 3779 fixes the length and alphabet; the check digit in position 9 comes from the North American standard (US 49 CFR 565.15) and is the part that catches typos.
THE CHECK DIGIT IS NOT UNIVERSAL, and that decides the shape of the answer. North American law requires it, so a VIN whose world manufacturer identifier starts with 1 to 5 (United States, Canada, Mexico) and whose check digit does not match is `valid: false` with `bad-check-digit`. ISO 3779 itself does not require one, and many European and Japanese manufacturers put a letter or an arbitrary digit in position 9 for vehicles built for their home markets. Rejecting those would reject real cars on UK roads, so for every other region a mismatch leaves `valid: true` and reports `checkDigitMatches: false`. A caller who knows the vehicle should carry a check digit (anything built for sale in North America, and most Chinese VINs) can insist on `checkDigitMatches`.
For example
validateVin(1M8GDM9AXKP042788)→ valid true, normalised 1M8GDM9AXKP042788, wmi 1M8, check digit matches true, reason — the textbook VIN whose check digit is X (remainder ten)validateVin(1HGCM82633A004352)→ valid true, normalised 1HGCM82633A004352, wmi 1HG, check digit matches true, reason — a US-built HondavalidateVin(11111111111111111)→ valid true, normalised 11111111111111111, wmi 111, check digit matches true, reason — seventeen ones: the weights sum to 89, which leaves 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.
export function validateVin(value: string): VinCheck
| value | string | a 17-character VIN in any case, optionally spaced |
| returns | VinCheck |
The type it declares, generated into your project
/** The result of checking a VIN. Strings and checkDigitMatches are null when valid is false; reason is null when it is true. */
export interface VinCheck {
readonly valid: boolean;
/** upper case, no spaces */
readonly normalised: string | null;
/** the world manufacturer identifier, the first three characters */
readonly wmi: string | null;
/** whether position 9 holds the computed check digit */
readonly checkDigitMatches: boolean | null;
/** empty, bad-character, bad-length, forbidden-letter or bad-check-digit */
readonly reason: string | null;
}
Your code names it in one line, in the file that uses it
import { validateVin } from "#fune/validation.vin@^1";
import { type VinCheck } from "./validation_vin_types.ts";
/**
* Letter values from 49 CFR 565.15, indexed by letter A-Z. I, O and Q are
* never used and hold -1; the others run 1-9 in three passes (A-I, J-R, S-Z)
* with the forbidden letters' slots skipped.
*/
const LETTER_VALUES = [1, 2, 3, 4, 5, 6, 7, 8, -1, 1, 2, 3, 4, 5, -1, 7, -1, 9, 2, 3, 4, 5, 6, 7, 8, 9];
/** Position weights; position 9 is the check digit itself and weighs nothing. */
const WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2];
function invalid(reason: string): VinCheck {
return { valid: false, normalised: null, wmi: null, checkDigitMatches: null, reason };
}
/**
* Check a 17-character VIN.
*
* The check digit is enforced only for North American VINs (first character
* 1-5), where the law requires it; elsewhere a mismatch is reported in
* checkDigitMatches but does not make the VIN invalid, because many European
* and Asian VINs never carried one. Validators answer rather than throw.
*/
export function validateVin(value: string): VinCheck {
if (typeof value !== "string") return invalid("empty");
let vin = "";
for (const ch of value) {
if (ch === " ") continue;
// Folded by hand: toUpperCase() is Unicode-aware and Rust's ASCII fold is not.
vin += ch >= "a" && ch <= "z" ? String.fromCharCode(ch.charCodeAt(0) - 32) : ch;
}
if (vin.length === 0) return invalid("empty");
for (const ch of vin) {
if (!((ch >= "0" && ch <= "9") || (ch >= "A" && ch <= "Z"))) return invalid("bad-character");
}
if (vin.length !== 17) return invalid("bad-length");
let total = 0;
for (let i = 0; i < 17; i++) {
const code = vin.charCodeAt(i);
let n: number;
if (code <= 57) n = code - 48;
else {
n = LETTER_VALUES[code - 65];
// I, O and Q read as 1, 0 and 0, so the standard never issues them.
if (n < 0) return invalid("forbidden-letter");
}
total += n * WEIGHTS[i];
}
const remainder = total % 11;
const expected = remainder === 10 ? "X" : String(remainder);
const matches = vin[8] === expected;
// WMIs starting 1-5 are North American, where 49 CFR 565 makes the check
// digit mandatory; anywhere else it is optional, so only report it.
if (!matches && vin[0] >= "1" && vin[0] <= "5") return invalid("bad-check-digit");
return { valid: true, normalised: vin, wmi: vin.slice(0, 3), checkDigitMatches: matches, reason: null };
}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.vin
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./validation.vin-1.0.0-typescript.fune, or fetch it from a terminal with fune pull validation.vin@1.0.0:typescript.
The whole function, every language, is one file too: validation.vin-1.0.0.fune, 17,748 bytes, sha256 310dc9072c78fe16ddf0e7f98d75b9535b807ba32c97aed2874a45be9b7b36c6. 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.vin
after — your function gets the result and the arguments, and returns the final result.
// fune: after validation.vin
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.vin --steps.
// fune: step validation.vin 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.
| Case | Arguments | Expected | |
|---|---|---|---|
| the textbook VIN whose check digit is X (remainder ten) | 1M8GDM9AXKP042788 | → | valid true, normalised 1M8GDM9AXKP042788, wmi 1M8, check digit matches true, reason — |
| a US-built Honda | 1HGCM82633A004352 | → | valid true, normalised 1HGCM82633A004352, wmi 1HG, check digit matches true, reason — |
| seventeen ones: the weights sum to 89, which leaves 1 | 11111111111111111 | → | valid true, normalised 11111111111111111, wmi 111, check digit matches true, reason — |
| a Mexican-built VIN, where the check digit is 0 | 3VWFE21C04M000001 | → | valid true, normalised 3VWFE21C04M000001, wmi 3VW, check digit matches true, reason — |
| lower case and spaces are normalised | 1hgcm8263 3a004352 | → | valid true, normalised 1HGCM82633A004352, wmi 1HG, check digit matches true, reason — |
| a North American VIN with the wrong check digit is invalid | 1HGCM82643A004352 | → | valid false, normalised —, wmi —, check digit matches —, reason bad-check-digit |
| a Mexican VIN with the wrong check digit is invalid too | 3VWFE21C54M000001 | → | valid false, normalised —, wmi —, check digit matches —, reason bad-check-digit |
| a single mistyped character elsewhere is caught by the check digit | 1HGCM82633A004353 | → | valid false, normalised —, wmi —, check digit matches —, reason bad-check-digit |
| a European VIN with Z in position 9 is valid but reports the mismatch | WVWZZZ1JZXW000001 | → | valid true, normalised WVWZZZ1JZXW000001, wmi WVW, check digit matches false, reason — |
| an O where a 0 belongs is a forbidden letter | 1HGCM82633A0O4352 | → | valid false, normalised —, wmi —, check digit matches —, reason forbidden-letter |
Show the other 7 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a lower-case i is folded and still forbidden | 1hgcm82633a0043i2 | → | valid false, normalised —, wmi —, check digit matches —, reason forbidden-letter |
| sixteen characters is too short | 1HGCM82633A00435 | → | valid false, normalised —, wmi —, check digit matches —, reason bad-length |
| eighteen characters is too long | 1HGCM82633A0043520 | → | valid false, normalised —, wmi —, check digit matches —, reason bad-length |
| a hyphen is a bad character, reported before the length | 1HGCM826-33A004352 | → | valid false, normalised —, wmi —, check digit matches —, reason bad-character |
| the empty string | → | valid false, normalised —, wmi —, check digit matches —, reason empty | |
| spaces only is empty | → | valid false, normalised —, wmi —, check digit matches —, reason empty | |
| a non-string is empty, not an exception | — | → | valid false, normalised —, wmi —, check digit matches —, reason empty |
More from the author
THE CALCULATION: each character becomes a number (digits are themselves; A-H are 1-8, J-N 1-5, P 7, R 9, S-Z 2-9, skipping the forbidden letters), is multiplied by the weight for its position (8 7 6 5 4 3 2 10 0 9 8 7 6 5 4 3 2), and the sum is taken modulo 11; a remainder of 10 is written `X`. Position 9, the check digit itself, has weight 0.
FORBIDDEN LETTERS: I, O and Q never appear in a VIN because they read as 1, 0 and 0. They get their own reason, `forbidden-letter`, because they almost always mean a transcription slip that the user can fix by retyping that one character.
ACCEPTED INPUT: letters in either case (folded to upper case) and digits; ASCII spaces are ignored anywhere. Hyphens and anything else are refused, because VINs are never printed with them.
| reason | meaning | |--------------------|----------------------------------------------------------| | `empty` | nothing but spaces, or not a string at all | | `bad-character` | anything other than A-Z, a-z, 0-9 and spaces | | `bad-length` | not exactly 17 characters | | `forbidden-letter` | contains I, O or Q | | `bad-check-digit` | a North American VIN (starts 1-5) whose check digit is wrong |
The checks run in that order; the first failure is the reason. Validators answer rather than throw.
Out of scope: decoding the model year (position 10), the plant, or the manufacturer's name from the WMI, and pre-1981 VINs, which were not 17 characters.
Sources: ISO 3779:2009 "Road vehicles - Vehicle identification number (VIN) - Content and structure"; US 49 CFR 565.15 "Content requirements" (check digit, transliteration and weight tables), https://www.ecfr.gov/current/title-49/subtitle-B/chapter-V/part-565/subpart-B/section-565.15.
Files
| Path | Bytes |
|---|---|
| README.md | 2,965 |
| impl/python.py | 2,294 |
| impl/rust.rs | 3,519 |
| impl/typescript.ts | 2,429 |
| vectors.json | 3,525 |