A calculator can have a correct multiplication and still give misleading results. A parser might silently accept a unit suffix, a percentage might be interpreted on the wrong scale, or an intermediate value might be rounded before the final total.
This tutorial builds a small JavaScript charging-cost example with three separate responsibilities: validate text, calculate with explicit units, and format the result. The prices and consumption below are hypothetical test fixtures. This is a programming exercise, not a tariff comparison or a financial recommendation.
Write the model before the function
Use these inputs: distance in km, consumption in kWh per 100 km, a home-charging share in percent, two prices in EUR per kWh, and an energy markup in percent. The home share refers to energy, not the number of charging sessions. Each price applies to its corresponding share of billed energy.
Here, “10% energy markup” explicitly means multiplying baseline energy by 1.10. It is an additive convention. It does not mean dividing by 0.90, which would model a 10% loss measured against energy drawn from the supply. Those assumptions produce different answers; a label such as “losses” without a definition is insufficient.
The model is:
base kWh = distance km / 100 × consumption kWh/100 km
billed kWh = base kWh × (1 + markup percent / 100)
weighted EUR/kWh = home share × home price
+ (1 − home share) × public price
cost EUR = billed kWh × weighted EUR/kWh
For 1,000 km, 18 kWh/100 km, 60% home energy, EUR 0.32/kWh at home, EUR 0.59/kWh publicly, and 10% markup, the decimal arithmetic is 180 × 1.10 × 0.428 = 84.744 EUR. The two-decimal display should be EUR 84.74.
This intentionally omits fixed fees, session fees, time-based charges, tiered rates, and location-dependent rules. If a product needs those features, extend the model and fixtures explicitly.
Define an input grammar
The parser accepts strings containing an unsigned integer, optionally followed by a decimal point and one to six fractional digits. It rejects whitespace, grouping separators, exponent notation, signs, unit suffixes, leading zeroes such as 01, and incomplete decimals such as .5 or 1.. Zero itself is valid.
That grammar is deliberately narrow. The interface should say “Use a decimal point; no thousands separators” and show an example such as 0.32. Do not guess whether 1,000 means one thousand or one with three decimal places. A localized interface needs a separate, explicitly specified parsing policy; changing the output locale does not implement one.
Validation has two stages. The regular expression checks the whole text; conversion then produces a number. Number.isFinite rejects non-finite results, including overflow from an enormous digit string. Field bounds reject values outside this example's operating range. These bounds are illustrative product decisions, not universal engineering limits.
Keep calculation separate from formatting
Save this as calculator.mjs. It uses no packages. Pass the field strings directly to estimate; missing fields fail validation rather than becoming zero.
export function decimal(text, name, max) {
if (typeof text !== "string" ||
!/^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,6})?$/.test(text)) {
throw new TypeError(`${name}: use digits and an optional decimal point (up to 6 places)`);
}
const value = Number(text);
if (!Number.isFinite(value) || value < 0 || value > max) {
throw new RangeError(`${name}: expected 0 to ${max}`);
}
return value;
}
export function estimate(input) {
const distanceKm = decimal(input.distanceKm, "distanceKm", 1_000_000);
const kWhPer100Km = decimal(input.kWhPer100Km, "kWhPer100Km", 1_000);
const homePercent = decimal(input.homePercent, "homePercent", 100);
const homeEurPerKWh = decimal(input.homeEurPerKWh, "homeEurPerKWh", 100);
const publicEurPerKWh = decimal(input.publicEurPerKWh, "publicEurPerKWh", 100);
const markupPercent = decimal(input.markupPercent, "markupPercent", 100);
const baseKWh = distanceKm / 100 * kWhPer100Km;
const billedKWh = baseKWh * (1 + markupPercent / 100);
const homeShare = homePercent / 100;
const weightedEurPerKWh = homeShare * homeEurPerKWh +
(1 - homeShare) * publicEurPerKWh;
const costEur = billedKWh * weightedEurPerKWh;
return { baseKWh, billedKWh, weightedEurPerKWh, costEur };
}
export const eur = new Intl.NumberFormat("en-IE", {
style: "currency", currency: "EUR",
minimumFractionDigits: 2, maximumFractionDigits: 2
});
The upper bounds are 1,000,000 km, 1,000 kWh/100 km, EUR 100/kWh, and 100% markup; home share is restricted to 0–100%. They keep this example's intermediates finite. A real application should choose limits from its own requirements and explain rejected values near the fields.
For a browser form, associate visible labels and unit hints with each input, use inputmode="decimal" where appropriate, and show validation messages accessibly. A keyboard hint is not validation. If a server uses the submitted inputs, enforce the same contract there too.
Test the assumptions and the boundaries
Save the following as calculator.test.mjs beside the module, then run node calculator.test.mjs. It uses Node's built-in assertion module. The tolerance is an absolute 1e-9 EUR for these small fixtures; it is not a general comparison policy for every numerical application.
import assert from "node:assert/strict";
import { decimal, estimate, eur } from "./calculator.mjs";
const input = {
distanceKm: "1000", kWhPer100Km: "18", homePercent: "60",
homeEurPerKWh: "0.32", publicEurPerKWh: "0.59", markupPercent: "10"
};
const close = (actual, expected) =>
assert.ok(Math.abs(actual - expected) < 1e-9);
const result = estimate(input);
close(result.baseKWh, 180);
close(result.billedKWh, 198);
close(result.costEur, 84.744);
assert.equal(eur.format(result.costEur), "€84.74");
for (const [homePercent, expected] of [["0", 116.82], ["100", 63.36]]) {
close(estimate({ ...input, homePercent }).costEur, expected);
}
close(estimate({ ...input, distanceKm: "0" }).costEur, 0);
close(estimate({ ...input, markupPercent: "0" }).costEur, 77.04);
for (const bad of ["", "18kWh", "0,32", "1,000", "Infinity", "-1", "1e3"]) {
assert.throws(() => decimal(bad, "sample", 100));
}
assert.throws(() => estimate({ ...input, homePercent: "101" }));
console.log("All checks passed", result.costEur, eur.format(result.costEur));
The endpoint cases are useful because they remove one price from the calculation: 0% home gives EUR 116.82, while 100% gives EUR 63.36. Zero distance should give zero energy cost. Zero markup should recover the baseline calculation, EUR 77.04. These checks expose reversed shares and percentage-scale errors more directly than many similar middle-of-range examples.
Expand the suite with zero consumption, both prices zero, each field's upper boundary, a value just above each boundary, omitted fields, excessive fractional digits, and numeric values passed where strings are required. Test malformed text independently from out-of-range numbers so failures identify the contract that broke.
The accompanying example was run with Node v25.8.2. Its fuller local suite also checked those cases, including 24 malformed/type inputs, six upper-bound violations, and all six missing fields. These checks passed. They verify this implementation against the stated model; they do not validate real tariffs or driving consumption.
Understand what the displayed cents mean
JavaScript Number uses IEEE 754 binary floating-point arithmetic. Many decimal fractions are approximations. In the tested run, the raw result was 84.74400000000001, not an exact decimal 84.744; the energy intermediate was 198.00000000000003.
The formatter produces €84.74 without changing the returned numeric value. Do not feed that formatted string back into the calculation or round the weighted price to cents before multiplying. Display rounding cannot recover precision already lost in intermediate rounding.
This approach suits a clearly labeled estimate. It is not a money ledger. Billing or accounting software needs an explicit precision model and rounding rules at the required business boundaries. Integer minor units alone do not settle fractional energy rates or allocation rules. Avoid treating an added Number.EPSILON as a universal currency fix.
The useful design habit is to make the contract visible: input syntax, units, percentage meaning, bounds, and rounding stage. Then the tests can check a named decision instead of merely repeating arithmetic.
References
- MDN: Number — numeric representation and conversion.
- MDN: Number.isFinite — finite-number checks without coercion.
- MDN: Intl.NumberFormat — locale-aware display formatting.
I also run EAutoKompass; the code here is a standalone teaching example rather than the site's production code.
Top comments (0)