Working with Ethereum and other chain assets often involves high-precision numbers, and small mistakes can lead to serious bugs, or worse, lost funds. Below are some guidelines and patterns we recommend following when handling numeric values, especially when interacting with smart contracts or constructing transactions.
🟩 TL;DR: If the blockchain works in uint256, your code should work in bigint 🟩
Rationale: Why We Use bigint Everywhere
Smart contracts (especially on Ethereum and EVM-compatible chains) operate on fixed-width integers, most commonly uint256. A uint256 is an unsigned 256-bit integer, capable of storing values from 0
up to 2^256 - 1
. This represents an extremely large range, far beyond what JavaScript's native number type can safely represent.
When interacting with these contracts, whether parsing return values, constructing calldata, or calculating gas and slippage, you are almost always dealing with values in this format.
TypeScript's bigint is the closest native representation of uint256. By using bigint for all calculations, you get the following benefits:
- ✅ Full compatibility with smart contract values
- ✅ No loss of precision (unlike number or float)
- ✅ Consistent and predictable behavior with large values (e.g. token balances in wei)
Keeping all calculations in bigint allows you to mirror how Solidity does math on-chain. This helps avoid bugs, rounding errors, and unexpected behavior, especially when dealing with slippage, fee calculations, or token transfers.
Different number systems in Typescript
In Typescript, we will typically encounter three main numeric types: number
, bigint
, and BigNumber
.
While they may seem similar at first glance, they differ significantly, especially when it comes to storage and precision:
-
number
in JavaScript is a 64-bit double precision floating-point value (IEEE 754). It can only safely store integers up to2^53 - 1
(~9 quadrillion). Anything beyond that risks silent precision loss. -
bigint
is a built-in JavaScript primitive introduced in ES2020 that can represent arbitrarily large integers, limited only by available memory. Unlikenumber
, it doesn’t use floating-point representation, so it can precisely store and operate on extremely large integers, including values well beyond the safe range ofnumber
. -
BigNumber
(from libraries like ethers.js) is not a nativenumber
type. It’s a wrapper class for string-based math, mainly used in environments wherebigint
is unavailable or when interoperating with JSON. BigNumber is safe, but slower, and must be explicitly converted forbigint
-based math. -
Solidity’s
uint256
is a fixed-width, 256-bit unsigned integer. It maps naturally to TypeScript'sbigint
, but not tonumber
. Usingnumber
will almost always break with large values.
General Rules of Thumb
BigInt first, BigInt always
Convert values to bigint as early as possible and do all calculations in bigint. Avoid using number unless you're working with small, UI-only values where precision doesn't matter. Only convert to string when serializing, logging, or outputting values. This helps avoid subtle bugs and keeps things compatible with smart contracts.
// ✅ Good: convert to bigint early and stay bigint
const amountBigInt = BigInt('123456789123456789');
const doubledCorrect = amountBigInt * 2n;
console.log(doubledCorrect.toString());
// 246913578246913578 — accurate
// ❌ Bad: using number early causes precision loss
const amountStr = '123456789123456789'; // 18-decimal token amount
const amountNumber = Number(amountStr); // loses precision!
const doubled = amountNumber * 2;
console.log(doubled);
// 246913578246913570 — wrong! should be exact but isn't
Avoid mixing BigInt with ethers.BigNumber unless you have a good reason. Convert explicitly using .toBigInt() or BigNumber.from(bigint.toString()).
Multiply before you divide
Order matters - When doing multi-step calculations, always multiply before dividing. Division truncates precision, so scaling up first helps preserve accuracy. For example, use (amount _ rate) / base instead of (amount / base) _ rate.
// ✅ Correct: multiply first, then divide
const amount = 123456789123456789n;
const feeBps = 250n; // 2.5% in basis points
const feeCorrect = (amount * feeBps) / 10000n;
console.log(feeCorrect.toString());
// 3086419728086419n
// ❌ Wrong: divide first, then multiply
const feeWrong = (amount / 10000n) * feeBps;
console.log(feeWrong.toString()); // 3086419728086250n
console.log('Off by:', (feeCorrect - feeWrong).toString());
// Off by: 169 wei!
Percentages and decimals ≠ floats
Never rely on float math when handling things like slippage, fees, or percentages.
For something like 0.5%, convert it to basis points (bps) by multiplying by 100nand then using a slippage scale of 10_000n
for mathematical operations entirely on that integer. This gives you an accurate 50n to use in calculations without any floating-point errors.
Basis points are a unit equal to 0.01 percent, or one one-hundredth of a percent. They are used in finance, crypto, and math to represent small percentage changes precisely, especially for things like fees, interest rates, and slippage.
Here is a comprehensive example script showcasing the correct function one should use, vs a raw calculation (even with bigints!) where division is applied before multiplication:
/**
* Basis point scale used to represent percentages with 2 decimal digits of precision.
* 1% = 100 basis points → 10000 = 100.00%
*/
const SLIPPAGE_BPS_SCALE = 10_000n;
/**
* Validates slippage tolerance value and throws error if invalid.
*
* @param slippageTolerance - Slippage percentage (e.g., 0.5 for 0.5%)
* @throws Error if value is negative or ≥ 100
*/
const validateSlippageTolerance = (slippageTolerance: number): void => {
if (slippageTolerance < 0 || slippageTolerance >= 100) {
throw new Error(`Invalid slippage tolerance: ${slippageTolerance}`);
}
};
/**
* Calculates the minimum acceptable output amount after applying slippage tolerance.
*
* @param rawAmount - Full amount before slippage (e.g., from a quote)
* @param slippageTolerance - Slippage % with up to 2 decimal places (e.g., 0.5)
* @returns Adjusted minimum amount as bigint
*/
function calculateSlippageAdjustedMinAmountToBigInt(
rawAmount: bigint,
slippageTolerance: number
): bigint {
if (slippageTolerance === 0) {
return rawAmount;
}
validateSlippageTolerance(slippageTolerance);
const slippageBasisPoints = BigInt(Math.trunc(slippageTolerance * 100)); // Convert to basis points
const slippageMultiplier = SLIPPAGE_BPS_SCALE - slippageBasisPoints;
const multiplied = rawAmount * slippageMultiplier;
const adjustedAmount = multiplied / SLIPPAGE_BPS_SCALE;
return adjustedAmount;
}
// -------------------------------- Example usage --------------------------------
const amount = 123_456_789_123_456_789n;
const slippage = 0.5;
const minAmountOut = calculateSlippageAdjustedMinAmountToBigInt(amount, slippage);
console.log('✅ Correct (via function):', minAmountOut.toString());
//✅ Correct (via function): 122839505177839505
const slippageBps = 50n; // 0.5% = 50 basis points
const wrongMinAmount = (amount / 10_000n) * (10_000n - slippageBps);
console.log('❌ Wrong:', wrongMinAmount.toString());
// ❌ Wrong: 122839505177832750
console.log(
'❌ Division before multiplication error (wei):',
(minAmountOut - wrongMinAmount).toString()
);
// ❌ Division before multiplication error (wei): 6755
// -------------------------------- Example usage 2 --------------------------------
const amount2 = 123_456_789_123_456_789n;
const slippage2 = 2.7;
const minAmountOutCorrect = calculateSlippageAdjustedMinAmountToBigInt(amount2, slippage2);
console.log('✅ Correct (bigint):', minAmountOutCorrect.toString());
// ✅ Correct (bigint): 120123455817123455
const floatMultiplier = 1 - slippage2 / 100; // 0.995
const minAmountOutFloat = BigInt(Math.floor(Number(amount2) * floatMultiplier));
console.log('❌ Wrong (float):', minAmountOutFloat.toString());
// ❌ Wrong (float): 120123455817123440
console.log('❌ Float math error (wei):', (minAmountOutCorrect - minAmountOutFloat).toString());
//❌ Float math error (wei): 15
Decimal Awareness
Watch out for too many digits after the dot
If you see a number like:
0.000000000000000123
Take a step back.
More than 18 digits after the decimal point is usually a red flag 🚩 and means something went wrong in the conversion or unit handling. EVM tokens use fixed decimal places and anything beyond that is likely incorrect.
Rule of thumb: ETH = 18 decimals, SOL = 9, USDC/USDT = 6. always convert to the correct base unit before doing calculations.
You must check the decimals of each token before you interact with it. Never assume a token on Ethereum has 18 decimals even if that’s usually the default case.
Ex: 0.000001
USDC → use parseUnit("0.000001", "6")
to get 1n
. don’t just YOLO and hope for the best 🙅
Always check the token decimals
Each token has a defined decimal precision and you need to respect that when formatting, parsing, or serializing amounts. ETH has 18 decimals, SOL has 9, and USDC/USDT have 6. If you convert human-readable numbers into base units or vice versa, make sure you're applying the correct power of 10. Getting this wrong is a common source of bugs.
Avoid using 10**decimals
since its risky with big-numbers - always use battle tested utility functions.
import { parseUnits } from "ethers";
// ✅ Safe: parseUnits handles all precision correctly — no surprises
const input = "0.123456789123456789"; // full 18-decimal ETH amount
const safe = parseUnits(input, 18);
console.log(safe.toString()); // 123456789123456789n — perfect
// ⚠️ Risky: parseFloat will "work" — until it doesn’t
const risky = BigInt(parseFloat(input) \* 10 \*\* 18);
console.log(risky.toString()); // 123456789123456784n — off by 5 wei
Another Example using gas:
// Convert 5 gwei to wei
import { parseUnits } from "ethers";
const gasPrice = parseUnits("5", "gwei");
How to Find a Token’s Decimals on Etherscan
- Go to etherscan.io (or an equivalent scanner)
- Paste the token contract address into the search bar
- Click on the Contract tab
- Scroll to “Read Contract” (or “Read as Proxy” if it's a proxy contract)
- Look for the decimals method and click to expand it
- The returned value is the token’s on-chain decimals (e.g. 6 for USDC, 18 for most ERC-20s - but not always! ⚠️)
This gives you the most accurate value straight from the blockchain. Always trust this over docs or third-party sources.
Serialization and Formatting
Strip trailing zeros
Once you're done with calculations and are converting values to strings for display or JSON, remove unnecessary trailing zeros. "100.000000" can just be "100". Clean output is easier to read and avoids confusion.
const removeTrailingZeros = (value: string): string => {
return value.replace(/\.0+$/, '');
};
const rawValue1 = '123.450000';
const rawValue2 = '100.0000';
const rawValue3 = '42.0';
console.log(removeTrailingZeros(rawValue1)); // "123.45"
console.log(removeTrailingZeros(rawValue2)); // "100"
console.log(removeTrailingZeros(rawValue3)); // "42"
Pad with intention
When padding values for fixed-width hex strings or byte arrays, don’t hardcode lengths or magic numbers. Use named helpers or constants to keep everything predictable and consistent. Proper padding is critical when encoding calldata or ABI-compatible values.
Smart contracts expect fixed-size values like bytes32 (32 bytes = 64 hex chars). Avoid hardcoding .padStart(64, "0")
— use a helper to make padding predictable and readable.
// Pads a hex string to a fixed byte length (e.g. 32 for bytes32)
const padHexToBytes = (value: string, byteLength: number): string => {
const hex = value.replace(/^0x/, "");
return `0x${hex.padStart(byteLength _ 2, "0")}`;
};
// Example: pad to bytes32
const raw = "abc123";
const padded = padHexToBytes(raw, 32);
console.log(padded);
// → 0x0000000000000000000000000000000000000000000000000000000000abc123
Always strip the 0x prefix before padding manually
When preparing hex strings for fixed-width values (e.g. bytes32), the 0x prefix is not counted in the byte length — it’s just a display marker. Failing to remove it before .padStart(...) will result in incorrectly sized output.
Example:
// ✅ Correct: remove 0x before padding, add it back after
const clean = value.replace(/^0x/, '');
const padded = `0x${clean.padStart(64, '0')}`;
// ❌ Incorrect: padding with 0x included
const wrong = value.padStart(64, '0');
// includes "0x", total length wrong
Things to Avoid
Don’t cast BigInt to number unless it’s safe
If you do Number(bigint) on a large value, you'll silently lose precision. Only do this as a last step when you're certain the value is small enough to fit safely in a JS number. For anything financial or on-chain, keep it bigint.
// ❌ Unsafe: precision lost silently when converting bigint to float
const big = 123456789123456789123456789n;
// Convert to float
const unsafe = Number(big);
console.log("⚠️ As number (scientific):", unsafe);
// 1.2345678912345678e+26 — precision lost
console.log("⚠️ As string:", unsafe.toString());
// '1.2345678912345679e+26' — notice the rounding error ⚠️⚠️⚠️
// Convert back to bigint (loss is permanent)
const backToBigInt = BigInt(unsafe);
console.log("❌ Converted back to bigint:", backToBigInt.toString());
// 1234567891234567913377628160 — not equal to original!
console.log((big - backToBigInt).toString())
// off by 2214306027! ⚠️
// Do math on unsafe float
const multiplied = unsafe _ 10;
console.log("🔁 Multiplied float:", multiplied);
// e.g., 1.2345678912345679e+27
const floatToBigIntAgain = BigInt(multiplied);
console.log("❌ Multiplied then back to bigint:", floatToBigIntAgain.toString());
// 1234567891234567913377628160 — error now scaled up!
// Final comparison
console.log("🧮 Original bigint:", big.toString());
// 123456789123456789123456789
console.log("❌ After float roundtrip:", backToBigInt.toString());
// 123456789123456791337762816
console.log("❌ After float _ 10 round:", floatToBigIntAgain.toString());
// 1234567891234567913377628160
Don’t trust float math or float-based libraries
Avoid any math library that defaults to floating-point calculations! Floats might seem fine in dev or on small amounts, but they will break under large values or edge cases. Always prefer integer-safe libraries or write your own utilities using bigint.
import { parseUnits } from "ethers";
const floatStringInput = "0.123456789123456789";
// ✅ Correct: use string input with parseUnits for exact bigint conversion
const exactResult = BigInt(parseUnits(floatStringInput, 18));
console.log("✅ parseUnits-style:", exactResult.toString());
// 123456789123456789 — exact
// ❌ Incorrect: converting string to float loses precision
const floatResult = BigInt(Number(floatStringInput) _ 1e18); // simulate parseUnits with float
console.log("❌ Float-based:", floatResult.toString());
// 123456789123456784 — imprecise!
// ------------------------------------------
// 🧪 Bonus: demonstration of JavaScript float issues
// ------------------------------------------
const unsafeFloat = 0.123456789123456789;
console.log("⚠️ Given float:", unsafeFloat);
// Logs: 0.12345678912345678 — rounded, last digit already lost!
// ❌ Still unsafe: converting float to string and passing to parseUnits
const unsafeWithEthers = parseUnits(unsafeFloat.toString(), 18);
console.log("⚠️ parseUnits(float.toString()):", unsafeWithEthers.toString());
// 123456789123456780 — off by 9 wei!
// ❌ More float math failure: multiply before converting to BigInt
const multiplied = unsafeFloat _ 1e18;
console.log("⚠️ Multiplied float:", multiplied);
// 123456789123456780 — again, off by 9 wei!
const bigintFromFloat = BigInt(multiplied);
console.log("⚠️ BigInt from float:", bigintFromFloat);
// 123456789123456784 — wrong result carried into bigint
// ✅ Safe again: always use string to preserve precision
const safeAgain = BigInt(parseUnits("0.123456789123456789", 18));
console.log("✅ Safe again:", safeAgain.toString());
// 123456789123456789 — exact
Bonus Tips
Use ethers.js’s parseUnits and formatUnits where appropriate
These functions help convert between human-readable and base-unit amounts, but always check you're passing the correct decimal value. For example, USDC should use 6 decimals, not 18 like ETH. Don’t assume default behavior.
Abstract repeated patterns into helpers Instead of writing ad hoc math everywhere, extract common operations like applying slippage, calculating percentages, or converting between units. This helps reduce bugs and makes the codebase easier to maintain.
// 100% in basis points
const SLIPPAGE_BPS_DENOMINATOR = 10_000n;
/**
* Pads a hex string to a fixed byte length (e.g. 32 for bytes32)
* @param value The hex string to pad (without 0x)
* @param byteLength The desired byte length (e.g. 32)
* @returns The padded hex string with 0x prefix
*/
function applySlippage(amount: bigint, slippageBps: bigint = 0n): bigint {
const multiplier = SLIPPAGE_BPS_DENOMINATOR - slippageBps;
return (amount * multiplier) / SLIPPAGE_BPS_DENOMINATOR;
}
const amount = 100n;
const slippage1 = applySlippage(amount, 1n);
const slippage0 = applySlippage(amount, 0n);
const slippageMax = applySlippage(amount, 9999n);
console.log(slippage1);
// 99.999% of 100 = 99.999 → truncated to 99n
console.log(slippage0);
// No slippage → 100% of 100 = 100n
console.log(slippageMax);
// Keeps just 0.01% of 100 = 0.01 → truncated to 0n
Use Ethereum checksums for addresses where needed
When displaying or validating Ethereum addresses, use EIP-55 checksums to detect typos and prevent issues from lowercase-only or mixed-case errors. This is especially helpful when working with user inputs, logs, or UI copy-paste scenarios.
import { getAddress, isAddress } from 'ethers';
// Validate and convert to checksummed form
const addr1 = '0xa0b86a33e6776c0c52b8b87e6b1e4f4b8a5e8c9d';
if (isAddress(addr1)) {
console.log(getAddress(addr1));
}
// → "0xa0b86a33E6776C0C52b8B87E6b1E4f4B8A5e8C9D"
const addr2 = '0x1234567890ABCDEF1234567890ABCDEF12345678';
if (isAddress(addr2)) {
console.log(getAddress(addr2));
}
// → "0x1234567890AbcdEF1234567890aBcdef12345678"
Use hex constants and compute values
When working with known bit limits (like uint256 max), use the hex literal form and compute the value using BigInt(...) instead of hardcoding the decimal version - it's easier to verify and avoids typos.
const UINT256_MAX_HEX = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
const UINT256_MAX = BigInt(UINT256_MAX_HEX);
console.log(UINT256_MAX.toString());
// → "115792089237316195423570985008687907853269984665640564039457584007913129639935"
This is especially useful when:
- Defining max token allowance values
- Working with bitmask operations
- Creating test cases for overflow/underflow
If possible avoid hardcoding the integer version - it is unreadable and error-prone.
Define and reuse well-known addresses
For readability and consistency, define constants for known Ethereum addresses like the zero address, burn address, and system contracts.
/**
* Checks if two Ethereum addresses are equal (case-insensitive).
* @param a First address
* @param b Second address
* @returns true if equal, false otherwise
*/
function isSameAddress(a: string, b: string): boolean {
return a.trim().toLowerCase() === b.trim().toLowerCase();
}
// Usage example:
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
const DEAD_ADDRESS = '0x000000000000000000000000000000000000dEaD';
console.log(isSameAddress(DEAD_ADDRESS, ZERO_ADDRESS)); // false