Percentages are the math operation programmers re-invent the most. Every checkout flow has them, every dashboard has them, every pricing toggle has them, and yet I still see the same three off-by-one bugs in code reviews every month. Most of them come down to one thing: nobody wrote a small set of named helpers, so every component invents its own.
Here are the five percentage helpers I keep in a percent.ts file. None of them are clever. All of them have saved me at least one bug.
1. percent of value
The one everyone gets right:
export const percentOf = (value: number, percent: number): number =>
(value * percent) / 100;
percentOf(250, 8.5); // 21.25
But having it as a named function means your sales tax calculation reads as percentOf(price, taxRate) instead of (price * taxRate) / 100 repeated in 14 components. Less typing is the smaller win; the bigger one is that any bug you find lives in exactly one place.
The canonical use case is sales tax. The Sales Tax (VAT) Calculator on Equation Solver is the page I open when I cannot remember whether a quoted price is tax-inclusive or tax-exclusive (a surprisingly common ambiguity in invoices).
2. value plus a markup
The one almost everyone gets wrong by inlining the math:
export const addPercent = (value: number, percent: number): number =>
value * (1 + percent / 100);
addPercent(100, 20); // 120
Why a helper for this? Because price + price * 0.2 and price * 1.2 and price + percentOf(price, 20) all produce the same answer most of the time, and developers debate which is most readable in PRs forever. Pick one, name it, move on.
3. value minus a discount
Symmetric, but its own function so the call site is easier to grep:
export const subtractPercent = (value: number, percent: number): number =>
value * (1 - percent / 100);
subtractPercent(100, 25); // 75
Also: if you ever need to restore the original price from a discounted one, the inverse is discounted / (1 - percent / 100), which is one of those "obvious in retrospect" tricks that I have watched seniors fail at on a whiteboard. I keep that as a separate helper too.
4. percentage change between two values
My favorite, because it is what every dashboard "up X% from last month" claim secretly relies on:
export const percentChange = (oldValue: number, newValue: number): number => {
if (oldValue === 0) return Infinity;
return ((newValue - oldValue) / oldValue) * 100;
};
percentChange(80, 100); // 25
percentChange(100, 80); // -20
Note the asymmetry: going from 80 to 100 is a 25% gain, but going from 100 to 80 is only a 20% loss. That asymmetry is the basis of half the misleading marketing claims you have ever read. A 50% discount followed by a 50% markup does not return to the original price; it leaves you 25% lower. Programmers who internalize this become impossible to mislead with charts.
For a clean visualization of this, the Inflation Calculator on Equation Solver shows what a percentage change compounded over many years actually does to purchasing power. It is the cleanest demo I know of why "3% inflation per year" is not a small number.
5. tip and tax in one go
Not a primitive, but the helper I actually open most often, mostly when traveling:
export function billTotal({
subtotal,
taxPercent = 0,
tipPercent = 0,
tipBasis = "preTax",
}: {
subtotal: number;
taxPercent?: number;
tipPercent?: number;
tipBasis?: "preTax" | "postTax";
}) {
const tax = percentOf(subtotal, taxPercent);
const tipBase = tipBasis === "preTax" ? subtotal : subtotal + tax;
const tip = percentOf(tipBase, tipPercent);
return { subtotal, tax, tip, total: subtotal + tax + tip };
}
The tipBasis flag is the part most calculators forget to expose. In the US, tipping on the post-tax total is common but not universal; in many countries it is implicitly pre-tax or simply included. Naming the choice in the API forces the caller to think about it for half a second, which is exactly the right amount of friction.
A small style note
You will notice none of these helpers round. That is on purpose; rounding belongs at the display layer, not at the math layer. If percentOf(0.10, 8.5) is 0.0085, that is the right answer; it is up to your formatter to decide what shows up in the UI. Helpers that round eagerly have caused me more bugs than I can count, especially in cumulative sums where small errors compound.
If there is a sixth helper to add, it is probably a weightedAverage(values, weights) for portfolio-style aggregations. But percentages are the load-bearing five. Once they are named, the bugs go away, and PRs about "is this the right formula?" go from weekly to never.
Top comments (0)