validation.isbn
Check an ISBN-10 or ISBN-13 and convert between them; the normalised form is always the ISBN-13.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 19 tests, run in TypeScript, Python and Rust.
What it does
Checks an International Standard Book Number in either form and hands back both. The answer is always normalised to the ISBN-13, because that is the form every ISBN has had since 2007 and the one to store and compare: the ISBN-10 0-306-40615-2 and the ISBN-13 978-0-306-40615-7 are the same book.
TWO CHECK DIGITS, TWO ALGORITHMS:
For example
validate_isbn(0-306-40615-2)→ valid true, normalised 9780306406157, isbn10 0306406152, reason — an ISBN-10, converted to its ISBN-13validate_isbn(978-0-306-40615-7)→ valid true, normalised 9780306406157, isbn10 0306406152, reason — the same book as an ISBN-13validate_isbn(0-8044-2957-X)→ valid true, normalised 9780804429573, isbn10 080442957X, reason — an ISBN-10 whose check digit is X (ten)
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_isbn(value: &str) -> IsbnCheck
| value | string | an ISBN-10 or ISBN-13, hyphenated or spaced, optionally prefixed "ISBN" |
| returns | IsbnCheck |
The type it declares, generated into your project
/// The result of checking an ISBN. Every string is null when valid is false; reason is null when it is true.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IsbnCheck {
pub valid: bool,
/// the ISBN-13, digits only
pub normalised: Option<String>,
/// the ISBN-10 equivalent; null for 979 ISBNs, which have none
pub isbn10: Option<String>,
/// empty, bad-character, bad-length, bad-format, bad-prefix or bad-check-digit
pub reason: Option<String>,
}
Your code names it in one line, in the file that uses it
fune!(validation.isbn@^1); // then call validate_isbn(…)
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
fn invalid(reason: &str) -> IsbnCheck {
IsbnCheck {
valid: false,
normalised: None,
isbn10: None,
reason: Some(reason.to_string()),
}
}
fn digit(ch: char) -> u32 {
ch as u32 - 48
}
/// The EAN-13 check digit for twelve digits: weights 1, 3, 1, 3 ... from the left.
fn ean_check(twelve: &[char]) -> char {
let mut total = 0;
for i in 0..12 {
total += digit(twelve[i]) * if i % 2 == 0 { 1 } else { 3 };
}
char::from_digit((10 - total % 10) % 10, 10).unwrap()
}
/// The ISBN-10 check character for nine digits: weights 10 down to 2, mod 11, 10 written X.
fn isbn10_check(nine: &[char]) -> char {
let mut total = 0;
for i in 0..9 {
total += digit(nine[i]) * (10 - i as u32);
}
let check = (11 - total % 11) % 11;
if check == 10 {
'X'
} else {
char::from_digit(check, 10).unwrap()
}
}
/// Check an ISBN-10 or ISBN-13 and return it as an ISBN-13, with its ISBN-10
/// where one exists.
///
/// The two forms use different check digits, so conversion recomputes rather
/// than copies. Validators answer rather than panic.
pub fn validate_isbn(value: &str) -> IsbnCheck {
// Hyphen positions vary by publisher, so separators are ignored wholesale.
let mut compact: Vec<char> = value.chars().filter(|ch| *ch != ' ' && *ch != '-').collect();
// An optional "ISBN" label, any case, then an optional colon.
if compact.len() >= 4 {
let label: String = compact[..4].iter().map(|c| c.to_ascii_uppercase()).collect();
if label == "ISBN" {
compact.drain(..4);
}
}
if compact.first() == Some(&':') {
compact.remove(0);
}
if compact.is_empty() {
return invalid("empty");
}
let mut isbn: Vec<char> = Vec::with_capacity(compact.len());
for ch in compact {
if ch.is_ascii_digit() {
isbn.push(ch);
} else if ch == 'X' || ch == 'x' {
isbn.push('X');
} else {
return invalid("bad-character");
}
}
if isbn.len() != 10 && isbn.len() != 13 {
return invalid("bad-length");
}
// X means ten, so it can only be an ISBN-10's check digit.
if let Some(x_at) = isbn.iter().position(|ch| *ch == 'X') {
if isbn.len() == 13 || x_at != 9 {
return invalid("bad-format");
}
}
if isbn.len() == 13 {
let prefix: String = isbn[..3].iter().collect();
if prefix != "978" && prefix != "979" {
return invalid("bad-prefix");
}
if ean_check(&isbn) != isbn[12] {
return invalid("bad-check-digit");
}
// 979 ISBNs were never ISBN-10s, so they have no ISBN-10 to give back.
let isbn10 = if prefix == "978" {
let mut ten: String = isbn[3..12].iter().collect();
ten.push(isbn10_check(&isbn[3..12]));
Some(ten)
} else {
None
};
return IsbnCheck {
valid: true,
normalised: Some(isbn.iter().collect()),
isbn10,
reason: None,
};
}
if isbn10_check(&isbn) != isbn[9] {
return invalid("bad-check-digit");
}
let mut body: Vec<char> = vec!['9', '7', '8'];
body.extend_from_slice(&isbn[..9]);
let check = ean_check(&body);
body.push(check);
IsbnCheck {
valid: true,
normalised: Some(body.iter().collect()),
isbn10: Some(isbn.iter().collect()),
reason: None,
}
}
/// Object keys are camelCase to match the shared vectors.
pub fn isbn_check_to_value(result: &IsbnCheck) -> 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)),
("isbn10", text(&result.isbn10)),
("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.
isbn_check_to_value(&validate_isbn(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.isbn
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./validation.isbn-1.0.0-rust.fune, or fetch it from a terminal with fune pull validation.isbn@1.0.0:rust.
The whole function, every language, is one file too: validation.isbn-1.0.0.fune, 19,171 bytes, sha256 8172c558a9fcbd41bc83843d50404321a07e6af6e93416743fbf2dbf57cced2f. 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.isbn
after — your function gets the result and the arguments, and returns the final result.
// fune: after validation.isbn
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.isbn --steps.
// fune: step validation.isbn 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 | |
|---|---|---|---|
| an ISBN-10, converted to its ISBN-13 | 0-306-40615-2 | → | valid true, normalised 9780306406157, isbn10 0306406152, reason — |
| the same book as an ISBN-13 | 978-0-306-40615-7 | → | valid true, normalised 9780306406157, isbn10 0306406152, reason — |
| an ISBN-10 whose check digit is X (ten) | 0-8044-2957-X | → | valid true, normalised 9780804429573, isbn10 080442957X, reason — |
| a lower-case x is the same check digit | 080442957x | → | valid true, normalised 9780804429573, isbn10 080442957X, reason — |
| the ISBN-13 of an X book converts back to the X form | 9780804429573 | → | valid true, normalised 9780804429573, isbn10 080442957X, reason — |
| a 979 ISBN has no ISBN-10 | 979-10-90636-07-1 | → | valid true, normalised 9791090636071, isbn10 —, reason — |
| an ISBN prefix and spaces are accepted | ISBN 978 0 306 40615 7 | → | valid true, normalised 9780306406157, isbn10 0306406152, reason — |
| a lower-case prefix with a colon | isbn:0306406152 | → | valid true, normalised 9780306406157, isbn10 0306406152, reason — |
| copying the ISBN-10 check digit onto the ISBN-13 is wrong | 9780306406152 | → | valid false, normalised —, isbn10 —, reason bad-check-digit |
| an ISBN-10 with its last digit off by one | 0306406153 | → | valid false, normalised —, isbn10 —, reason bad-check-digit |
Show the other 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| an ISBN-10 with two digits transposed | 0306046152 | → | valid false, normalised —, isbn10 —, reason bad-check-digit |
| a valid EAN-13 outside Bookland is not an ISBN | 4006381333931 | → | valid false, normalised —, isbn10 —, reason bad-prefix |
| X anywhere but the end is a bad format | 03064X6152 | → | valid false, normalised —, isbn10 —, reason bad-format |
| X is never part of an ISBN-13 | 978030640615X | → | valid false, normalised —, isbn10 —, reason bad-format |
| twelve digits is neither length | 978030640615 | → | valid false, normalised —, isbn10 —, reason bad-length |
| a letter other than X is a bad character | 0-306-4O615-2 | → | valid false, normalised —, isbn10 —, reason bad-character |
| the prefix alone is empty | ISBN | → | valid false, normalised —, isbn10 —, reason empty |
| the empty string | → | valid false, normalised —, isbn10 —, reason empty | |
| a non-string is empty, not an exception | 42 | → | valid false, normalised —, isbn10 —, reason empty |
More from the author
- ISBN-10: weight the ten characters 10, 9, 8 ... 1 and the sum must be a multiple of 11. The check digit can therefore be 10, written `X` (or `x`), and `X` is allowed only in that last position. - ISBN-13: the GS1/EAN-13 rule - weights 1, 3, 1, 3 ... from the left, and the check digit brings the sum to a multiple of 10. The first three digits must be 978 or 979 ("Bookland"); any other EAN-13 is a barcode, not an ISBN.
CONVERSION is where naive code goes wrong: the two check digits are computed differently, so converting means recomputing, never copying the old check digit across. An ISBN-10 becomes `978` + its first nine digits + a fresh EAN check digit. An ISBN-13 has an ISBN-10 only when it starts with 978; `979` ISBNs (issued since the 978 range began running out) have none, and `isbn10` is null for them.
ACCEPTED INPUT: ASCII digits and `X`, with ASCII spaces and hyphens ignored anywhere (hyphen positions depend on the registration group and publisher, so they are not checked), and an optional leading `ISBN` in any case, optionally followed by a colon: `ISBN 978-0-306-40615-7`, `isbn:0306406152`. Forms such as `ISBN-13:` are not recognised.
| reason | meaning | |-------------------|------------------------------------------------------------------| | `empty` | nothing left after removing separators and the prefix, or not a string | | `bad-character` | a character other than a digit, `X`, space or hyphen | | `bad-length` | not 10 or 13 characters | | `bad-format` | an `X` anywhere but the last place of an ISBN-10 | | `bad-prefix` | a 13-digit number that does not start with 978 or 979 | | `bad-check-digit` | the check digit does not match |
The checks run in that order; the first failure is the reason. Validators answer rather than throw.
A valid ISBN is well formed, not necessarily assigned: only the ISBN agency's records say whether a book carries it. Hyphenation (splitting into group, publisher, title and check) needs the International ISBN Agency's range table and is out of scope.
Source: International ISBN Agency, ISBN Users' Manual, 7th edition, sections 5 and 9 (https://www.isbn-international.org/content/isbn-users-manual).
Files
| Path | Bytes |
|---|---|
| README.md | 2,748 |
| impl/python.py | 2,894 |
| impl/rust.rs | 4,274 |
| impl/typescript.ts | 2,957 |
| vectors.json | 3,301 |