Lessons 1/4 · 15 min
TypeScript and amounts: not losing a digit
A transfer form that lies
A developer writes a transfer form. The user types an amount, the code converts it to a number, sends it. Everything works in tests with small amounts. Yet in JavaScript, 0.1 + 0.2 gives 0.30000000000000004, and a large amount can be rounded with no message at all. When it is money, a silent error is the worst kind.
TypeScript is JavaScript with types added: labels (number, text, list…) checked by a compiler before execution. tsc --noEmit runs that check alone. Types disappear at run time: they catch bugs while you write, but do not protect against malformed data arriving from the network. Vite is the tool that serves the page during development and bundles it for production; it turns TypeScript into JavaScript without checking it, so type checking remains a step you run yourself.
The number trap
An ordinary JavaScript number is a 64-bit floating-point number. Integers are exact there only up to 2^53 − 1 = 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER). Yet many chains count in very small units: a token with 18 decimals is worth 10^18 units, far beyond.
Example. A balance of 1,000,000,000,000,000,001 units (one token plus one unit):
console.log(Number(1000000000000000001n)); // 1000000000000000000
console.log(1000000000000000001n); // 1000000000000000001nWith Number, the extra unit vanished, with no error. The `bigint` type (arbitrary-size integers, suffix n) keeps everything. Practical rules:
- Represent an amount in the smallest unit as a
bigint, never as a decimalnumber. - Mixing
bigintandnumberin an operation throws an error (TypeError), which is a good signal. JSON.stringifyfails on abigint: convert to text withtoString()to send or store it.- Convert to "readable" decimal text only at the last moment, for display.
Converting what the user types
Avoid floating-point arithmetic: cut the string.
function toUnits(text: string, decimals: number): bigint {
if (!/^\d+(\.\d*)?$/.test(text)) throw new Error("Invalid amount");
const [whole, frac = ""] = text.split(".");
if (frac.length > decimals) throw new Error("Too many decimals");
return BigInt(whole + frac.padEnd(decimals, "0"));
}
// toUnits("1.5", 18) -> 1500000000000000000n
// toUnits("1.5.5", 18) -> error (the whole string is validated before any splitting)First validate the whole string with a regular expression: otherwise a split that keeps only the first two pieces would accept "1.5.5" and silently read it as 1.5. The "too many decimals" case is refused rather than rounded: better a message than an amount different from what the user thinks they send. (bigint literals such as 1n need a compilation target of ES2020 or later, which is the case for recent Vite templates.)
Types that describe states
Type unions make it impossible to combine just anything:
type Wallet =
| { kind: "disconnected" }
| { kind: "connected"; account: string; chainId: number };In the disconnected state there is no account field: the compiler refuses to read it. This is the basis of the next two lessons. Also turn on strict mode ("strict": true in tsconfig.json): without it many of these guarantees evaporate.
What this does NOT guarantee
- Types are a promise made to the compiler. A JSON response received from the node is of unknown type until you have checked it: writing
as Balancesilences the compiler, not the problem. - An exact amount is not a reasonable amount: also validate the ceiling, the available balance and the address.
- Form validation protects nothing on the server or chain side: the chain will accept or refuse anyway.
Recap: amounts as bigint in the smallest unit, text cut rather than computed, type unions for states.
