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.
def 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
@dataclass(frozen=True)
class IsbnCheck:
"""The result of checking an ISBN. Every string is null when valid is false; reason is null when it is true."""
valid: bool
#: the ISBN-13, digits only
normalised: Optional[str]
#: the ISBN-10 equivalent; null for 979 ISBNs, which have none
isbn10: Optional[str]
#: empty, bad-character, bad-length, bad-format, bad-prefix or bad-check-digit
reason: Optional[str]
Your code names it in one line, in the file that uses it
from fune.validation.isbn import validate_isbn # validation.isbn@^1
from typing import List, Optional
from .validation_isbn_types import IsbnCheck
def _invalid(reason: str) -> IsbnCheck:
return IsbnCheck(valid=False, normalised=None, isbn10=None, reason=reason)
def _ean_check(twelve: str) -> str:
"""The EAN-13 check digit for twelve digits: weights 1, 3, 1, 3 ... from the left."""
total = sum((ord(twelve[i]) - 48) * (1 if i % 2 == 0 else 3) for i in range(12))
return str((10 - total % 10) % 10)
def _isbn10_check(nine: str) -> str:
"""The ISBN-10 check character for nine digits: weights 10 down to 2, mod 11, 10 written X."""
total = sum((ord(nine[i]) - 48) * (10 - i) for i in range(9))
check = (11 - total % 11) % 11
return "X" if check == 10 else str(check)
def validate_isbn(value: str) -> IsbnCheck:
"""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 raise.
"""
if not isinstance(value, str):
return _invalid("empty")
# Hyphen positions vary by publisher, so separators are ignored wholesale.
compact = "".join(ch for ch in value if ch != " " and ch != "-")
# An optional "ISBN" label, any case, then an optional colon. Folded by
# hand: str.upper() is Unicode-aware and the Rust sibling is not.
if len(compact) >= 4:
label = "".join(chr(ord(c) - 32) if "a" <= c <= "z" else c for c in compact[:4])
if label == "ISBN":
compact = compact[4:]
if compact.startswith(":"):
compact = compact[1:]
if not compact:
return _invalid("empty")
out: List[str] = []
for ch in compact:
if "0" <= ch <= "9":
out.append(ch)
elif ch == "X" or ch == "x":
out.append("X")
else:
return _invalid("bad-character")
isbn = "".join(out)
if len(isbn) != 10 and len(isbn) != 13:
return _invalid("bad-length")
# X means ten, so it can only be an ISBN-10's check digit.
x_at = isbn.find("X")
if x_at != -1 and (len(isbn) == 13 or x_at != 9):
return _invalid("bad-format")
if len(isbn) == 13:
prefix = isbn[:3]
if prefix != "978" and 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.
isbn10: Optional[str] = isbn[3:12] + _isbn10_check(isbn[3:12]) if prefix == "978" else None
return IsbnCheck(valid=True, normalised=isbn, isbn10=isbn10, reason=None)
if _isbn10_check(isbn) != isbn[9]:
return _invalid("bad-check-digit")
body = "978" + isbn[:9]
return IsbnCheck(valid=True, normalised=body + _ean_check(body), isbn10=isbn, reason=None)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python 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.isbn
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./validation.isbn-1.0.0-python.fune, or fetch it from a terminal with fune pull validation.isbn@1.0.0:python.
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 |