---
title: "ADR 0016. The unit table ships spoken forms, not conversion factors"
description: "An authored token source that says what a number is measured in and how to say it aloud, and refuses by name the three conversions that are not definitions."
url: "https://opsinjs.pensievelabs.org/project/decisions/0016-the-unit-table-ships-no-factors"
source: "https://opsinjs.pensievelabs.org/project/decisions/0016-the-unit-table-ships-no-factors.md"
section: "Project"
kind: "project"
reviewed: "2026-09-03"
reviewer: "engineering"
aliases: ["unit table", "units json", "conversion factors", "spoken units", "mmol mg/dL"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="project" />

## Status [#status]

**Accepted.** 2026-09-03. `apps/www/tokens/units.json` is authored, emitted by
`scripts/build-tokens.mts`, and read by `Value` through `@/lib/opsinjs`.

## Context [#context]

Two component specifications assume a unit table that did not exist.
[Value](../../components/value.mdx) resolves "the spoken unit form from the unit table";
[ReadingInput](../../components/reading-input.mdx) needs mmol/L↔mg/dL, kg↔lb↔st and °C↔°F.
Nothing under `tokens/` mentioned mmol or mmHg outside the glossary.

The reason a table is needed at all is narrower than it first looks, and it is not
conversion. It is **speech**. A screen reader handed `mmHg` improvises a pronunciation, and
the improvisation is rarely close: *one twenty over eighty em em aitch gee* is a failure.
The symbol is the part every product remembers to supply; the spoken form is the part
nobody has.

The instruction that produced this file asked for the three conversion families. The
doctrine says the opposite, in [Unit systems](../../health/unit-systems.mdx): "we do not
ship conversion factors as clinical content… Publishing a table of factors here would
invite someone to apply the glucose factor to cholesterol", and "opsinjs ships no factors
and no thresholds". The implementer followed the doctrine over the instruction. That was
the right call and this record exists to make it binding rather than a one-off.

## Decision [#decision]

**`tokens/units.json` carries the identity of a unit and how to say it: id, symbol as
displayed, spoken form, plural, and what family it measures.** It is an authored token
source with an emitter, like colour and motion and material. It is never values typed
into a component, because a conversion factor somebody typed into a `.tsx` file is a
clinical number with no provenance.

**It carries exact DEFINITIONAL conversions and nothing else.** °C to °F is nine fifths
plus thirty-two because the Fahrenheit scale is *defined* against Celsius. The
international pound is exactly 0.45359237 kg because a treaty says so. Each conversion
carries a `basis` field stating where the number comes from, and the emitter refuses a
conversion that has none.

**It refuses three conversions by name, in a `refusedConversions` block that ships:**

* **mmol/L ↔ mg/dL.** Molar concentration and mass concentration are related by the molar
  mass of the substance being measured. That is a property of the *substance*, not of
  either unit. Glucose and cholesterol are both reported in mmol/L, and a single factor in
  this table would be applied to both.
* **HbA1c mmol/mol ↔ %.** The two ways of reporting it are related by a fitted regression
  between two assay standardisations. A regression is a measurement with a residual, not a
  definition, and it belongs to whoever measured it.
* **kcal ↔ kJ.** There is more than one calorie. The thermochemical calorie is exactly
  4.184 joules and the fifteen-degree calorie is not, and food-energy labelling picks one
  by regulation rather than by physics.

**It carries no default precision, and the build gate refuses the keys `precision` and
`decimals` by name** so that it cannot acquire one later. See
[ADR 0017](./0017-precision-means-decimal-places.mdx).

## Consequences [#consequences]

* **A product that needs mg/dL does the arithmetic itself**, with the molar mass of the
  substance it is actually displaying. That is more work for the product and it is the
  correct amount of work: only the product knows what was measured.
* **The refusals are published**, not merely omitted. `REFUSED_CONVERSIONS` is exported from
  the substrate and ships to consumers, so a developer who reaches for `convertUnit("mmol/L",
  "mg/dL")` gets a named reason rather than `undefined` and a shrug. An omitted conversion
  renders an explicit "we do not have this"; it never falls back to a guess.
* **The spoken forms are British English, and that is a stated limit rather than a claim.**
  A component asked for a non-English locale gets English words beside locale-formatted
  digits. Naming the gap is the honest state; inventing translations is not.
* **A unit that is not in the table renders as written** rather than being pronounced by
  guesswork. That is worse for a listener than a correct spoken form and better than a
  confident wrong one.

## Alternatives considered [#alternatives-considered]

**Ship the three factors with a warning.** Rejected. The warning is read once and the
factor is used forever, and the failure it guards against is the glucose factor applied to
cholesterol, which produces a number that looks entirely reasonable. There is no error
state; a reader simply sees the wrong figure for their own result.

**Ship a per-substance conversion table.** Rejected, and it is the tempting one because it
solves the objection directly. It would make opsinjs the owner of a list of molar masses
and of the decision about which substances are on it, which is clinical content with a
clinical owner, and this system has neither.

**Put the spoken forms in the component.** Rejected on the same grounds as every other
token: a value in a component is a value nothing regenerates, nothing diffs and nothing
publishes. The table is emitted into the generated token module, into the substrate that
ships, and into the reference pages, from one parse of one file.

## Revisiting this [#revisiting-this]

Revisit when a conversion in the refused list becomes a definition rather than a
measurement. That would mean a standards body had fixed it exactly, and the `basis` field
would then have something true to say.

Revisit the English-only spoken forms when there is a second locale with somebody to write
them. The shape of the table does not need to change for that; the entries do.

<LastUpdated />

<Reviewed />
