Munchable is a barcode scanner for digestive conditions: point the camera at a packet, get an answer on the device. Everything downstream of the camera depends on one string of digits being right, and on the same pack producing the same string every time.
The part of that I did not expect to be hard is the eight-digit case. A scanner that hands you eight digits has given you a code from one of two families with two different check-digit arithmetics, and nothing in the decode says which. Getting that wrong does not produce an error. It produces "we don't have this one" for a product we do have, which to the person holding the packet is indistinguishable from the app being useless.
You can watch the phases this feeds on munchable.app, where the landing page animates the scan states, and you can run the real thing in a browser at app.munchable.app (the phone app's own codebase, served as a single page app).
One sum, read right to left
Every GTIN ends with a check digit computed from the digits in front of it. The weights alternate 3 and 1, starting from the digit immediately before the check digit and working left:
/**
* The GS1 check-digit test shared by every GTIN length: weights alternate
* 3,1,3,1… reading right-to-left from the digit before the check digit.
*/
function hasValidCheckDigit(digits: string): boolean {
let sum = 0;
for (let i = digits.length - 2; i >= 0; i--) {
const weight = (digits.length - 2 - i) % 2 === 0 ? 3 : 1;
sum += Number(digits[i]) * weight;
}
const expected = (10 - (sum % 10)) % 10;
return expected === Number(digits[digits.length - 1]);
}
Writing the weight as a function of distance from the end, rather than of the index, is the whole trick to making one function serve EAN-8, UPC-A, EAN-13 and GTIN-14. The parity of a position counted from the left changes with the length; counted from the right it does not.
Running this on device costs nothing and it is the cheapest quality gate in the app. A misread that flips or drops a digit almost always fails it, which turns a dead end into a retry with the camera still on the label.
UPC-E is a UPC-A with the zeros squeezed out
A UPC-E label is a compressed UPC-A. What the scanner reports is eight digits: a number-system digit (0 or 1), six data digits, and a check digit. Two things follow from that, and both of them were wrong in our app at one point.
The first is that the check digit belongs to the expanded UPC-A. It is a different sum over different digits. Run the EAN-8 sum over the eight compressed digits and you are testing a number against a check digit that was never computed from it.
The second is that the product is catalogued under the expanded GTIN, so a lookup on the compressed digits misses a row we hold.
Which zeros come back is decided by the last data digit:
function expandUpcE(digits: string): string | null {
if (digits.length !== 8) return null;
const n = digits[0];
if (n !== '0' && n !== '1') return null; // UPC-E carries number system 0 or 1
const [s1, s2, s3, s4, s5, s6] = digits.slice(1, 7);
const check = digits[7];
switch (s6) {
case '0':
case '1':
case '2':
return `${n}${s1}${s2}${s6}0000${s3}${s4}${s5}${check}`;
case '3':
return `${n}${s1}${s2}${s3}00000${s4}${s5}${check}`;
case '4':
return `${n}${s1}${s2}${s3}${s4}00000${s5}${check}`;
default:
return `${n}${s1}${s2}${s3}${s4}${s5}0000${s6}${check}`;
}
}
Note that the last data digit does double duty in the final three cases: it both selects the expansion and reappears as a digit of the result. That is why the zero run is five digits long in one branch and four in another.
The number: 58 per cent are ambiguous
The interesting question is how much damage a validator does if it only knows the EAN-8 sum. That is countable rather than arguable, because the UPC-E space is small: two number-system digits and six data digits is exactly 2,000,000 codes. For each one you can build the expanded body, compute the real check digit, and then ask whether those eight digits happen to satisfy the EAN-8 sum as well.
def check(d):
s = 0
for i in range(len(d) - 2, -1, -1):
s += int(d[i]) * (3 if (len(d) - 2 - i) % 2 == 0 else 1)
return (10 - (s % 10)) % 10
def body(n, s):
s1, s2, s3, s4, s5, s6 = s
if s6 in '012': return f"{n}{s1}{s2}{s6}0000{s3}{s4}{s5}"
if s6 == '3': return f"{n}{s1}{s2}{s3}00000{s4}{s5}"
if s6 == '4': return f"{n}{s1}{s2}{s3}{s4}00000{s5}"
return f"{n}{s1}{s2}{s3}{s4}{s5}0000{s6}"
total = passes = 0
for n in '01':
for v in range(10 ** 6):
s = f"{v:06d}"
code = f"{n}{s}{check(body(n, s) + '0')}"
total += 1
passes += int(code[-1]) == check(code)
print(total, passes, f"{(total - passes) / total:.2%}")
# 2000000 1160000 42.00%
So 1,160,000 of the 2,000,000 valid UPC-E codes also satisfy the EAN-8 sum, and 840,000 do not. A scanner gate that validates eight digits as EAN-8 and nothing else refuses exactly 42 per cent of perfectly good UPC-E reads.
That 58 per cent is far higher than the one-in-ten you would get from a random eight-digit string, and the reason is the structural zeros: the expansion inserts four or five zeros, which contribute nothing to the expanded sum, so the two sums end up correlated rather than independent.
Two decisions fall out of that number
Validation accepts either family. An eight-digit read is tested twice, as an EAN-8 and as a UPC-E against its expansion, and either pass is enough:
export function isValidGtin(raw: string): boolean {
const digits = raw.replace(/\D/g, '');
if (digits.length !== 8 && digits.length !== 12 && digits.length !== 13 && digits.length !== 14) {
return false;
}
// All zeros passes the check-digit test but is never a real product.
if (/^0+$/.test(digits)) return false;
if (hasValidCheckDigit(digits)) return true;
const upcA = digits.length === 8 ? expandUpcE(digits) : null;
return upcA !== null && hasValidCheckDigit(upcA);
}
The asymmetry of the failure modes decides this. A small pack that reads as if the camera were dead, with no feedback and no lookup, is far worse than a rare bad decode reaching a lookup that simply misses.
Normalisation prefers the literal read. Every lookup, cache key and contribution uses one form, thirteen digits for the UPC and EAN family, because iOS hands over UPC-A as twelve digits while EAN-13 carries the leading zero, and two forms of the same pack means two cache entries and two chances to miss:
export function normalizeBarcode(raw: string): string {
const digits = raw.replace(/\D/g, '');
if (digits.length === 12) return `0${digits}`; // UPC-A → EAN-13
if (digits.length === 8 && !hasValidCheckDigit(digits)) {
const upcA = expandUpcE(digits);
if (upcA && hasValidCheckDigit(upcA)) return `0${upcA}`; // UPC-E → UPC-A → EAN-13
}
return digits;
}
Look at the condition on the eight-digit branch. Expansion is attempted only when the literal reading has already failed. For the 58 per cent of UPC-E codes that satisfy both sums, this keeps the eight digits the scanner actually read.
That is deliberate, and it is the one call in here that took real thought. A genuine EAN-8 is catalogued as exactly those eight digits, so rewriting a code that passes its own check requires the literal reading to be provably wrong first. Ambiguity resolves in favour of what came off the label; only a code that cannot be what it claims to be gets rewritten. The function is also idempotent, so normalising an already normalised code is a no-op, which matters when the same string passes through a cache key, a contribution payload and a history row.
Why this lives in a module that imports nothing
All of the above sits in one file with no imports at all, next to the gate that decides what the app does with a read. That is not tidiness. The module that talks to the network reaches storage and the auth client through its imports, and neither of those loads outside React Native, so anything parked there can only be tested by scanning a real pack with a real phone. Arithmetic over 2,000,000 codes is exactly the kind of thing a plain node --test run should be checking, and it cannot if the import graph drags a native module in.
See it working
- app.munchable.app runs the same codebase as the phone builds in a browser, so you can hand it a barcode and watch the gate accept or ignore it.
- munchable.app is the landing page, where the scan sequence is animated rather than filmed, including the hand-counted barcode digits.
- munchable.app/answers is what a successful read eventually reaches: one page per ingredient and condition, such as is inulin low FODMAP.
If you are validating GTINs anywhere, the test worth writing today is the eight-digit one. Everything else in the family has a single sum and a single answer, and eight digits has two of each.
Top comments (0)