opsinjs
RoadmapDecisions

ADR 0016. The unit table ships spoken forms, not conversion factors

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.

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

Two component specifications assume a unit table that did not exist. Value resolves "the spoken unit form from the unit table"; ReadingInput 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: "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

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.

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

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

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.

Edit this page

Last read through against the system on 2026-09-03. Due for review every 12 months; expiry is reported by pnpm run check:freshness.

On this page