Foundations

Numbers

Reading numbers as people type and paste them, and showing them as their locale and their work expect.

Markdown

Numbers are entered by typing, pasting and stepping, and shown back in the form people read. An interface that handles them well accepts what people naturally type, and shows values as their language and their work expect.

Reading what is typed

Type, or choose a sample: separators from other locales, full-width digits, a minus sign, arithmetic, other units. Change the locale to see how the same value is shown.

Locale
Unit
Reads as
4.2333333333333325
Shown as
4.233 mm
Code
"use client";

import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import { numberType } from "@publictheta/ui/primitives/number-type.ts";
import { length } from "@publictheta/ui/primitives/units.ts";
import { useMemo, useState } from "react";
import { Controls } from "../../ui/controls.tsx";

const SAMPLES = [
  "1,234.5",
  "1,5",
  "1.234,5",
  "123.5",
  "ー3",
  "(40-8)/2",
  "(1,234.50)",
  "1 1/2",
  "1e3",
  "12pt",
  "3 in",
  "5'10\"",
  "10–20",
];

export default function NumberReader() {
  const [locale, setLocale] = useState("en-US");
  const [unit, setUnit] = useState<"none" | "mm">("mm");
  const [text, setText] = useState("12pt");
  const type = useMemo(
    () => numberType({ locale, precision: 3, ...(unit === "mm" && { units: length, unit: "mm" }) }),
    [locale, unit],
  );
  const result = type.parse(text);

  return (
    <div className="w-full">
      <Controls>
        <RadioGroup label="Locale" size="small" value={locale} onValueChange={setLocale}>
          <Radio value="en-US">English</Radio>
          <Radio value="de-DE">Deutsch</Radio>
          <Radio value="fr-FR">Français</Radio>
          <Radio value="ja-JP">日本語</Radio>
          <Radio value="ar-EG">العربية</Radio>
        </RadioGroup>
        <RadioGroup label="Unit" size="small" value={unit} onValueChange={setUnit}>
          <Radio value="none">None</Radio>
          <Radio value="mm">Millimetres</Radio>
        </RadioGroup>
      </Controls>
      <div className="mx-auto flex max-w-md flex-col gap-4">
        <input
          aria-label="Typed"
          value={text}
          onChange={(event) => setText(event.target.value)}
          className="h-10 rounded-lg border border-border bg-surface px-3 font-mono text-base text-fg-strong outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
        />
        <div className="flex flex-wrap justify-center gap-1.5">
          {SAMPLES.map((sample) => (
            <button
              key={sample}
              type="button"
              onClick={() => setText(sample)}
              className="rounded-md bg-surface-muted px-2 py-0.5 font-mono text-xs text-fg hover:bg-surface-strong"
            >
              {sample}
            </button>
          ))}
        </div>
        <dl aria-live="polite" className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
          <dt className="text-fg-muted">Reads as</dt>
          <dd className="font-mono text-fg-strong">{result.ok ? result.value : `— (${result.reason})`}</dd>
          <dt className="text-fg-muted">Shown as</dt>
          <dd className="font-mono text-fg-strong">
            {result.ok ? type.format(type.constrain(result.value)) : "—"}
          </dd>
        </dl>
      </div>
    </div>
  );
}

Best practices

Show numbers in the reader's locale. Digits, the decimal point and the separators that group thousands follow the language of the interface: 1,234.5 in English, 1.234,5 in German, ١٬٢٣٤٫٥ in Arabic.

Read what people type, not only what the field would show. Accept the locale's separators where they group thousands; full-width digits, which an input method produces; any minus sign, and parentheses around a negative amount; and a separator from another locale where it can only mean one thing — 1,5 in an English field is one and a half, because a separator before a single digit cannot be grouping thousands.

Never guess. What is typed is read as what the person meant, or it is invalid. 10 20, 1,234,56, a pasted range such as 10–20 and 5² are not numbers: joining the digits would keep a value nobody typed.

Accept arithmetic. In a field that takes a measurement or a quantity, 12*3 or (40-8)/2 is a faster way to say a value than working it out elsewhere. A percent sign after a number divides it by a hundred, so 200*10% is 20. Read pasted data as a number only, without arithmetic: 2026-10-13 in a cell is a date in the wrong column, not 2003.

Give a unit, and accept others. Show the unit people work in after the value, and convert a value typed in another: 1in in a millimetre field is 25.4 mm. Measures written together, from the larger unit down, add up: 5'10" is 70 in.

Keep only meaningful precision. Round a value to the digits that matter for it — for money, the digits its currency has — with halves away from zero, and never show the residue of binary arithmetic, such as 0.30000000000000004.

Constrain rather than refuse. A value beyond the allowed range becomes the nearest value within it, and is shown as such, so people see what was kept.

Let a cycle go round. Where a range is a cycle — degrees, hours — a value past one end is the value that far past the other: 370 in 0 to 360 is 10, and −90 is 270. The cycle holds its minimum and not its maximum, which is the minimum again. This is reading, not limiting: the value kept is the value meant.

Step predictably. Up and Down Arrow change a value by one step; with Shift by ten steps, with Option or Alt by a tenth of one; Page Up and Page Down by ten. A step lands on a multiple of the step size, counted from the minimum: with a step of 5, 7 steps up to 10 and down to 5. Typing is never moved to a multiple. The step size is in what is shown, so a step of 1 moves 50% to 51%. Where a tenth of a step is finer than the field keeps, it is a whole step.

Reading

How an English field reads what is typed:

TypedRead asBecause
1,234.51234.5the locale's separators
1,2341234a separator before three digits groups them
1,51.5a separator before one digit is a decimal point
0,1250.125nothing is grouped after a zero
1.234,51234.5with two kinds, the last is the decimal point
1,234,56invalidneither grouping nor one decimal point
123.5123.5full-width, from an input method
ー5−5what a Japanese keyboard types for a minus
−3−3any minus sign
(1,234.50)−1234.5wholly in parentheses, as accounts write it
10–20invalida dash between two numbers is a range
(40-8)/216arithmetic
=12*336a leading equals sign is ignored
200*10%20a percent sign divides by a hundred
1 1/21.5a mixed number
10 20invalidtwo numbers
1e31000scientific notation
5²invalida superscript is not a digit
12pt4.23 mmconverted, in a millimetre field
5'10"70 infeet and inches, in a length field
5050%in a percentage field, the sign is optional
usd 5$5.00in a dollar field, its own currency by any name

Units

A field that takes a measure holds its value in one unit and accepts any other of the same kind, each converted by a fixed factor to the kind's base. Each unit is shown by its symbol after a space that does not break, except where the symbol is written against the number, as a degree is.

KindUnitShownIsAlso typed as
Lengthpixelpxa CSS pixel, the basepixel, pixels
pointpt1/72 inpoint, points
picapc1/6 inpica, picas
inchin96 pxinch, inches, ", ″
footft12 infoot, feet, ', ′
cmcm1/2.54 incentimeter(s), centimetre(s)
mmmm1/25.4 inmillimeter(s), millimetre(s)
QQ¼ mm, for Japanese type級, 歯, h
Angledegree°the basedeg, degree, degrees
radianrad180/π°radian, radians
turnturn360°turns
gradgrad0.9°gon, grads

Names are matched without regard to case. A field can be given units of its own kind in the same form: each a symbol, a factor to the base, other names, and whether it is written against the number.

Platform considerations

Touch. Offer a numeric keyboard.

Assistive technology. Expose the value as a number, with its unit in the text read aloud, and the range when there is one.

See also Number field.

Behaviour

Reading

What is shown reads back as the same value, in every locale
fc.assert(
  fc.property(
    fc.constantFrom(...LOCALES),
    fc.integer({ min: -10_000_000_00, max: 10_000_000_00 }).map((n) => n / 100),
    (locale, number) => {
      const type = numberType({ locale, precision: 2 });
      const result = type.parse(type.format(number));
      const pasted = type.parse(type.format(number), { arithmetic: false });
      return result.ok && result.value === number && pasted.ok && pasted.value === number;
    },
  ),
);
An amount of money, a percentage and a measure read back as shown, in every locale
fc.assert(
  fc.property(
    fc.constantFrom(...LOCALES),
    fc.constantFrom(
      { kind: "currency", currency: "USD", precision: 2 } as const,
      { kind: "currency", currency: "EUR", precision: 2 } as const,
      { kind: "currency", currency: "JPY", precision: 0 } as const,
      { kind: "percent", precision: 4 } as const,
      { units: length, unit: "mm", precision: 2 } as const,
      { units: length, unit: "ft", precision: 2 } as const,
      { units: angle, unit: "deg", precision: 2 } as const,
    ),
    fc.integer({ min: -10_000_000_00, max: 10_000_000_00 }),
    (locale, { precision, ...options }, n) => {
      const type = numberType({ locale, ...options });
      const number = n / 10 ** precision;
      const result = type.parse(type.format(number));
      return result.ok && type.constrain(result.value) === number;
    },
  ),
);
Full-width digits and signs, as an input method types them, read as numbers
fc.assert(
  fc.property(
    fc.integer({ min: -1_000_000_00, max: 1_000_000_00 }).map((n) => n / 100),
    (number) => {
      const type = numberType({ locale: "ja-JP", precision: 2 });
      const result = type.parse(fullWidth(type.format(number)));
      return result.ok && result.value === number;
    },
  ),
);
expect(value("123.5", { locale: "ja-JP" })).toBe(123.5);
Any minus sign makes a number negative
for (const minus of ["-", "−", "-", "–"]) expect(value(`${minus}4`)).toBe(-4);
A typographic dash is a minus before a number, and not a number's sign between two
for (const dash of ["–", "—", "‒", "―"]) {
  expect(value(`${dash}5`)).toBe(-5);
  expect(value(`3*${dash}5`)).toBe(-15);
  expect(value(`10${dash}20`)).toBe("invalid");
  expect(value(`10 ${dash} 20`)).toBe("invalid");
}
expect(value("10−20")).toBe(-10);
expect(value("10-20")).toBe(-10);
The long vowel mark and the hyphens a Japanese keyboard types are a minus beside a digit
for (const mark of ["ー", "ー", "‐", "‑"]) {
  expect(value(`${mark}5`, { locale: "ja-JP" })).toBe(-5);
  expect(value(`10${mark}3`, { locale: "ja-JP" })).toBe(7);
}
expect(value("ー", { locale: "ja-JP" })).toBe("invalid");
An ideographic full stop or comma between digits is a point or a comma
expect(value("1。5", { locale: "ja-JP" })).toBe(1.5);
expect(value("1、234", { locale: "ja-JP" })).toBe(1234);
expect(value("1。5", { locale: "ja-JP" })).toBe(1.5);
expect(value("。", { locale: "ja-JP" })).toBe("invalid");
The locale's group separators are read where they group thousands
expect(value("1,234,567", { locale: "en-US" })).toBe(1234567);
expect(value("1.234.567", { locale: "de-DE" })).toBe(1234567);
expect(value("1 234 567", { locale: "fr-FR" })).toBe(1234567);
expect(value("1'234'567", { locale: "de-CH" })).toBe(1234567);
expect(value("12,34,567", { locale: "hi-IN" })).toBe(1234567);
expect(value("1,234", { locale: "en-US" })).toBe(1234);
Separators that neither group thousands nor mark one decimal point are not a number
for (const text of [
  "1,234,56",
  "12,34,567",
  "1,23.5",
  "1.2.3",
  "1,234.5.6",
  "1.234,567.890",
  "0,125,000",
  "1,,5",
]) {
  expect(value(text, { locale: "en-US" })).toBe("invalid");
}
expect(value("1.234.56", { locale: "de-DE" })).toBe("invalid");
expect(value("1,234,567", { locale: "de-DE" })).toBe("invalid");
A single separator that cannot group thousands is the decimal point, whatever the locale
expect(value("1,5", { locale: "en-US" })).toBe(1.5);
expect(value("0,125", { locale: "en-US" })).toBe(0.125);
expect(value(",125", { locale: "en-US" })).toBe(0.125);
expect(value("1234,567", { locale: "en-US" })).toBe(1234.567);
expect(value("3.25", { locale: "de-DE" })).toBe(3.25);
expect(value("0.125", { locale: "de-DE" })).toBe(0.125);
expect(value("1.234", { locale: "de-DE" })).toBe(1234);
expect(value("1.234,5", { locale: "en-US" })).toBe(1234.5);
An apostrophe groups thousands only before three digits
expect(value("1'234", { locale: "de-CH" })).toBe(1234);
expect(value("1’234’567.5", { locale: "de-CH" })).toBe(1234567.5);
expect(value("1'234", { locale: "en-US" })).toBe(1234);
for (const text of ["1'23", "1'2345", "1234'567", "5'10"])
  expect(value(text, { locale: "de-CH" })).toBe("invalid");
A space groups thousands only before three digits, in a locale that groups with one
expect(value("12 345,5", { locale: "fr-FR" })).toBe(12345.5);
expect(value("1 234 * 2", { locale: "fr-FR" })).toBe(2468);
for (const text of ["10 20", "1 23", "1 2345", "1234 567"])
  expect(value(text, { locale: "fr-FR" })).toBe("invalid");
expect(value("1 234", { locale: "en-US" })).toBe("invalid");
Two numbers side by side, or apart by a tab, are not one number
for (const text of ["10 20", "10\t20", "1\t234", "10 + 2\t3", "5 (3)", "(5) 3"]) {
  expect(value(text)).toBe("invalid");
  expect(value(text, { locale: "fr-FR" })).toBe("invalid");
}
expect(value("5$6", { kind: "currency", currency: "USD" })).toBe("invalid");
A whole number, a space and a proper fraction are a mixed number
expect(value("1 1/2")).toBe(1.5);
expect(value("-1 1/2")).toBe(-1.5);
expect(value("2 * 1 1/2")).toBe(3);
expect(value("12 3/4", { locale: "fr-FR" })).toBe(12.75);
expect(value("1 1/2 in", { units: length, unit: "mm" })).toBeCloseTo(38.1, 10);
for (const text of ["1 3/2", "1 2/2", "1 0/2", "1.5 1/2", "1 1/2/3", "1 1/2.5", "1 1 / 2"])
  expect(value(text)).toBe("invalid");
A number wholly in parentheses is negative, as accounts write it
expect(value("(1,234.50)")).toBe(-1234.5);
expect(value("($500)", { kind: "currency", currency: "USD" })).toBe(-500);
expect(value("( 12 mm )", { units: length, unit: "mm" })).toBe(-12);
expect(value("(0)")).toBe(0);
expect(value("(2+3)*4")).toBe(20);
expect(value("(2)*4")).toBe(8);
expect(value("(-2)")).toBe(-2);
expect(value("2*(3)")).toBe(6);
Digits of other numbering systems read as numbers
expect(value("١٢٫٥", { locale: "ar-EG" })).toBe(12.5);
expect(value("४२")).toBe(42);
Superscripts, subscripts, fractions and enclosed numerals are not digits
for (const text of ["5²", "5³", "2⁴", "H₂", "1₀", "1½", "½", "①", "⑴5", "㏠"])
  expect(value(text)).toBe("invalid");
Scientific notation reads as the number it writes
expect(value("1e3")).toBe(1000);
expect(value("1.5E-7")).toBe(1.5e-7);
expect(value("2e+2*3")).toBe(600);
expect(value("1,5e3", { locale: "de-DE" })).toBe(1500);
expect(value("1e3mm", { units: length, unit: "cm" })).toBeCloseTo(100, 10);
for (const text of ["1e", "e3", "1e3.5", "1e999"]) expect(value(text)).toBe("invalid");
Where the field has a unit written e, a number followed by e is in that unit
const units = { c: { label: "C", factor: 1 }, e: { label: "e", factor: 2 } };
expect(value("1e", { units, unit: "c" })).toBe(2);
expect(value("1e3", { units, unit: "c" })).toBe("invalid");
expect(value("1e 3c", { units, unit: "c" })).toBe(5);
An equals sign before everything else is ignored
expect(value("=12*3")).toBe(36);
expect(value(" = 5")).toBe(5);
expect(value("=5")).toBe(5);
for (const text of ["=", "==5", "5=5"]) expect(value(text)).toBe("invalid");
Arithmetic is worked out
expect(value("12*3")).toBe(36);
expect(value("(40-8)/2")).toBe(16);
expect(value("2×3÷4")).toBe(1.5);
expect(value("1,5*2", { locale: "de-DE" })).toBe(3);
Read as a literal only, arithmetic and what merely looks like it are not numbers
for (const text of [
  "2026-10-13",
  "555-1234",
  "10/13/2026",
  "1/2",
  "12*3",
  "(40-8)/2",
  "--5",
  "((5))",
  "5+",
]) {
  expect(literal(text)).toBe("invalid");
}
expect(literal("-1,234.5")).toBe(-1234.5);
expect(literal("+5")).toBe(5);
expect(literal("–5")).toBe(-5);
expect(literal("(500)")).toBe(-500);
expect(literal("1 1/2")).toBe(1.5);
expect(literal("1e3")).toBe(1000);
expect(literal("50%")).toBe(0.5);
expect(literal("$5", { kind: "currency", currency: "USD" })).toBe(5);
expect(literal("5'10\"", { units: length, unit: "in" })).toBe(70);
expect(literal("")).toBe("empty");
A value typed in another unit is converted to the field's
expect(value("1in", { units: length, unit: "mm" })).toBeCloseTo(25.4, 10);
expect(value("72pt", { units: length, unit: "in" })).toBe(1);
expect(value("10mm + 2cm", { units: length, unit: "mm" })).toBeCloseTo(30, 10);
expect(value("0.5turn", { units: angle, unit: "deg" })).toBe(180);
expect(value("12 MM", { units: length, unit: "mm" })).toBe(12);
expect(value("2ft", { units: length, unit: "in" })).toBe(24);
const arc = { deg: { label: "°", factor: 1 }, minute: { label: "′", factor: 1 / 60 } };
expect(value("1°30′", { units: arc, unit: "deg" })).toBe(1.5);
expect(value("30'", { units: arc, unit: "deg" })).toBe(0.5);
A converted value too small to write without an exponent is still a number
expect(value("0.00001px", { units: length, unit: "in" })).toBe(0.00001 / 96);
expect(value("0.000001deg", { units: angle, unit: "turn" })).toBe(0.000001 / 360);
Measures written one after another, from the larger unit to the smaller, add up
expect(value("5'10\"", { units: length, unit: "in" })).toBe(70);
expect(value("5ft 10in", { units: length, unit: "in" })).toBe(70);
expect(value("5′10″", { units: length, unit: "in" })).toBe(70);
expect(value("5’ 10”", { units: length, unit: "in" })).toBe(70);
expect(value("-5'10\"", { units: length, unit: "in" })).toBe(-70);
expect(value("5'10\" * 2", { units: length, unit: "in" })).toBe(140);
expect(value("1cm 5mm", { units: length, unit: "mm" })).toBeCloseTo(15, 10);
for (const text of ["10in 5ft", "10mm 20mm", "5'10", "5ft 10", "5' 50%"]) {
  expect(value(text, { units: length, unit: "in" })).toBe("invalid");
}
A unit the field does not know is not a value
expect(value("12kg", { units: length, unit: "mm" })).toBe("invalid");
expect(value("12mm")).toBe("invalid");
expect(value("5'10\"")).toBe("invalid");
expect(value("5'10\"", { units: angle, unit: "deg" })).toBe("invalid");
Where an apostrophe is a unit, it groups thousands only in a locale that groups with one
expect(value("1'234", { units: length, unit: "in" })).toBe("invalid");
expect(value("1'234", { locale: "de-CH", units: length, unit: "in" })).toBe(1234);
A currency field accepts its currency's code, symbols and name, in either case
const dollars = { kind: "currency", currency: "USD" } as const;
expect(value("$1,234.50", dollars)).toBe(1234.5);
expect(value("1234.50 USD", dollars)).toBe(1234.5);
expect(value("usd 5", dollars)).toBe(5);
expect(value("US$5", dollars)).toBe(5);
expect(value("5 US dollars", dollars)).toBe(5);
expect(value("1 us dollar", dollars)).toBe(1);
expect(value("$5", { locale: "fr-FR", ...dollars })).toBe(5);
expect(value("5 $US", { locale: "fr-FR", ...dollars })).toBe(5);
expect(value("¥1,200", { locale: "ja-JP", kind: "currency", currency: "JPY" })).toBe(1200);
expect(value("1200円", { locale: "ja-JP", kind: "currency", currency: "JPY" })).toBe(1200);
expect(value("jpy 1200", { locale: "ja-JP", kind: "currency", currency: "JPY" })).toBe(1200);
A currency field does not accept another currency, nor any other field a currency
expect(value("€5", { kind: "currency", currency: "USD" })).toBe("invalid");
expect(value("5 EUR", { kind: "currency", currency: "USD" })).toBe("invalid");
expect(value("1200円", { kind: "currency", currency: "USD" })).toBe("invalid");
expect(value("$5")).toBe("invalid");
A percent sign after a number divides it by a hundred, in any field, and is never a remainder
expect(value("200*10%")).toBe(20);
expect(value("10%3")).toBe("invalid");
expect(value("50%")).toBe(0.5);
expect(value("50 %", { locale: "fr-FR" })).toBe(0.5);
expect(value("10mm * 50%", { units: length, unit: "mm" })).toBe(5);
expect(value("50%", { kind: "percent" })).toBe(0.5);
expect(value("50%*50%", { kind: "percent" })).toBe(0.25);
expect(value("50%*2", { kind: "percent" })).toBe(1);
In a percentage field, what is typed without a percent sign is in percent
expect(value("50", { kind: "percent" })).toBe(0.5);
expect(value("10+5", { kind: "percent" })).toBeCloseTo(0.15, 10);
expect(value("50*2", { kind: "percent" })).toBe(1);
expect(value("50")).toBe(50);
Nothing typed is empty; anything else that is not a number is invalid
expect(value("")).toBe("empty");
expect(value("   ")).toBe("empty");
for (const text of ["abc", "1.2.3", "12*", "--", "1/0"]) expect(value(text)).toBe("invalid");

Units

Lengths are in CSS pixels, 96 to the inch, and every length unit is what print and the screen take it to be
const inches = (text: string) => value(text, { units: length, unit: "in" });
expect([inches("96px"), inches("72pt"), inches("6pc"), inches("2.54cm"), inches("25.4mm")]).toEqual([
  1, 1, 1, 1, 1,
]);
expect(inches("1ft")).toBe(12);
// Q, the Japanese typesetting unit, is a quarter of a millimetre.
expect(value("4Q", { units: length, unit: "mm" })).toBeCloseTo(1, 10);
Angles are in degrees: a turn is 360, a radian 180 over π, a grad nine tenths of one
const degrees = (text: string) => value(text, { units: angle, unit: "deg" });
expect(degrees("1turn")).toBe(360);
expect(degrees(`${Math.PI}rad`)).toBeCloseTo(180, 10);
expect(degrees("10grad")).toBeCloseTo(9, 10);
A unit is typed by any of its names, in any case, and by its symbol
const inches = (text: string) => value(text, { units: length, unit: "in" });
expect([
  inches("2 Inches"),
  inches("1 foot"),
  inches('2"'),
  inches("96 PIXELS"),
  inches("72 points"),
]).toEqual([2, 12, 2, 1, 1]);
expect(value("10 millimetres", { units: length, unit: "mm" })).toBeCloseTo(10, 10);
expect(value("4級", { units: length, unit: "mm" })).toBeCloseTo(1, 10);
expect(value("90 degrees", { units: angle, unit: "turn" })).toBe(0.25);
expect(value("100 gon", { units: angle, unit: "deg" })).toBeCloseTo(90, 10);
Each unit is shown by its own symbol: after a space that does not break before most, against a degree
const shown = (units: Units) => Object.keys(units).map((unit) => numberType({ units, unit }).format(2));
expect(shown(length)).toEqual(
  ["px", "pt", "pc", "in", "ft", "cm", "mm", "Q"].map((label) => `2\u00a0${label}`),
);
expect(shown(angle)).toEqual(["2°", "2\u00a0rad", "2\u00a0turn", "2\u00a0grad"]);

Showing

A value is shown with the locale's digits and separators
expect(numberType({ locale: "de-DE" }).format(1234.5)).toBe("1.234,5");
expect(numberType({ locale: "ar-EG" }).format(12.5)).toBe("١٢٫٥");
A unit follows the value, attached where the unit is written so
expect(numberType({ units: length, unit: "mm" }).format(12)).toBe("12 mm");
expect(numberType({ units: angle, unit: "deg" }).format(90)).toBe("90°");
A value shows no more fraction digits than its precision
expect(numberType({ precision: 2 }).format(1 / 3)).toBe("0.33");
An amount of money keeps the fraction digits its currency has, unless given a precision
const yen = numberType({ kind: "currency", currency: "JPY" });
expect(yen.format(1234.5)).toBe("¥1,235");
expect(yen.constrain(1234.5)).toBe(1235);
const dollars = numberType({ kind: "currency", currency: "USD" });
expect(dollars.format(1234.5)).toBe("$1,234.50");
expect(dollars.constrain(1.005)).toBe(1.01);
expect(numberType({ kind: "currency", currency: "USD", precision: 4 }).constrain(1.00005)).toBe(1.0001);

Showing a percentage

The precision is of the percentage as shown: one digit keeps 12.3%, none keeps 50%
const tenths = numberType({ locale: "en-US", kind: "percent", precision: 1 });
expect(tenths.format(tenths.constrain(0.12345))).toBe("12.3%");
const whole = numberType({ locale: "en-US", kind: "percent", precision: 0 });
expect(whole.constrain(0.5)).toBe(0.5);
expect(whole.format(whole.constrain(0.504))).toBe("50%");
A percent sign written before the number, as some languages write it, is read
for (const locale of ["tr-TR", "eu", "ar-EG", "en-US"]) {
  const type = numberType({ locale, kind: "percent" });
  expect(type.parse(type.format(0.5)), locale).toEqual({ ok: true, value: 0.5 });
}
expect(value("%50", { kind: "percent" })).toBe(0.5);
expect(value("200*%10")).toBe(20);

Options that cannot be meant

A precision out of range, a currency not named, or a locale that is not one is put right, not failed on
expect(numberType({ locale: "en-US", precision: -1 }).format(1.5)).toBe("2");
expect(numberType({ locale: "en-US", precision: 200 }).format(1.5)).toBe("1.5");
expect(numberType({ locale: "en-US", precision: Number.NaN }).format(1.5)).toBe("1.5");
expect(numberType({ locale: "en-US", kind: "currency" }).format(1.5)).toBe("1.5");
expect(numberType({ locale: "en_US" }).format(1234.5)).toBe("1,234.5");
A step that is not a size is a step of one, and a range given backwards is the same range
expect(numberType({ step: 0 }).step(5, 1)).toBe(6);
expect(numberType({ step: -2 }).step(5, 1)).toBe(6);
const range = numberType({ minimum: 10, maximum: 0 });
expect([range.constrain(5), range.constrain(-5), range.constrain(15)]).toEqual([5, 0, 10]);
Zero is shown as zero, whichever side it was approached from
expect(numberType({ locale: "en-US" }).format(-0)).toBe("0");

Constraining and stepping

A value is rounded to its precision, without floating-point residue
expect(numberType({ precision: 2 }).constrain(0.1 + 0.2)).toBe(0.3);
expect(numberType({ precision: 0 }).constrain(2.5)).toBe(3);
A half rounds away from zero, as the decimal number it is written as
expect(numberType({ precision: 2 }).constrain(1.005)).toBe(1.01);
expect(numberType({ precision: 2 }).constrain(-1.005)).toBe(-1.01);
expect(numberType({ precision: 0 }).constrain(-2.5)).toBe(-3);
expect(numberType({ precision: 1 }).constrain(0.95)).toBe(1);
expect(numberType({ precision: 2 }).constrain(-0.001)).toBe(0);
fc.assert(
  fc.property(fc.integer({ min: -1_000_000_000, max: 1_000_000_000 }), (thousandths) => {
    const hundredths = Math.sign(thousandths) * Math.floor((Math.abs(thousandths) + 5) / 10);
    return numberType({ precision: 2 }).constrain(thousandths / 1000) === hundredths / 100 + 0;
  }),
);
Rounding leaves a value of any magnitude a number
const type = numberType({ precision: 6 });
for (const large of [1e21, 1.2345e306, 2 ** 53, 123456789012.34567, -9007199254.740993]) {
  expect(Math.abs(type.constrain(large) - large)).toBeLessThanOrEqual(Math.abs(large) * 1e-15);
}
expect(type.constrain(1.5e-7)).toBe(0);
expect(type.constrain(5e-7)).toBe(0.000001);
expect(numberType({ precision: 0 }).constrain(4503599627370495.5)).toBe(4503599627370496);
A value is held within the range
const type = numberType({ minimum: 0, maximum: 100 });
expect(type.constrain(-5)).toBe(0);
expect(type.constrain(150)).toBe(100);
A step adds the step size, as often as asked, and stays in range
const type = numberType({ step: 0.1, maximum: 1 });
expect(type.step(0.2, 1)).toBe(0.3);
expect(type.step(0.2, -10)).toBe(-0.8);
expect(type.step(0.95, 1)).toBe(1);
A step from between two multiples of the step size goes to the nearest one that way
const type = numberType({ step: 5 });
expect(type.step(7, 1)).toBe(10);
expect(type.step(7, -1)).toBe(5);
expect(type.step(10, 1)).toBe(15);
expect(type.step(7, 3)).toBe(20);
expect(type.step(-7, 1)).toBe(-5);
fc.assert(
  fc.property(
    fc.integer({ min: 1, max: 1000 }),
    fc.integer({ min: -1_000_000, max: 1_000_000 }),
    fc.boolean(),
    (hundredths, from, up) => {
      const size = hundredths / 100;
      const start = from / 100;
      const stepped = numberType({ step: size, precision: 2 }).step(start, up ? 1 : -1);
      const multiple = stepped / size;
      const moved = up ? stepped - start : start - stepped;
      return Math.abs(multiple - Math.round(multiple)) < 1e-6 && moved > 0 && moved < size + 0.005;
    },
  ),
);
The multiples a step goes to are counted from the minimum, when there is one
const type = numberType({ step: 5, minimum: 2 });
expect(type.step(7, 1)).toBe(12);
expect(type.step(8, 1)).toBe(12);
expect(type.step(8, -1)).toBe(7);
expect(type.step(3, -1)).toBe(2);
A typed value is kept where it is, on a multiple of the step size or not
const type = numberType({ step: 5, minimum: 2 });
const typed = type.parse("8");
expect(typed.ok && type.constrain(typed.value)).toBe(8);
A tenth of a step moves by tenths of the step size
expect(numberType({ step: 1 }).step(5, 0.1)).toBe(5.1);
expect(numberType({ step: 1 }).step(5.12, 0.1)).toBe(5.2);
expect(numberType({ step: 1 }).step(5.1, -0.1)).toBe(5);
expect(numberType({ step: 1 }).step(5.1, 1)).toBe(6);
A tenth of a step that the precision cannot keep is a whole step instead
expect(numberType({ precision: 0 }).step(5, 0.1)).toBe(6);
expect(numberType({ precision: 0 }).step(5, -0.1)).toBe(4);
expect(numberType({ precision: 1, step: 0.5 }).step(1, 0.1)).toBe(1.5);
expect(numberType({ precision: 1 }).step(5, 0.1)).toBe(5.1);
In a percentage, the step size is in percent as shown, and the range in the fraction kept
const type = numberType({ kind: "percent", step: 1, minimum: 0, maximum: 1 });
expect(type.step(0.5, 1)).toBe(0.51);
expect(type.step(0.5, -10)).toBe(0.4);
expect(type.step(0.505, 1)).toBe(0.51);
expect(type.step(0.995, 1)).toBe(1);
expect(type.constrain(50)).toBe(1);
expect(numberType({ kind: "percent", step: 5 }).step(0.5, 1)).toBe(0.55);
Several steps at once land where as many single steps would
const type = numberType({ step: 5, maximum: 100 });
expect(type.step(7, 1, 3)).toBe(20);
expect(type.step(7, -1, 2)).toBe(0);
expect(type.step(7, 10, 2)).toBe(100);
expect(numberType().step(5, 0.1, 3)).toBe(5.3);
expect(numberType().step(5.12, -0.1, 2)).toBe(5);

A range that is a cycle

A step past either end comes round from the other
expect(degrees.step(350, 2)).toBe(10);
expect(degrees.step(10, -2)).toBe(350);
expect(degrees.step(355, 0.1, 6)).toBe(1);
A typed value beyond the range is read round the cycle, and is the value typed: nothing was limited
expect(degrees.parse("370")).toEqual({ ok: true, value: 10 });
expect(degrees.parse("-90")).toEqual({ ok: true, value: 270 });
expect(degrees.parse("360*3+45")).toEqual({ ok: true, value: 45 });
expect(degrees.constrain(725.5)).toBe(5.5);
The maximum is the minimum again, and is shown as the minimum
expect(degrees.parse("360")).toEqual({ ok: true, value: 0 });
expect(degrees.step(350, 1)).toBe(0);
expect(degrees.constrain(359.9999999)).toBe(0);
// So little below the minimum that, a turn up, it is the maximum to every digit kept.
expect(numberType({ minimum: 0, maximum: 360, wrap: true, precision: 20 }).constrain(-1e-17)).toBe(0);
const half = numberType({ minimum: -180, maximum: 180, wrap: true });
expect(half.constrain(180)).toBe(-180);
expect(half.constrain(190)).toBe(-170);
Every value kept lies in the cycle, a whole number of turns from the one given
fc.assert(
  fc.property(fc.double({ min: -1e6, max: 1e6, noNaN: true }), (given) => {
    const kept = degrees.constrain(given);
    const turns = (given - kept) / 360;
    return kept >= 0 && kept < 360 && Math.abs(turns - Math.round(turns)) < 1e-6;
  }),
);
Without both ends there is no cycle: the range is held as any other
const open = numberType({ minimum: 0, wrap: true });
expect(open.wrap).toBe(false);
expect(open.constrain(-5)).toBe(0);
expect(open.parse("-5")).toEqual({ ok: true, value: -5 });
expect(numberType({ minimum: 5, maximum: 5, wrap: true }).constrain(9)).toBe(5);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/number-type.json
Source

src/primitives/number-type.ts

/**
 * The numeric value type: how a number is read from what people type or paste,
 * how it is shown, and how it steps.
 *
 * Reading is forgiving of form and strict about meaning: a text is read as what
 * the person meant, or it is invalid — never a guess. It accepts:
 *
 * - full-width digits from an input method, and every common digit system;
 * - the locale's group and decimal separators, and a separator from another
 *   locale where it cannot be anything else (`1,5` is one and a half);
 * - any minus sign: `-`, `−`, `-`, a dash before a number, `(500)` as accounts
 *   write it;
 * - arithmetic: `12*3`, `(40-8)/2`, `×` and `÷`, `10%` for a tenth — read as a
 *   formula (`algorithms/formula.ts`) that names no field;
 * - mixed numbers and scientific notation: `1 1/2`, `1.5e-7`;
 * - units, converted to the field's: `12pt` in a millimetre field, `5'10"`;
 * - the field's own currency, by symbol, code or name.
 *
 * Showing is the locale's: its digits, separators and unit placement.
 */
import { compileFormula } from "../algorithms/formula.ts";
import { DASH, normalizeNumeric } from "./typed-text.ts";
import { validLocale } from "../lib/valid-locale.ts";
import type { Unit, Units } from "./units.ts";

export interface NumberTypeOptions {
  readonly locale?: string | undefined;
  /** The range, in the value kept: 0.5, not 50, bounds a percentage at 50%. */
  readonly minimum?: number | undefined;
  readonly maximum?: number | undefined;
  /**
   * Whether the range is a cycle — an angle, an hour — so that its maximum is
   * its minimum again: a value past either end, typed or stepped, comes round
   * from the other. Needs both ends.
   */
  readonly wrap?: boolean | undefined;
  /** The amount the arrow keys add, in what is shown: 1 is 1% in a percentage. */
  readonly step?: number | undefined;
  /**
   * The most fraction digits a value shows, and so keeps: of the percentage in a
   * percentage, where 1 keeps 12.5%. A currency's own when not given.
   */
  readonly precision?: number | undefined;
  /** `percent` shows 0.5 as 50%; `currency` needs `currency`. */
  readonly kind?: "decimal" | "percent" | "currency" | undefined;
  readonly currency?: string | undefined;
  /** The units that can be typed, and the one values are in. */
  readonly units?: Units | undefined;
  readonly unit?: string | undefined;
}

export type ParseResult =
  | { readonly ok: true; readonly value: number }
  | { readonly ok: false; readonly reason: "empty" | "invalid" };

export interface ParseOptions {
  /** `false` reads one number as written and no arithmetic, as for pasted data. */
  readonly arithmetic?: boolean | undefined;
}

export interface NumberType {
  readonly parse: (text: string, options?: ParseOptions) => ParseResult;
  readonly format: (value: number) => string;
  /**
   * The value after `count` steps, on the multiples of the step size counted from
   * the minimum; a step is `step`, ten with Shift, a tenth with Alt. `times` is
   * how many such steps at once, as a drag makes them.
   */
  readonly step: (value: number, count: number, times?: number) => number;
  /** Rounded to the precision and held within the range — or, in a cycle, brought round into it. */
  readonly constrain: (value: number) => number;
  readonly minimum: number | undefined;
  readonly maximum: number | undefined;
  /** Whether the range is a cycle: asked for, and with both ends to make one. */
  readonly wrap: boolean;
}

const EMPTY: ParseResult = { ok: false, reason: "empty" };
const INVALID: ParseResult = { ok: false, reason: "invalid" };

// Sticky, so each is tried at a position in the text without slicing it.
const DIGITS = /(?:\d|[.,](?=\d))[\d.,]*/y;
const APOSTROPHE_GROUPED = /[1-9]\d{0,2}(?:'\d{3})+(?!\d)(?:[.,]\d*)?/y;
const EXPONENT = /e[+-]?\d+/iy;
const FRACTION = / +(\d+)\/(\d+)(?![\d.,'/])/y;
const SUFFIX = new RegExp(String.raw` *([^\d\s.,+\-*/()=${DASH}]+)`, "y");
const ANOTHER = / *(?=\d)/y;

const SPACE_GROUPED = /(?<![\d.,'])[1-9]\d{0,2}(?: \d{3})+(?!\d)/g;
const THOUSANDS = /^[1-9]\d{0,2}(?:[.,]\d{3})+$/;
// The Indian system: thousands, then hundreds of them in twos.
const LAKHS = /^[1-9]\d?(?:[.,]\d{2})*[.,]\d{3}$/;
const LITERAL = /^(?:[+-]?n|\(n\))$/;
const NO_FIELDS = (): undefined => undefined;
const NO_VALUES = (): null => null;
const NO_UNITS: ReadonlyMap<string, Unit> = new Map();

interface Separators {
  readonly group: string;
  readonly decimal: string;
  /** Whether groups above the thousands are of two digits. */
  readonly lakhs: boolean;
}

// A locale's separators never change, and every type in it asks for them.
const SEPARATORS = new Map<string, Separators>();

function separators(locale: string): Separators {
  let known = SEPARATORS.get(locale);
  if (known === undefined) SEPARATORS.set(locale, (known = readSeparators(locale)));
  return known;
}

function readSeparators(locale: string): Separators {
  const parts = new Intl.NumberFormat(locale).formatToParts(1234567.8);
  const whole = parts.filter((part) => part.type === "integer");
  return {
    group: normalizeNumeric(parts.find((part) => part.type === "group")?.value ?? ","),
    decimal: parts.find((part) => part.type === "decimal")?.value ?? ".",
    lakhs: whole.length > 2 && whole.at(-2)!.value.length === 2,
  };
}

/**
 * One number as typed, with whatever separators it has, as a plain decimal.
 * With two kinds of separator the last is the point and the others must group
 * thousands. With one kind, the locale's decimal point is one, once; any other
 * groups thousands where it can, and is the point where it occurs once and
 * cannot. Anything else is not a number.
 */
function readDigits(token: string, decimal: "." | ",", lakhs: boolean): string | null {
  const dot = token.lastIndexOf(".");
  const comma = token.lastIndexOf(",");
  if (dot === -1 && comma === -1) return token;
  let point = -1;
  if (dot !== -1 && comma !== -1) {
    point = Math.max(dot, comma);
    const mark = token[point]!;
    if (token.indexOf(mark) !== point || !groups(token.slice(0, point), lakhs && mark === decimal))
      return null;
  } else {
    const mark = dot === -1 ? "," : ".";
    const last = Math.max(dot, comma);
    const once = token.indexOf(mark) === last;
    if (mark === decimal) {
      if (!once) return null;
      point = last;
    } else if (!groups(token, lakhs && decimal === ".")) {
      if (!once) return null;
      point = last;
    }
  }
  if (point === -1) return token.replace(/[.,]/g, "");
  return `${token.slice(0, point).replace(/[.,]/g, "")}.${token.slice(point + 1)}`;
}

function groups(whole: string, lakhs: boolean): boolean {
  return THOUSANDS.test(whole) || (lakhs && LAKHS.test(whole));
}

/** A finite, non-negative number written without an exponent, as a formula reads it. */
function decimalText(value: number): string {
  const text = String(value);
  const at = text.indexOf("e");
  if (at === -1) return text;
  const digits = text.slice(0, at).replace(".", "");
  const shift = Number(text.slice(at + 1));
  // An exponent form has one digit before its point.
  return shift < 0 ? `0.${"0".repeat(-shift - 1)}${digits}` : digits + "0".repeat(shift + 1 - digits.length);
}

/**
 * Rounded to `precision` fraction digits, halves away from zero. It rounds the
 * decimal digits the value is written with rather than scaling it: 1.005 is
 * stored a little below itself, and a large value times a power of ten overflows.
 */
function round(value: number, precision: number): number {
  if (Number.isInteger(value) || !Number.isFinite(value)) return value;
  const text = decimalText(Math.abs(value));
  const cut = text.indexOf(".") + 1 + precision;
  if (cut >= text.length) return value;
  let kept = text.slice(0, precision === 0 ? cut - 1 : cut);
  if (text.charCodeAt(cut) >= 53) {
    // Add one in the last place kept, carrying through the nines.
    let place = kept.length - 1;
    while (place >= 0 && (kept[place] === "9" || kept[place] === ".")) place -= 1;
    const tail = kept.slice(place + 1).replace(/9/g, "0");
    kept = place < 0 ? `1${tail}` : `${kept.slice(0, place)}${Number(kept[place]) + 1}${tail}`;
  }
  const rounded = Number(kept);
  return value < 0 && rounded !== 0 ? -rounded : rounded;
}

function escaped(text: string): string {
  return text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
}

/** Every way the locale writes the currency: its code, symbols and names. */
function currencyForms(locale: string, currency: string): RegExp {
  const forms = new Set([currency]);
  const add = (format: Intl.NumberFormat, amount: number) => {
    for (const part of format.formatToParts(amount)) {
      if (part.type === "currency") forms.add(normalizeNumeric(part.value));
    }
  };
  for (const currencyDisplay of ["symbol", "narrowSymbol", "name"] as const) {
    const format = new Intl.NumberFormat(locale, {
      style: "currency",
      currency,
      currencyDisplay,
      // "1 US dollar", not "1.00 US dollars".
      minimumFractionDigits: 0,
      maximumFractionDigits: 1,
    });
    // Enough amounts to meet each plural form of a name.
    for (const amount of currencyDisplay === "name" ? [0, 1, 2, 3, 11, 100, 0.5] : [1]) add(format, amount);
  }
  // The symbol that says which dollar, `US$`, as written where it is not the local one.
  add(new Intl.NumberFormat("en-001", { style: "currency", currency }), 1);
  // Longest first, so `US$` goes whole rather than leaving `US`.
  const longestFirst = [...forms].toSorted((a, b) => b.length - a.length);
  return new RegExp(longestFirst.map(escaped).join("|"), "giu");
}

function unitIndex(units: Units): ReadonlyMap<string, Unit> {
  const index = new Map<string, Unit>();
  for (const [key, unit] of Object.entries(units)) {
    // Read as the text is, so `″` names what `"` does.
    for (const name of [key, unit.label, ...(unit.aliases ?? [])]) {
      index.set(normalizeNumeric(name).toLowerCase(), unit);
    }
  }
  return index;
}

/** A percent sign written before its number, at the start or after an operator. */
const PERCENT_FIRST = /(^|[-+*/(\s])%\s*(\d[\d.,]*)/g;

export function numberType({
  locale: localeOption = "en-US",
  minimum: minimumOption,
  maximum: maximumOption,
  wrap: wrapOption = false,
  step: stepOption = 1,
  precision: precisionOption,
  kind: kindOption = "decimal",
  currency,
  units,
  unit,
}: NumberTypeOptions = {}): NumberType {
  // Options are taken as given by data as often as by code: what cannot be meant
  // is put right rather than left to fail where a value is shown.
  const locale = validLocale(localeOption);
  const kind = kindOption === "currency" && currency === undefined ? "decimal" : kindOption;
  const step = Number.isFinite(stepOption) && stepOption > 0 ? stepOption : 1;
  const [minimum, maximum] =
    minimumOption !== undefined && maximumOption !== undefined && minimumOption > maximumOption
      ? [maximumOption, minimumOption]
      : [minimumOption, maximumOption];
  const local = separators(locale);
  // A locale whose separators are neither "." nor "," (Arabic's ٫ and ٬) has them
  // rewritten before reading, so the rules below only meet the two common ones.
  const native = local.decimal !== "." && local.decimal !== ",";
  const decimal: "." | "," = native ? "." : (local.decimal as "." | ",");
  const spaceGroups = local.group === " ";
  const index = units === undefined ? NO_UNITS : unitIndex(units);
  const fieldUnit = units !== undefined && unit !== undefined ? units[unit] : undefined;
  // Where `'` is feet it groups thousands only for a locale that writes them so.
  const apostropheGroups = local.group === "'" || !index.has("'");
  const exponents = !index.has("e");

  // A currency has its own minor unit: none for yen, two digits for dollars.
  const own =
    kind === "currency" && precisionOption === undefined
      ? new Intl.NumberFormat(locale, { style: kind, ...(currency !== undefined && { currency }) })
      : undefined;
  const shown = Number.isFinite(precisionOption)
    ? Math.min(Math.max(Math.trunc(precisionOption!), 0), 20)
    : (own?.resolvedOptions().maximumFractionDigits ?? (kind === "percent" ? 4 : 6));
  // A percentage is kept as a fraction, two digits finer than it is shown.
  const precision = kind === "percent" ? shown + 2 : shown;
  const formatter =
    own ??
    new Intl.NumberFormat(locale, {
      style: kind,
      ...(currency !== undefined && { currency }),
      maximumFractionDigits: shown,
    });
  // Built when first read: a type that only shows values never needs it.
  let money: RegExp | undefined;

  const stepSize = kind === "percent" ? step / 100 : step;
  const anchor = minimum ?? 0;
  const period = minimum !== undefined && maximum !== undefined ? maximum - minimum : 0;
  const wrap = wrapOption && period > 0 && Number.isFinite(period);

  /** Brought round into the cycle, which holds its minimum and not its maximum. */
  function wrapped(value: number): number {
    if (!wrap || !Number.isFinite(value)) return value;
    const within = value - anchor - Math.floor((value - anchor) / period) * period;
    return anchor + within;
  }

  function constrain(value: number): number {
    let result = round(value, precision);
    if (wrap && Number.isFinite(result)) {
      result = round(wrapped(result), precision);
      // Rounding may reach the maximum, which is the minimum again.
      if (result >= maximum! || result < minimum!) result = minimum!;
    } else {
      if (minimum !== undefined) result = Math.max(minimum, result);
      if (maximum !== undefined) result = Math.min(maximum, result);
    }
    return Object.is(result, -0) ? 0 : result;
  }

  function parse(text: string, options?: ParseOptions): ParseResult {
    let source = normalizeNumeric(text);
    if (kind === "currency" && currency !== undefined) {
      money ??= currencyForms(locale, currency);
      // A space, so that the numbers either side of a sign stay two.
      source = source.replace(money, " ");
    }
    if (native) source = source.replaceAll(local.group, ",").replaceAll(local.decimal, ".");
    source = source.replace(/×/g, "*").replace(/÷/g, "/").trim();
    if (source === "") return EMPTY;
    if (source.startsWith("=")) source = source.slice(1);
    // Some locales write the percent sign first: %50.
    source = source.replace(PERCENT_FIRST, "$1$2%");
    if (spaceGroups) source = source.replace(SPACE_GROUPED, (grouped) => grouped.replaceAll(" ", ""));

    let expression = "";
    // The text's outline: `n` for each quantity, and each operator as itself.
    let shape = "";
    let operand = false;
    let percent = false;
    let quantity = 0;
    let at = 0;
    while (at < source.length) {
      const char = source[at]!;
      if (char === " ") {
        at += 1;
      } else if (char === DASH) {
        // A dash between two numbers is a range, which is not a number.
        if (operand) return INVALID;
        expression += "-";
        shape += "-";
        at += 1;
      } else if ("+-*/()".includes(char)) {
        expression += char;
        shape += char;
        operand = char === ")";
        at += 1;
      } else {
        // Two numbers with nothing between them are not one.
        if (operand) return INVALID;
        // A quantity: a number, or measures from the larger unit down, in the field's unit.
        let total = 0;
        let larger = Infinity;
        for (;;) {
          APOSTROPHE_GROUPED.lastIndex = at;
          DIGITS.lastIndex = at;
          const grouped = apostropheGroups ? APOSTROPHE_GROUPED.exec(source) : null;
          const token = grouped ?? DIGITS.exec(source);
          if (token === null) return INVALID;
          at += token[0].length;
          const plain = readDigits(
            grouped === null ? token[0] : token[0].replaceAll("'", ""),
            decimal,
            local.lakhs,
          );
          if (plain === null) return INVALID;

          let amount = Number(plain);
          EXPONENT.lastIndex = at;
          FRACTION.lastIndex = at;
          const exponent = exponents ? EXPONENT.exec(source) : null;
          const fraction = exponent === null && !plain.includes(".") ? FRACTION.exec(source) : null;
          if (exponent !== null) {
            amount = Number(plain + exponent[0]);
            at += exponent[0].length;
          } else if (fraction !== null) {
            const over = Number(fraction[1]);
            const under = Number(fraction[2]);
            // Only a proper fraction makes a mixed number; `1 3/2` is two numbers.
            if (over > 0 && over < under) {
              amount += over / under;
              at += fraction[0].length;
            }
          }

          SUFFIX.lastIndex = at;
          const suffix = SUFFIX.exec(source);
          if (suffix === null || suffix[1] === "%") {
            if (larger !== Infinity) return INVALID;
            total = amount;
            if (suffix !== null) {
              total /= 100;
              percent = true;
              at += suffix[0].length;
            }
            break;
          }
          at += suffix[0].length;
          const typed = index.get(suffix[1]!.toLowerCase());
          if (typed === undefined || fieldUnit === undefined || typed.factor >= larger) return INVALID;
          total += amount * typed.factor;
          larger = typed.factor;
          ANOTHER.lastIndex = at;
          const another = ANOTHER.exec(source);
          if (another === null) {
            total /= fieldUnit.factor;
            break;
          }
          at += another[0].length;
        }
        if (!Number.isFinite(total)) return INVALID;
        expression += decimalText(total);
        quantity = total;
        shape += "n";
        operand = true;
      }
    }
    const literal = LITERAL.test(shape);
    if (options?.arithmetic === false && !literal) return INVALID;

    let value: number;
    if (literal) {
      // One number is its own value — negative after a minus, or wholly in
      // parentheses as accounts write it — and a paste of many compiles nothing.
      value = shape === "-n" || shape === "(n)" ? 0 - quantity : quantity;
    } else {
      // Arithmetic is a formula that names no field: one language, read one way.
      const compiled = compileFormula(expression, NO_FIELDS);
      const result = compiled.ok ? compiled.evaluate(NO_VALUES) : null;
      if (typeof result !== "number") return INVALID;
      value = result;
    }
    // Without a percent sign anywhere, what a percentage field is given is in percent.
    if (kind === "percent" && !percent) value /= 100;
    // Read round the cycle, not held at its end: 370° is 10°, and nothing was limited.
    return { ok: true, value: wrapped(value) };
  }

  function format(value: number): string {
    // Zero is shown as zero, whichever side it was approached from.
    const number = formatter.format(value === 0 ? 0 : value);
    if (fieldUnit === undefined) return number;
    return fieldUnit.attached ? `${number}${fieldUnit.label}` : `${number} ${fieldUnit.label}`;
  }

  function stepped(value: number, count: number, times = 1): number {
    if (count === 0 || !(times >= 1)) return constrain(value);
    // Less than one step is a finer grid; one the precision cannot keep is a whole step.
    const part = Math.min(1, Math.abs(count));
    const fine = stepSize * part;
    const keeps = Math.abs(round(fine, precision) - fine) <= fine * 1e-9;
    const size = keeps ? fine : stepSize;
    const steps = (keeps ? count / part : Math.sign(count)) * Math.trunc(times);
    const position = (value - anchor) / size;
    const nearest = Math.round(position);
    // Between two multiples, the first step goes to the next one that way.
    const from =
      Math.abs(position - nearest) <= 1e-9 * Math.max(1, Math.abs(position))
        ? nearest
        : steps > 0
          ? Math.floor(position)
          : Math.ceil(position);
    return constrain(anchor + (from + steps) * size);
  }

  return { parse, format, step: stepped, constrain, minimum, maximum, wrap };
}