Unit converters are everywhere, but building one surfaces exactly one interesting design fork: almost every unit can be expressed as a single "factor to a base unit," but temperature can't. 1km = 1000m, 1in = 0.0254m — linear units convert any pair through a shared base with no N×N table. But 0°C is not 0°F (it's 32°F). Celsius / Fahrenheit / Kelvin are affine (
y = a·x + b), not a simple ratio. How you handle that "temperature is the exception" case is the subtle, essential part. I built a converter across length, mass, temperature, area, speed, and data size.
🌐 Demo: https://sen.ltd/portfolio/unit-converter/
📦 GitHub: https://github.com/sen-ltd/unit-converter
Linear units: one factor kills the N×N table
A naive length converter looks like it needs every pair: mm↔cm, mm↔m, mm↔km... 11 units = 110 conversions. That doesn't scale.
The fix: pick one base unit, express each unit as its factor to the base. With meters as the base for length:
{ id: "mm", factor: 0.001 }, // 1mm = 0.001m
{ id: "m", factor: 1 }, // base
{ id: "km", factor: 1000 }, // 1km = 1000m
{ id: "in", factor: 0.0254 }, // 1in = 0.0254m
Conversion is a one-way trip through the base:
export function convert(categoryId, fromId, toId, value) {
// ...
const base = value * from.factor; // any unit → base
return base / to.factor; // base → any unit
}
12 in → ft: 12 × 0.0254 = 0.3048m, then 0.3048 / 0.3048 = 1ft. N factors for N units — the N×N table is gone. This is the basic shape of dimensional conversion.
Temperature: a factor can't express it
Try the same with temperature and it breaks. "What's Celsius's factor?" has no answer, because Celsius and Fahrenheit have different origins:
- 0°C = 32°F (freezing)
- 100°C = 212°F (boiling)
- The difference ratio is 100:180 = 5:9, but zero doesn't map to zero
That's not proportional (y = a·x), it's affine (y = a·x + b) — you need the intercept b, not just the slope a. A single factor can't represent it.
The fix: for the temperature category only, give each unit a pair of functions (toBase / fromBase). Base is Kelvin:
{
id: "temperature", base: "K", affine: true,
units: [
{ id: "C", toBase: (c) => c + 273.15, fromBase: (k) => k - 273.15 },
{ id: "F", toBase: (f) => (f - 32) * 5/9 + 273.15, fromBase: (k) => (k - 273.15) * 9/5 + 32 },
{ id: "K", toBase: (k) => k, fromBase: (k) => k },
],
}
The engine branches on an affine flag:
if (cat.affine) {
const base = from.toBase(value); // any temp → K
return to.fromBase(base); // K → any temp
}
const base = value * from.factor; // linear route
return base / to.factor;
Same "one trip through the base" shape — the factor multiplication just becomes a function call. Keep the abstraction; raise only the expressiveness.
A test that pins "temperature isn't a ratio"
Encode the famous fact — the -40°C = -40°F crossover:
test("-40°C = -40°F (the crossover)", () =>
approx(convert("temperature", "C", "F", -40), -40));
test("0°C = 32°F", () => approx(convert("temperature", "C", "F", 0), 32));
test("100°C = 212°F", () => approx(convert("temperature", "C", "F", 100), 212));
// regression guard: if temperature were (wrongly) linear, 0°C→0°F
test("affine is NOT a simple ratio: 0°C→F is 32, not 0", () => {
assert.notEqual(convert("temperature", "C", "F", 0), 0);
});
That last one earns its keep. Implement temperature with a linear factor by mistake and 0°C → 0°F. Pinning "it's not zero" prevents a refactor from quietly collapsing temperature back to linear.
The catalog self-checks
Data-driven, so the catalog's own integrity is tested:
test("linear base unit has factor 1", () => {
for (const cat of CATEGORIES) {
if (cat.affine) continue;
assert.equal(getUnit(cat, cat.base).factor, 1);
}
});
test("affine units' toBase/fromBase round-trip", () => {
const temp = getCategory("temperature");
for (const u of temp.units)
for (const v of [-40, 0, 37, 100])
approx(u.fromBase(u.toBase(v)), v);
});
"Base unit's factor is 1" is an invariant of every linear category — breaking it skews every conversion, so it's a safety net when adding units. Affine units are guarded by the fromBase(toBase(x)) === x round trip.
Units worth having
Being a general converter, I added the "wait, how much is that?" units:
- Japanese traditional: 尺 (0.303m), 里 (3927m), 坪 (3.306m²), 畳/tatami (1.653m²), 匁 (3.75g), 貫 (3.75kg)
-
Data 10³ vs 2¹⁰: KB (1000) and KiB (1024) as distinct units —
1 KB ≠ 1 KiBis tested - Mach for speed: sea-level 15°C speed of sound, 340.29 m/s
test("1 KB (10³) ≠ 1 KiB (2¹⁰)", () => {
assert.notEqual(convert("data", "kb", "byte", 1), convert("data", "kib", "byte", 1));
});
test("2 tatami ≈ 1 tsubo", () => approx(convert("area", "tatami", "tsubo", 2), 1, 1e-3));
Architecture
units.js ← unit catalog (factors + affine functions) (DOM-free)
convert.js ← conversion engine + number formatting (DOM-free, 40 tests)
app.js ← UI glue
convert.js is DOM-free, so all 40 tests run in Node.
Try it
In the temperature tab, type -40 and watch both Celsius and Fahrenheit read -40 — the crossover. In data, see the ~7.4% gap between 1 GB and 1 GiB.
Takeaways
- Linear units = one factor to a base → the N×N table disappears:
value × fromFactor ÷ toFactor. - Temperature is affine (
y = a·x + b). Origins differ (0°C ≠ 0°F), so factors can't express it — usetoBase/fromBasefunctions. - Keep both paths as "one trip through the base"; swap the factor multiply for a function call.
- A regression test that
0°C→F is not 0stops anyone collapsing temperature back to linear. - With a data-driven catalog, test the catalog's own invariants (base factor = 1, round-trip).
- KB/KiB and traditional units — the "small distinctions" are where a general tool earns its place.
This is OSS portfolio #265 from SEN LLC (Tokyo). https://sen.ltd/portfolio/

Top comments (0)