Technology

How to Build a Unit Converter in JavaScript

To build a unit converter in JavaScript, store one conversion factor per unit relative to a single base unit, then convert in two steps: multiply the input by the source unit's factor to reach the base, and divide by the target unit's factor to leave it. That is the whole algorithm — result = value * from.factor / to.factor.

The interesting parts are the ones that pattern does not cover: floating-point error that makes 1 foot equal 12.000000000000002 inches, and temperature scales that have an offset as well as a factor. This guide covers all three.

I maintain a little over 300 unit converters on this site, and every one of them runs on about fifteen lines of logic. If you have been writing a separate if branch for each pair of units — milesToKm, kmToMiles, feetToMeters — you are writing n² functions for a problem that needs n numbers. This guide replaces that with a table.

What you are building

A converter with a value input, a "from" dropdown, a "to" dropdown and a result that updates as you type. No framework, no build step, no dependencies. It works in any browser from the last decade and runs entirely on the client, so nothing the user types leaves their machine.

By the end you will have handled the three things that separate a working converter from a toy: multi-unit conversion without a combinatorial explosion of functions, floating-point cleanup, and offset scales like Fahrenheit.

The core idea: convert through a base unit

The mistake most first attempts make is treating every pair of units as its own problem. Eight length units means 8 × 7 = 56 directional conversions. Write one function each and you have 56 places for a typo to hide.

Instead, pick one unit in the group to be the base and describe every other unit as a multiple of it. For length, the metre is the natural choice because the SI definitions are already written that way. Now any conversion is two operations through that shared pivot, and adding a ninth unit costs you one line instead of sixteen functions.

Approach8 unitsAdding a 9th unit
One function per pair56 functions+16 functions
Factors through a base8 numbers+1 number

Step 1: the conversion data

Each unit needs a display name and a factor: how many base units it is worth. A metre is 1 metre; a kilometre is 1000; an inch is 0.0254.

const UNITS = {
  length: {
    base: 'm',
    units: {
      mm: { name: 'Millimeter', factor: 0.001 },
      cm: { name: 'Centimeter', factor: 0.01 },
      m:  { name: 'Meter',      factor: 1 },
      km: { name: 'Kilometer',  factor: 1000 },
      in: { name: 'Inch',       factor: 0.0254 },
      ft: { name: 'Foot',       factor: 0.3048 },
      yd: { name: 'Yard',       factor: 0.9144 },
      mi: { name: 'Mile',       factor: 1609.344 }
    }
  }
};

Those four imperial figures are not approximations. Since the international yard and pound agreement of 1959 the yard has been defined as exactly 0.9144 m, which fixes the inch at exactly 25.4 mm and the mile at exactly 1609.344 m. Use the exact values and your converter is correct to the limit of the number type; round them to 1.609 and you have baked in an error that grows with every input. The NIST unit conversion reference lists the exact factors for the units you are likely to want.

Step 2: the convert function

function convert(value, from, to, group) {
  const units = UNITS[group].units;
  if (!units[from] || !units[to]) {
    throw new Error(`Unknown unit: ${from} or ${to}`);
  }
  const inBase = value * units[from].factor;
  return inBase / units[to].factor;
}

That is the entire engine. convert(1, 'mi', 'km', 'length') returns 1.609344. convert(5000, 'mm', 'm', 'length') returns 5. The same function handles all 56 directional pairs, and it will handle weight, area and volume too the moment you add those groups to the table.

Step 3: the HTML

<form id="converter">
  <label>Value
    <input type="number" id="value" value="1" step="any" inputmode="decimal">
  </label>
  <label>From <select id="from"></select></label>
  <label>To <select id="to"></select></label>
  <output id="result"></output>
</form>

Two details worth keeping. step="any" stops the browser rejecting decimal input — without it, some browsers treat 1.5 as invalid on a number field whose implied step is 1. And inputmode="decimal" gets phone users the numeric keypad instead of the full QWERTY keyboard, which is the single cheapest mobile usability win available on a form like this.

Step 4: wiring it up

const group = 'length';
const $ = (id) => document.getElementById(id);

// Fill both dropdowns from the same data
function populate() {
  const units = UNITS[group].units;
  for (const select of [$('from'), $('to')]) {
    select.innerHTML = '';
    for (const [code, unit] of Object.entries(units)) {
      const opt = document.createElement('option');
      opt.value = code;
      opt.textContent = `${unit.name} (${code})`;
      select.appendChild(opt);
    }
  }
  $('from').value = 'm';
  $('to').value = 'ft';
}

function render() {
  const value = parseFloat($('value').value);
  if (!isFinite(value)) { $('result').textContent = ''; return; }
  const out = convert(value, $('from').value, $('to').value, group);
  $('result').textContent = `${value} ${$('from').value} = ${tidy(out)} ${$('to').value}`;
}

populate();
$('converter').addEventListener('input', render);
$('converter').addEventListener('change', render);
render();

Listening on the form rather than each field means one listener covers the number box and both dropdowns, including future fields. Calling render() once at the end means the converter shows a result on load instead of an empty box.

Note that the dropdowns are built from the same UNITS object the maths reads. Hand-writing <option> tags in the HTML is how converters end up offering a unit the engine cannot convert.

Step 5: the floating-point problem nobody warns you about

Run the converter and ask it for 1 foot in inches. The correct answer is 12. Here is what JavaScript actually returns:

convert(1, 'ft', 'in', 'length')
// 12.000000000000002

convert(3, 'ft', 'm', 'length')
// 0.9144000000000001

Those are real outputs, not hypotheticals. JavaScript numbers are IEEE 754 doubles, which store values in binary. Decimals like 0.1 and 0.3048 have no exact binary representation, in the same way 1/3 has no exact decimal representation. The classic demonstration is 0.1 + 0.2, which evaluates to 0.30000000000000004.

Publishing 12.000000000000002 inches destroys a user's confidence in the tool instantly. The fix is to round to a sensible number of significant digits at the point of display — and only at the point of display:

function tidy(n, sigDigits = 10) {
  if (!isFinite(n)) return '—';
  return parseFloat(n.toPrecision(sigDigits)).toString();
}

toPrecision(10) gives ten significant digits, which is far more than any practical conversion needs and comfortably inside a double's ~15–17 digits of reliable precision, so the noise disappears while genuine precision survives. Wrapping it in parseFloat then toString strips the trailing zeros that toPrecision leaves behind, turning "12.00000000" into "12".

Use toPrecision, not toFixed. toFixed(4) counts digits after the decimal point, so it prints a perfectly good small number as 0.0000 — converting millimetres to miles would show zero.

Method1 ft in inches1 mm in miles
raw12.0000000000000026.21371192237334e-7
toFixed(4)12.00000.0000
tidy()126.213711922e-7

Step 6: temperature, where the pattern breaks

Everything above assumes a unit is a pure multiple of the base — that zero in one unit is zero in every other. Temperature is not like that. 0 °C is 32 °F, so Fahrenheit has both a scale factor and an offset, and value * factor / factor cannot express it.

Rather than bolt a special case onto the length engine, give temperature its own shape: a pair of functions per unit instead of a number.

const TEMPERATURE = {
  C: { name: 'Celsius',    toBase: c => c,                fromBase: c => c },
  F: { name: 'Fahrenheit', toBase: f => (f - 32) * 5 / 9, fromBase: c => c * 9 / 5 + 32 },
  K: { name: 'Kelvin',     toBase: k => k - 273.15,       fromBase: c => c + 273.15 }
};

function convertTemp(value, from, to) {
  const celsius = TEMPERATURE[from].toBase(value);
  return TEMPERATURE[to].fromBase(celsius);
}

Same two-step shape — into the base, then out of it — but each direction is a function rather than a multiplication. Verify it against the fixed points you already know: convertTemp(100, 'C', 'F') is 212, convertTemp(32, 'F', 'C') is 0, and convertTemp(-40, 'C', 'F') is −40, the one temperature where the two scales agree. If those three pass, your signs are right.

The same offset problem appears in a few other places: gauge versus absolute pressure, and calendar dates. Pure ratio units — length, mass, area, volume, data storage — all work with plain factors. If you want the physical background, our temperature conversion guide works through the scales, and the metric vs imperial guide covers where the imperial definitions came from.

Common mistakes

  • Rounding the factors instead of the output. Storing 1.609 rather than 1.609344 makes every mile conversion wrong by about 21 cm per 100 km, and no amount of display formatting recovers it.
  • Rounding too early. Round once, at display time. Rounding intermediate values compounds the error through every subsequent step.
  • Using toFixed for a general-purpose converter. It is fine when you know the magnitude in advance and wrong the moment you do not.
  • Trusting parseFloat blindly. parseFloat("12abc") returns 12, and parseFloat("") returns NaN. Guard with isFinite before you render, or users will see "NaN" the moment they clear the box.
  • Converting between groups. Nothing in the code above stops someone asking for kilograms in metres. Keep each group's dropdowns populated from that group only.
  • Forgetting the empty state. Decide what an empty input shows before a user finds out for you.

Where to go next

Once the single-group converter works, the natural extensions are all cheap because the data and the logic are separate:

  • More groups. Weight, area, volume and data storage are all pure-factor groups — add them to UNITS and add a group selector. Nothing in convert() changes.
  • A reference table. Loop over the units and show the input converted to all of them at once. Users comparing several units at a glance is a common reason to open a converter at all.
  • Deep links. Read ?from=mi&to=km&value=5 from the URL so a result can be shared.
  • Keyboard and screen-reader support. Put the result in an <output> element, which is announced as a live region by default — one of the reasons it is used in the markup above instead of a <div>.

If you are building this as a portfolio project, the parts worth writing up are the float handling and the temperature refactor, because they show judgement rather than syntax. Keeping the page fast matters too once you add tables and groups — our notes on making a site load quickly apply directly.

You can see the finished pattern in production on the multi-unit converter here, or a single-pair version on the miles to kilometers converter.

Frequently asked questions

How do you convert units in JavaScript without a library?

Store each unit's factor relative to one base unit in an object, then multiply by the source factor and divide by the target factor: value * from.factor / to.factor. A whole category of units needs one small object and one function, so a library is rarely worth the dependency for conversion alone.

Why does my JavaScript converter return numbers like 12.000000000000002?

JavaScript uses IEEE 754 double-precision floats, which cannot represent most decimal fractions exactly in binary. The arithmetic is correct to about 15–17 significant digits and the leftover is display noise. Format the output with toPrecision(10) and wrap it in parseFloat to drop trailing zeros.

Should I use toFixed or toPrecision for conversion results?

toPrecision, for a general-purpose converter. toFixed counts digits after the decimal point, so it collapses very small results to 0.0000 and pads large ones needlessly. toPrecision counts significant digits, which behaves sensibly across every magnitude a converter might produce.

How do you handle temperature conversion in a unit converter?

Temperature scales have an offset as well as a factor, so a single multiplier cannot express them. Give each temperature unit a toBase and a fromBase function and convert through a chosen base scale. Check your implementation against 100 °C = 212 °F and −40 °C = −40 °F.

Are imperial conversion factors exact or approximate?

Exact. The 1959 international yard and pound agreement defines the yard as exactly 0.9144 metres, which makes the inch exactly 25.4 mm and the mile exactly 1609.344 m. Use the full values rather than rounded ones — the rounding belongs in the display layer, not the data.

Can this run without a server?

Yes. The converter is a single HTML file with inline JavaScript and no network calls, so it works offline and nothing the user types is transmitted anywhere. That also makes it trivial to host on any static file host.

Conclusion

A unit converter is a good project precisely because the naive version works immediately and then teaches you something. The base-unit table removes the n² problem, toPrecision removes the float noise, and temperature forces you to notice that your abstraction assumed something it never stated — that zero means the same thing in every unit.

Get those three right and the same fifteen lines will carry every unit group you care to add. That is genuinely how the converters on this site are built.

Comments