Status: implemented, pre-release (0.1.0). This document is the source of truth for the design: the decisions made, and the questions still open.
quanto is a library for turning messy human text into well-typed values, and back again, for any kind of value: lengths, weights, durations, money, dates, and custom types.
Think of it as a universal moment.js, generalized beyond dates, or as "what if Zod were unit-aware". 5'11", 180cm and 1,8 m all parse into the same typed value, which can be converted, compared and formatted.
It ships as a core with no codecs, plus codec packages built on the core's public API (see Packaging):
quantois the protocol: the codec contract, the definition helpers (defineCodec,defineExternalCodec,quantity,defineRange), context, issues, composition (merge,optional,approx,infinite,range,dimensions) and the shared parsing primitives. It holds strong opinions, and every codec is expected to conform to them.- The codecs are a bootstrap:
@quantojs/commonhas the settled common ones (quantities, numbers, money, odds, text), and dedicated packages have domains that are incomplete or still evolving, like dates (@quantojs/datetime). Use them as they are, copy and modify one, or write your own: a codec of yours is no different from theirs.
The input component (<QuantoInput />) is one consumer of this core, shipped as a separate package. It is not the core, and nothing UI-specific lives in the core.
- Values are plain, immutable, JSON-safe data. No class instances. The same value works on client and server without serializers.
- Operations are standalone functions. Nothing is hung off a prototype or namespace object. Everything is a named export and tree-shakes. There are no overloads. Subpaths and packages act as namespaces, so
quanto/quantityand@quantojs/common/moneycan both exportconvert; import with an alias when a file needs both. - Context is explicit when you want it, inferred when you don't.
localeandnowcan be passed in. Whennowis missing, parsing uses the machine's clock and local offset, because that is what the person typing means. Every successful parse reports thecontextit was based on (see Parse context), so apps that want replayability can keep it. There is no global, mutable context. - Don't rebuild what exists. Validation is delegated to Standard Schema libraries (Zod, Valibot, ArkType…). Opt-in rich formatters lean on
Intl. Date math, if ever shipped, is a separate package built on Temporal. - One contract, two authors. First-party codecs and custom (often LLM-written) codecs use the exact same API and the same fixture requirements. Every first-party codec is defined with
defineCodecand the exported primitives, in a package separate from the core, so it can't reach into internals. First-party codecs get no special hooks. - Explicit over ergonomic. Agents will write most custom codecs and unit tables, so APIs favor explicitness and good descriptions over brevity.
- Common case only, in the first-party codecs. First-party codecs implement common-sense behaviour with no knobs for edge cases. Anyone who needs different behaviour writes a custom codec, which has full control over parsing, formatting and units.
- Stable once stored. User ergonomics come first at parse time: parsing may infer from the machine (clock, local offset). Stability is required of what's stored: once
rawandvalueexist they are the source of truth and are never re-parsed behind the user's back. Given the same text and the same context, parsing gives the same value on every runtime, because the data it needs (currency minor units, month names, separators) is bundled rather than read fromIntl. Default formatting uses the same bundled data;Intlis used only by opt-in formatters (intlUnit,intlMoney, the date ones). - Start small. Ship the minimum contract and build iteratively. Deferred items are listed at the end.
A codec is the pair of a parser and a formatter for a value T. (The word "type" is avoided because it collides with TypeScript's keyword in prose, props and generics.)
interface Codec<T> {
id: string; // e.g. 'length'; used to tag merged results
parse(text: string, ctx?: Ctx): ParseResult<T>;
format(value: T, ctx?: Ctx): string;
schema: StandardSchemaV1<T>; // validates a structured T (see below)
}
type ParseResult<T> =
| { ok: true; value: T; context: ParseContext; alternatives?: T[] }
| { ok: false; issues: Issue[]; alternatives?: T[] }; // alternatives: other readings of the text; see Alternatives
type Ctx = {
locale?: string; // BCP 47. Missing → en-US. See Locales.
now?: string; // RFC 3339 timestamp with a UTC offset. Missing → the machine's clock and local offset.
grammars?: Grammar[]; // other languages' grammars, tried before the built-in English. See Grammars.
};
// The context a parse was based on, whether passed in or inferred.
// Passing it back as ctx reproduces the value.
type ParseContext = {
locale: string; // always present: the canonicalized ctx.locale, or 'en-US'
now?: string; // present only if the parse read the clock
};
// e.g. parse('tomorrow') without ctx → context: { locale: 'en-US', now: '2026-09-30T14:02:11-04:00' }parsenever throws on bad input. Every failure is aParseResultwith issues. Programmer errors throw, with a message that says what is wrong and what to do about it. Almost all are raised by factory functions at definition time (unknown unit, alias collisions, a malformedid…). Two can only surface when parse runs: an asyncschema(see below), and a custom codec whoseparsereturns a value its owncheckrejects.formatthrows on a malformed value, one that fails the codec's structural check (a unit not in the table, a non-finite number, a malformed date string). That is a programmer error, like an operation given a mismatched currency.formatdoesn't run the user'sschema, so a stored value that a later, stricter schema rejects still displays.formatWithFallback(codec, value, fallback, ctx?)is for displaying data that may be malformed (legacy rows, a unit since removed from the table). It returnsfallbackwhereformatwould throw on a malformed value.isInvalidValueError(error)tells that error apart for callers that handle it themselves. Any other error, such as a bug in a formatter, still throws, so the fallback never hides bugs.valueis typedunknown, since the point is untrusted data.parseandformatare synchronous. Parsing that has to call out (a model, a server) uses a separate interface, external codecs, with an asyncparseand the same syncformatandschema(see External codecs);Codecwill not change.- Round-trip. A formatter round-trips when
parse(format(v))succeeds and differs fromvby no more than the formatter's rounding. Formatting is the only place precision is lost.- First-party default formatters must round-trip. That matters wherever formatted text becomes editable
raw: a picker ordefaultValuesetsraw = format(value), and formatted display shows text the user then edits. A formatter that doesn't round-trip turns one keystroke and a blur into a silently changed value (1.83 mshown as2 m, re-parsed as 2 m). - Custom formatters should round-trip, whether a custom codec's default or a
formatoption. Display-only formatters (prose, heavy rounding) are allowed. A field using one must not show formatted text as editable (usedisplay="raw"in the component) and must not setrawfrom it.
- First-party default formatters must round-trip. That matters wherever formatted text becomes editable
schemais a composed Standard Schema overT: quanto's own structural check (the unit is in the codec's table, the currency is known, the string is a valid date…) followed by the user-suppliedschemaoption, if any. Servers validate stored values with it and never need to re-parse. The codec itself is deliberately not a Standard Schema, because "validate a string" and "validate a structured value" are different operations.
Codecs are created by factory functions that take an options object. Option names shared across codecs:
| Option | Meaning |
|---|---|
schema |
A synchronous Standard Schema from T to T (StandardSchemaV1<T, T>). parse runs it on the parsed value and keeps its output, so it can normalize as well as validate (round, clamp to a canonical form). Transforms must be idempotent, because the schema runs again on values that already went through it: a server validating a stored value, or re-parsing formatted text. This is the only validation mechanism. |
format |
(value: T, ctx: ResolvedCtx) => string. Replaces the codec's default formatter. Should round-trip (see above). |
These two make up CodecOptions<T>. Codec-specific options (e.g. defaultUnit, canonicalUnit, defaultCurrency) extend it and are documented per codec.
defineCodec is how every codec is built, first-party or custom. The author supplies the parts specific to the value; defineCodec supplies everything every codec must do the same way.
import { defineCodec, readNumber, formatNumber, type CodecOptions } from 'quanto';
export const percent = (options?: CodecOptions<number>) =>
defineCodec<number>({
id: 'percent',
parse(text, ctx) {
const n = readNumber(text, ctx);
if (!n || !/^\s*%?$/.test(text.slice(n.end))) {
return { ok: false, issues: [{ code: 'unparseable', message: 'Expected a percentage, like "12.5%".' }] };
}
return { ok: true, value: n.value };
},
format: (value, ctx) => `${formatNumber(value, ctx, { maxFractionDigits: 2 })}%`,
check: (value) => (typeof value === 'number' && Number.isFinite(value) ? [] : [{ message: 'Expected a finite number.' }]),
options,
});The author supplies:
| Field | Meaning |
|---|---|
id |
Letters, digits, _, - and .. Parentheses and commas are reserved for derived ids (see Merging codecs). |
parse(text, ctx) |
Returns ParseOutcome<T>: { ok: true; value; alternatives? } | { ok: false; issues; alternatives? }. No context. |
format(value, ctx) |
The default formatter. |
check(value: unknown) |
The structural check: returns { message, path? }[], empty when value is a well-formed T. |
options? |
The caller's CodecOptions<T>, passed through. |
defineCodec then:
- Returns an
emptyissue for empty or whitespace-only input, without calling the author'sparse. The author'sparsereceives the text trimmed, but otherwise untouched: callnormalizeif the codec wants it. - Resolves
ctxinto aResolvedCtx(below) and passes it toparseandformat. - Runs
checkon the parsed value. A failure is a bug in the codec, so it throws. - Runs
options.schemaand keeps its output as the value. Its issues are wrapped withcode: 'invalid'; a Promise throws. - Runs
checkandoptions.schemaon each alternative the same way, keeping the schema's output. Acheckfailure throws; an alternative the schema rejects is dropped, since it can't be chosen. - Attaches
contextto a successful result. - Uses
options.formatin place of the author'sformatwhen given. - Runs
checkbefore formatting, and throws quanto's invalid-value error when it fails. That is the errorformatWithFallbackcatches. - Builds
schema:check, thenoptions.schema, as one Standard Schema.
interface ResolvedCtx {
locale: Locale; // the resolved bundled data for ctx.locale; see Locales
now(): string; // ctx.now, or the machine's clock and local offset.
// Calling it records `now` in the result's context.
grammars: Grammar[]; // ctx.grammars, or none
// …and any package's extensions (CtxExtensions), as passed
}Reading the clock through ctx.now() is what keeps context accurate without any bookkeeping in the codec.
Extensions. A package that needs app-wide settings of its own declares a key on ctx by augmenting the CtxExtensions interface, which Ctx and ResolvedCtx both extend:
declare module 'quanto' {
interface CtxExtensions {
readonly music?: MusicCtx; // @quantojs/music: { key?: string }
}
}quanto passes every key it doesn't own through to ResolvedCtx untouched, so the setting reaches codecs inside merge, range and approx, and comes from the React provider like locale does. Each package validates its own key and owns its meaning. One key per package, named for it, so packages can't collide. Extensions aren't recorded in ParseContext, so an extension that changes what parse returns makes replay depend on passing it again; keep them to formatting where possible (ctx.music.key only changes spelling).
The lexing every first-party codec uses is exported from quanto, so custom codecs get the same locale behaviour and the same determinism:
normalize(text): the input normalization described under Locale-aware number parsing: quotes and primes, Unicode fractions (5½→5 1/2, and typeset1¹⁄₂→1 1/2), superscript exponents on a number (10³→10^3), NFKC (so full-width digits read as digits), the Unicode minus sign, and runs of any whitespace to one space. It doesn't fold case or trim.readNumber(text, ctx, { from?, suffixes? }): reads one number starting atfrom(default 0, leading spaces skipped) using the locale rules, and returns{ value, end }orundefined. It accepts a leading-or+; whether negatives make sense is the codec's call.suffixes: trueacceptsk,m,b/bnandt, attached to the number and case-insensitive. Withoutsuffixes, it reads a product of powers (10^3,2*5,6.02×10^23) and math constants (π/2,2π); see Locale-aware number parsing. When the locale's reading of an ambiguous separator gives invalid grouping, the other reading is used (1234,567in en-US is 1234.567).formatNumber(n, ctx, { maxFractionDigits?, minFractionDigits? }): formats with the region's bundled separators and grouping, so the result reads back withreadNumber. Defaults: at most 2 fraction digits, at least 0; rounding is half away from zero, on the number's shortest decimal representation (1.005→1.01at two digits). Space and apostrophe grouping print as plainand'. From 10²¹ up, where a double's digits past the first 17 are noise, it prints a power of ten instead:1.62×10⁶⁵, or10¹⁰⁰when the factor is 1, with the fraction-digit options applying to the factor.readNumberreads both back (superscripts normalize to^).readNumberToken(text, ctx, { from? })andreadWordToken(text, ctx, from): read a number in digits or in words likereadNumber, but keep its digits as text (aNumberToken), for codecs that must stay exact.formatDecimalParts(int, frac, ctx)prints such digits with the locale's separators. Money uses all three.numberSpan(side, ctx, read): where the first number in one side of a range starts and ends, fordefineRangerules that complete a side (money's range uses it).ctx.locale: the resolved language and region, the number separators and grouping, and the measurement system, for codecs that need them directly.lookupRegional(locale, regions, exceptions?): looks up a codec's own region-keyed data (thelanguage-regionrow if there is one, otherwise the region's), so every codec resolves a locale the same way.@quantojs/common/moneyuses it for currencies,@quantojs/datetimefor date orders and clocks, and custom codecs for their own tables.
The built-in date grammar is not exported in v1.
A successful parse reports the context it was based on: the locale (always) and now (when the parse read the clock), whether they were passed in or inferred. merge reports the union over the codecs it ran.
It is deliberately not part of the stored envelope. value is authoritative, so reading a stored value never needs it. Context is only needed to replay a parse: auditing, debugging a surprising value, or migrating stored values after a parser fix. That is rare, so apps opt in by keeping it wherever suits them:
const result = height.parse(raw, ctx);
if (result.ok) {
save({ raw, value: result.value }); // the envelope
audit.record(fieldId, result.context); // optional, stored separately
}
// later: replay
height.parse(raw, storedContext); // the same value, on the same package versionsParseContextis assignable toCtx, so replay needs no merging. It doesn't recordgrammars, which are code, not data: replay passes the same grammars alongside it.- Replay is exact on the same versions of
quantoand of the package that owns the codec: a date's result depends on@quantojs/datetimeas well as the core. A later version of either can differ where its parser or bundled data changed, which is exactly what a migration wants to detect.contextdoesn't record versions; apps that need exact replay keep their dependency versions alongside (a lockfile in their history is usually enough).
A grammar is how one language writes the things codecs read. English is built in and always on; other languages are opt-in, passed in ctx:
const ctx = { locale: 'de-DE', grammars: [de] };
length().parse('fünfundzwanzig m', ctx); // 25 m; "twenty-five m" still reads toointerface Grammar {
language: string; // BCP 47 language subtag: 'de'
numbers?: NumberGrammar; // numbers in words
}
interface NumberGrammar {
// A number in words at `from`, as plain digit text ("1500.5", "2/3", "2 3/4") and the index past the words.
read(text: string, from: number): { text: string; end: number } | undefined;
}- Open-ended by design. Every part of a grammar is optional, and each codec uses the parts it knows.
numbersis the first; others (unit words, relative dates) can be added as new optional parts without changing the API. - A grammar returns digits, not a number. quanto reads the digit text with its own exact lexer, so money stays exact and thirds stay fractions, and a grammar can't produce a value quanto couldn't read from digits. Text that isn't plain digit text throws, naming the grammar.
- A grammar reads one well-formed number or nothing. It should return undefined for words that don't form one number (
two fifty), so nothing is guessed. - In
ctx, not on each codec, unlike month names: number words matter to every codec, andctxreaches throughmerge,rangeandapprox, and through the React provider, so an app sets them once. Being opt-in keeps the rule that a field changes only when the app passes something different. ctx.grammarsare tried in order, then English; the first that reads a number wins.
Every issue quanto produces has the Standard Schema issue shape plus a code from a closed union:
type IssueCode =
| 'empty' // input is empty or whitespace only
| 'unparseable' // text could not be understood at all
| 'missing_unit' // bare number and the codec has no defaultUnit
| 'unknown_unit' // a unit was written but isn't in the codec's unit table ("70 kg" in a length field)
| 'incompatible_unit' // a unit on another scale than the one required ("85 dB" where canonicalUnit is dBA)
| 'missing_currency' // bare number and the codec has no defaultCurrency
| 'unknown_currency' // a currency was written but isn't known ("12 XYZ", "12 bananas")
| 'excess_precision' // "$3.459" for a two-decimal currency
| 'wrong_count' // more or fewer parts than allowed ("24 × 36 × 10" where two are)
| 'ambiguous' // the text reads several ways and the codec won't choose; see Alternatives
| 'invalid'; // the user's schema rejected the value (or, server-side, the codec's structural check did)
type Issue = {
code: IssueCode;
message: string;
path?: PropertyKey[];
codec?: string; // set by merge(): the id of the codec that reported it
};- Issues from the user's
schemaare passed through unchanged, wrapped withcode: 'invalid'. Theirpathis relative toT. messageis English. UIs that need localization map oncode.- The union is exported so fixtures and consumers can reference codes by type, not by string.
- Parse failures belong to quanto: text that can't be understood, or a unit the codec doesn't know. The value doesn't exist yet, so there's nothing to validate.
- Validation belongs to the user's
schema:too tall,must be in the future, etc.
Both are reported as Standard Schema–shaped issues, so a UI renders them the same way. quanto has no constraint API of its own: no min, no max, no chaining.
schema must be synchronous, for every codec, external ones included: quanto has no async validation, and checks that need a server belong to the app after commit. Standard Schema has no async flag, so an async schema can't be detected at definition time: if its validate() returns a Promise, parse throws.
parse('') (and whitespace-only input) returns an empty issue. Required-ness is therefore the default. To allow empty:
const maybeHeight = optional(length({ defaultUnit: 'in' }));
maybeHeight.parse(''); // { ok: true, value: null }optional is a wrapper, like range. It keeps the inner codec's id. Its value type is T | null and its schema accepts null. It is the one wrapper that also takes an external codec, since it never parses (see External codecs).
const height = approx(length({ defaultUnit: 'ft' }));
height.parse('about 6 ft'); // { ok: true, value: { value: { value: 6, unit: 'ft' }, approximate: true } }
height.parse('6 ft'); // approximate: falseapprox(codec, options?)is a wrapper whose value is{ value: T; approximate: boolean }, so the inner codec and its values are untouched, and only fields that want the distinction pay for it.- Markers:
~,∼,≈,about,around,approx.,approximately,roughly,circa,ca.before the value;or soand-ish/ishafter it (5 ft-ish,5ish), or-ishon the number (10-ish minutes). At most one before and one after; anything else goes to the inner codec, which rejects it. - Text without a marker parses as
approximate: false. Formatting prefixes~when approximate (~5 ft). The id isapprox(<inner id>); the inner codec's alternatives are wrapped too. - It composes over ranges:
approx(range(length()))readsabout 5-7 ft.5+isn't an approximation: it's an open range.
const quota = infinite(dataSize());
quota.parse('unlimited'); // { ok: true, value: { infinite: 1 } }
quota.parse('-∞'); // { ok: true, value: { infinite: -1 } }
quota.parse('500 GB'); // { ok: true, value: { value: 500, unit: 'GB' } }
quota.parse('∞ TB'); // { ok: true, value: { infinite: 1, unit: 'TB' } }
const limit = infinite(merge([length(), dataSize()]));
limit.parse('unlimited GB'); // { codec: 'dataSize', value: { infinite: 1, unit: 'GB' } }
limit.parse('infinite length'); // { codec: 'length', value: { infinite: 1 } }
limit.parse('∞'); // { infinite: 1 }infinite(codec, options?)is a wrapper whose value isT | Infinite, whereInfiniteis{ infinite: 1 | -1, unit? }. Infinity is the natural model for "unlimited" and "never", and a wrapper keeps it out of every value type that doesn't want it, asoptionalandapproxdo.- Plain data, not
Infinity.JSON.stringifyturnsInfinityintonulland drops symbols, so the marker is an object. It's an object rather than a string like'infinity'so it can't collide with a string-valued codec (infinite(text())). The sign is1or-1, likeMath.signandcompare, sov.infinite * Infinityis the number when an app wants one.isInfinite(v)tells the two apart. - Words:
∞,infandinfinity, each with an optional+or-(-∞,- inf), are always read. Besides those,infinite,unlimited,no limitandunboundedmean positive infinity, andnegative infinityandminus infinitymean negative. Matching ignores case. The words are checked before the inner codec, so they win even over one that reads any text. A sign goes only on the symbols:-unlimitedis unparseable. - Units are kept on a best-effort basis. Infinity is the same in every unit, but the unit says what kind of value was meant, which a merge needs to pick a member, and it lets a field show
∞ GBafter blur rather than drop it. After an infinity,∞ ft,∞ft,infinite feetor-∞ inkeeps the unit: the wrapper asks the inner codec to parse1 <unit>(or1<unit>, for∞') and takes the unit from its value, so the inner codec's aliases, case rules,canonicalUnit(∞ ftin a field canonical in inches is{ infinite: 1, unit: 'in' }) and a merge's member order all apply, and its issues are reported as they are (∞ kgin a length field isunknown_unit). Only values with aunitkeep one: quantities, not money or dates, where text after the infinity is unparseable. A number or a sign after it is unparseable too (∞ 5 ft,∞ ft 6 in). - Kinds: a codec's id, as words (
length,data size), names its kind:infinite length,∞ data size. Over a merge it picks the member; on a single codec it's the codec's own kind and changes nothing. - Over a merge, an infinity with a unit or a kind is tagged like any merged value (
{ codec: 'dataSize', value: { infinite: 1, unit: 'GB' } }), so apps switch onvalue.codecas usual and testvalue.value. A bare∞names no kind, so it's untagged ({ infinite: 1 }). The type isWithInfinite<T>. wordsreplaces the extra words for a field's own vocabulary:infinite(date(), { words: { positive: ['never'] } })for an expiry.positiveandnegativeare replaced separately, and the symbols stay. It's the wrapper's one option besidesschemaandformat, for the same reason the date codecs takenames: the vocabulary is data.- Formatting prints
∞and-∞, or the first word of the matching list when one was passed (never), then the unit's first alias with quantity formatting's spacing (∞ GB,-∞ ft), or the kind (∞ length). Other values use the inner codec's format. It round-trips. - The structural check accepts an infinity whose unit is in the codec's unit table (a merge member's, for a tagged one) and whose tag names a member, and checks other values with the inner codec's schema.
- Both signs are always read. A field where one makes no sense (a negative quota) rejects it with its
schema, as with any other out-of-range value. - The id is
infinite(<inner id>). The inner codec's alternatives pass through, and an infinite parse reports a context with the locale only. It takes sync codecs only, likeapprox. It isn't a quantity codec, sorange()doesn't take it; a range with an infinite end is an open range (5+), whose missing side isnull. @quantojs/anythingwraps its merge in it (see Anything), tagging a bare infinity itself so its values stay tagged.
An alternative is another reading of the text as typed: a whole value, not a guess at what the text might grow into (that's a completion; see Completions). Any codec can report them, on either branch:
postalCode().parse('90210');
// { ok: false, issues: [{ code: 'ambiguous', … }],
// alternatives: [{ country: 'US', code: '90210' }, { country: 'DE', code: '90210' }, { country: 'FR', code: '90210' }] }- On success, the codec chose and is saying what else it could have meant: merge's later codecs, or a codec's own second reading.
- On failure, with an
ambiguousissue, the codec won't choose: the readings are equally good, and picking one would be a silent guess. The person chooses. - On failure with another issue, an alternative is a correction the codec won't apply silently but can name:
coordinatesoffers the swapped reading of a pair that's out of range in the field's order (-74.006, 40.7128in a lat,lng field). Choosing it is the person's call, as withambiguous. - Choosing one replaces
valueand keepsraw, on either branch:rawis still what was typed, and the alternative is a reading of it. It commits without parsing again, after the schema (which already ran on it at parse time; schemas are idempotent, so running it again is safe, and one path is simpler than tracking what was validated). The fields'choose(value)does it (achooseevent in both machines); the reading it replaces becomes an alternative, so the choice can be undone.pickis different: it's for pickers, and setsrawtoformat(value). - Alternatives are values, so they go through the codec's
checkand schema like the value (see Defining a codec), and they are a parse-time hint: never stored. A failed commit stores{ raw, issues }; choosing an alternative afterwards stores{ raw, value }. - Alternatives are plain
Ts, JSON-safe and compared by fixtures. Candidates that are only labels until fetched are completions, which only external codecs have. - A value the user's schema rejects comes back as
invalidissues, still with the alternatives that passed. - Wrappers:
mergetags them (below);approx,infiniteandoptionalwrap them;rangeanddefineRangedon't report their sides' alternatives, since a range already chooses between readings by its completion rules.
const heightOrWeight = merge([length(), mass()]);- Takes an array of codecs. Earlier codecs win: the first whose
parsesucceeds provides the value. The order therefore also decides what bare input means. - The result is tagged by
id:{ codec: 'length', value: … } | { codec: 'mass', value: … }.formatdispatches on the tag. - Alternatives are reported. A successful
ParseResultalso carriesalternatives, tagged: every other reading of the text, in codec order, where each member contributes its value (if it parsed) and then its own alternatives. So anambiguousmember's readings are offered even when another member wins.1mundermerge([length(), duration()])returns length asvalueand duration inalternatives, so a UI can offer a chooser instead of guessing silently. See Alternatives. - Failures report everything. If every codec fails, the result carries all of their issues, in codec order, each with
codecset to the id that reported it, and all of their alternatives, tagged, in the same order. - Two codecs with the same
idare a definition-time error. - Its
idis derived, so it is deterministic:merge(length,mass), from the inner ids in order. Nested merges flatten:merge([merge([a, b]), c])is the same codec asmerge([a, b, c]), and tags are always leaf ids. Custom ids can't contain parentheses or commas, so derived ids never collide with them. - Empty input is one
emptyissue, not one per codec. contextcombines the contexts of the codecs that parsed: the locale, andnowif any of them read the clock.- One clock per parse. Without
ctx.now, the first member to read the machine clock pins that reading: later members get it asctx.now, so they all agree.defineRangedoes the same across both sides, sotoday-tomorrowcan't straddle midnight. Completion doesn't read the clock at all (see Ranges). - Types: the value is
Tagged<T> = { codec: string; value: T }, whereTis the union of the leaf codecs' value types. Codec ids are plain strings at the type level, so narrowing oncodecdoesn't narrowvalue; check the value's shape or cast after checking the tag. The merged codec exposes its leaf codecs ascodecs. - Merge is expected to be uncommon. Custom codecs are the primary extension mechanism.
Length, mass, duration, temperature, volume, area and so on are all quantities: a number plus a unit from the codec's unit table.
type Quantity<U extends string = string> = { value: number; unit: U };
// e.g. { value: 71, unit: 'in' }- A value keeps the unit the user typed, unless the codec sets
canonicalUnit. valueis a JS number. Precision drift from conversion (70.99999999) is expected and is removed by rounding informat, never in stored values.- Conversion factors are exact where they are defined exactly (1 in = 0.0254 m).
A quantity codec is built from a unit table: a plain object mapping unit IDs to a conversion factor and aliases. There is no separate "dimension" concept; the codec carries its table. quantity(), from quanto, is a definition helper, not a codec: @quantojs/common's quantity codecs are defined with it, exactly as users and agents define theirs:
export const lengthUnits = {
m: { toBase: 1, aliases: ['m', 'meter', 'meters', 'metre', 'metres'] },
cm: { toBase: 0.01, aliases: ['cm', 'centimeter', 'centimeters'] },
in: { toBase: 0.0254, aliases: ['in', 'inch', 'inches', '"', '″'] },
ft: { toBase: 0.3048, aliases: ['ft', 'foot', 'feet', "'", '′'] },
// …
} satisfies UnitTable;
export const length = (options?: QuantityOptions<keyof typeof lengthUnits>) =>
quantity({ id: 'length', units: lengthUnits, ...options });
// Adding units is a plain object spread. No global mutation.
const horseHeight = quantity({
id: 'horseHeight',
units: { ...lengthUnits, hand: { toBase: 0.1016, aliases: ['hh', 'hand', 'hands'] } },
defaultUnit: 'hand',
});
// A brand-new kind of quantity.
const dataSize = quantity({ id: 'dataSize', units: { /* B, KB, KiB, … */ } });TypeScript types (unit IDs, Quantity<U>) are inferred from the table's literal type. There is no interface merging and no global registry.
- Unit IDs are the stored identifiers. They only need to be unique within a table, and are kept compact (
mWandMW,MbpsandMBps), even where two differ only by case. - Aliases are resolved within the codec's own table.
1mis one meter to a length codec and one minute to a duration codec. A unit from some other table (70 kgin a length field) is simply anunknown_unit. Users never see unit IDs. - Alias matching is case-insensitive, after Unicode normalization:
3Mand3mare both meters. The one exception is worked out from the table, with nothing to configure: when aliases of different units differ only by case, likemW(milliwatt) andMW(megawatt), orMb(megabit) andMB(megabyte), each of them matches only exactly as written. Input that matches none of them exactly, likemwin that table, is anunknown_unitrather than a guess. Where one reading is the common case, the table lists that spelling as an alias too:MB: { aliases: ['MB', 'mb', …] }makesmbread as megabytes whileMbstays megabits. The same alias on two units is a definition-time error. - Linear, affine and function units.
toBasesays how a unit converts to the table's base unit, and its form declares what kind of unit it is; nothing is inferred:- a number is a linear factor (
in: { toBase: 0.0254 }). Converting between two units is value ×from.toBase÷to.toBase, with decimal factors scaled to integers first so results are exact. Use this form whenever the conversion is a plain factor. { factor, offset }is affine (temperature): base = value × factor + offset. With kelvin as the base, °C is{ factor: 1, offset: 273.15 }.- a function, with a sibling
fromBasefunction, is for conversions that aren't "multiply, then maybe add": fuel economy ('l/100km': { toBase: (v) => 100 / v, fromBase: (v) => 100 / v, … }with km/L as the base) or wire gauge.- Having one of the pair without the other is a definition-time error, and so is a failed spot check. At fixed sample points (
0.5, 1, 2, 10, 100, skipping any wheretoBaseisn't finite),fromBase(toBase(x))must come back toxwithin tolerance, andtoBasemust be strictly increasing or strictly decreasing. The check catches a missing, swapped or wrong inverse; theroundTripfixtures stay the real safety net. - The functions must be strictly monotonic, or they can't invert each other and
comparehas no meaning. Tables should pick a base where bigger means more: km/L, not L/100km, for fuel economy. - A value with no base value (
0 L/100km), or none incanonicalUnit(0 mpgwhen the canonical unit is L/100km), isunparseable: the input means nothing. Merely out-of-range values (-5 mpg) are left to the user's schema, as for every quantity.convertthrows on a non-finite result.
- Having one of the pair without the other is a definition-time error, and so is a failed spot check. At fixed sample points (
- a number is a linear factor (
- Only linear units compound (
5 ft 11 in) or serve as asubunit. Affine and function units convert and compare only. subunit(optional) names the unit a trailing bare number takes after this one:ft: { …, subunit: 'in' }makes5'11read as 5 ft 11 in, andm: { …, subunit: 'cm' }makes1m80read as 180 cm. It's explicit rather than guessed from the table, and must name a smaller linear unit.quantity()checks at definition time that no alias belongs to two units (see the case rule above), that subunits and function units are well formed, and thatdefaultUnitandcanonicalUnitname real units.
Operations are opt-in, live in their own subpath, and are a convenience rather than the core. There are two, convert and compare: the ones apps would get wrong on their own (exact conversion, affine and function units, tolerance).
Operations take a quantity codec as their first argument. The codec carries the unit table, so unit checking is explicit at both type level and runtime:
import { convert, compare } from 'quanto/quantity';
const len = length();
convert(len, h, 'cm'); // Quantity<'cm'>
compare(len, a, b); // -1 | 0 | 1-
Arithmetic is left to apps. Whether adding two values means anything is a domain question (
300 K + 5 K,30 mpg + 40 mpg, an absolute temperature plus a difference), and the app knows its domain. Withconvertit's one line:{ value: a.value + convert(len, b, a.unit).value, unit: a.unit }. -
A value whose unit isn't in the codec's table is a type error, and throws at runtime (covering untyped JSON).
-
compareuses a relative tolerance of1e-9on base-unit values. Five feet and sixty inches differ by an ulp after conversion; without tolerance they would compare unequal. It orders values in the base unit: with a base where bigger means more, bigger compares greater. Likeconvert, it throws on a value with no finite base value (0 L/100km), which would otherwise compare equal to everything. -
Values aren't tied to the codec that parsed them. A
heightvalue and aroadDistancevalue are both lengths, and any codec whose table contains both units can convert and compare them. The results of operations are not re-validated against any codec's schema. -
Out of scope: dimensional algebra (length ÷ time = speed, compound SI units).
const height = length({
defaultUnit: 'in', // what a bare "70" means
canonicalUnit: 'in', // optional: always convert the parsed value to this unit
schema: z.object({ value: z.number().min(24).max(96), unit: z.literal('in') }),
format: feetInches, // optional
});defaultUnitis required to accept bare numbers. Without it,70yields amissing_unitissue; this is the only way to express "a unit is required".- Locale-dependent defaults are an explicit opt-in: pass a table keyed by measurement system, e.g.
defaultUnit: { us: 'in', uk: 'in', metric: 'cm' }. The system comes from the locale's region (see Locales). Because the table lives on the codec, height and road distance can default differently. number(onquantity()'s definition, not a user option) replaces how numbers are read and printed, for quantities with their own number syntax. It's{ read(text, ctx, from), format(value, ctx) }, withreadreturning{ value, end }likereadNumber, in the unit's own terms, andformatprinting somethingreadreads back. An optionalminis the smallest valueformatcan print; smaller values fail the structural check, soformatnever prints textreadrejects. It's used for every number in the input, including compound input and themissing_unitexample, and the codec exposes it ascodec.numberso range completion can find each side's number.paceis the built-in user (5:30is 330 seconds).canonicalUnitnarrows the value type to that unit (Quantity<'in'>) and turns unit-aware constraints into plain number checks that any validator can express. It is the recommended way to validate quantities.- Without
canonicalUnit, cross-unit constraints are written as refinements that use quanto's math, e.g..refine((h) => compare(len, h, { value: 8, unit: 'ft' }) <= 0). - Compound input (
5'11",5 ft 11 in,1 lb 4 oz,2h30m) is summed into the smallest unit mentioned (71 in, 20 oz, 150 min), or intocanonicalUnitif set. The smallest unit usually gives an exact integer, and it's whatcanonicalUnitusers expect.- Components go from larger to smaller units, without repeats;
11 in 5 ftis unparseable. Only linear units compound. - Only the first component carries a sign, and it applies to the whole:
-5 ft 6 inis -66 in. That includes a minus on a zero first component:-0 ft 6 inis -6 in. - A trailing bare number takes the previous unit's
subunit; without one it's unparseable. - Parts can be joined by
,,andor&(5 feet, 11 inches,1 hour and 30 minutes), but only after a part with a unit:5 ft,and5, 11are unparseable. - Conversions scale decimal factors to integers before dividing, so
5 ft 11 inis exactly 71 in and 1 in is exactly 25.4 mm. Factors are taken as the decimals they're written as; when the scaled integers would pass 2^53 (hp's 745.69987158227022), conversion falls back to plain division, accurate to float precision.
- Components go from larger to smaller units, without repeats;
- Magnitude suffixes: the first number can carry
k,morbn, attached and case-insensitive (2k ft,2M lbs,1.5k km), or the wordsthousand,millionorbillion(2 million ft), which follow the same rules.tandbare left out: they're tonnes and bytes, and nobody types trillions of feet. The suffix's value is exact in decimal (1.1kis 1100).- A unit follows: it's a magnitude. This is never ambiguous, since a unit must be followed by a number, so
2m ftcan only be 2,000,000 ft. - Bare (
2k,2M): if the letter is one of the codec's own aliases, the unit wins:2Min a length field is 2 meters,2tin mass is 2 tonnes,2min duration is 2 minutes. Otherwise it's a magnitude of thedefaultUnit(2Min a mass field defaulting to lb is 2,000,000 lb), and without one it'smissing_unit. - A suffixed number can't start compound input (
2k ft 6 inis unparseable), and later components never take a suffix. - Only the default number syntax reads suffixes; a codec with its own
number(likepace) doesn't. Formatting never prints them.
- A unit follows: it's a magnitude. This is never ambiguous, since a unit must be followed by a number, so
markers(onquantity()'s definition) are words that can follow a number without naming a unit, so it reads as a bare number.temperatureuses them:20°,20 degand20 degreestake thedefaultUnit, and without one the definition'smarkerUnit, also keyed by measurement system: temperature's is{ us: 'F', uk: 'C', metric: 'C' }, so451°is °F in en-US and20°is °C in en-GB. A bare70staysmissing_unit: the degree sign says it's a temperature, a bare number doesn't, so only the marker gets a regional default. Aliases take precedence (20°C,20 degrees F), and a marker that's also an alias is a definition-time error.- Unit matching: the longest alias wins (
milesbeforemibeforem), aliases may contain spaces and/(fl oz,km/h), and a word alias can't run into a following letter (5 msis not5 m+s). A period after a word alias is skipped (5 ft. 11 in.). A word that matches no alias, alone or after a slash (70 kgin a length field,5:30 /ydfor pace), isunknown_unit; anything else left over isunparseable. - Default formatting prints at most 2 fraction digits (
70.87 in,1.62×10⁶⁵ m), or, for a non-zero value that would round to 0, enough for three significant digits (0.000123 m, since0 L/100kmwouldn't parse back), then the unit's first alias, separated by a space unless the alias is',",°,%,‰or‱(45°,30',12.5%). - The quantity codecs in
@quantojs/common:length,mass,duration,temperature,volume,area,speed(its own unit table, not derived),dataSize,dataRate,compute,computeRate,energy,power,pressure,angle,frequency,fuelEconomy,pace,torque,force,acceleration,flowRate,density,voltage,current,resistance,capacitance,charge,luminousFlux,illuminance,luminance,colorTemperature,radiationDose,absorbedDose,proportionandsoundLevel, each exported alongside its unit table (lengthUnits, …). It also hasnumber,percent,ratioandtext, and money and odds in their own subpaths. Dates are in their own package. numberis a plain number with no unit:1,234.5,twelve,6.02×10^23, and the magnitudes1.2k,3M,2bn,1.5t(attached) or3 million(a word). An expression takes no magnitude (2*3kis unparseable). It formats like a quantity's number, to 2 fraction digits. Integers and bounds are theschema's job.percentis a plain number in percentage points (12.5, not0.125): a bare number, one followed by%,percent,per centorpct, or one after%(%50, as written in Turkish). A bare fraction (3/4) is unparseable, since it could mean 75% or 0.75%;3/4%is fine. Basis points, per mille and parts per million areproportion, and ratios areratio.proportionis a quantity codec, so unlikepercentit keeps the unit:25 bpsis{ value: 25, unit: 'bp' }and converts to 0.25%. Its units are%,‰,bp(also‱),ppmandppb, against the whole.pptis left out: it means both parts per thousand and per trillion. A bare number ismissing_unit, as with any quantity.ratiois two or more non-negative numbers, stored as written (16:9is[16, 9], not reduced and not divided out). Colons take any number of terms (1:2:4);to,in,out ofand/take exactly two (3 in 10,3/4). Formats with colons. It records the terms, not what they mean:3 in 10is[3, 10], a part of a whole, and formats as3:10. Odds, which have a direction and a probability behind them, are@quantojs/common/odds.textis the identity codec: the value is the trimmed string as typed, with no other parsing. Blank is anemptyissue, as everywhere;optional(text())allows it, andschemavalidates. It's what a field with no codec uses.durationcovers fixed-length units only (ms through weeks). Calendar durations (months, years) are not quantities because they have no fixed length.approximateDurationis for fields where a rough figure is the point (3 monthsto recover,1.5 yearsof experience). Its value isApprox<Quantity>,{ value, approximate }, as fromapprox: a wrapper can't do this, because the inner codec has no way to say a value is approximate, andapprox(duration())would read3 monthsas exact.- Units:
duration's, plus months (mo) and years (yr) at their average Gregorian length. A year is 365.2425 days (31,556,952 s) and a month a twelfth of that (30.436875 days, 2,629,746 s), so twelve months are exactly a year, both are whole seconds, and1 year 6 monthssums exactly to 18 mo.approximateDurationUnitsis the table. - Months and years force
approximate: true, since a month is 28 to 31 days:3 months,1 month 15 days(45.44 d),3 monthswithcanonicalUnit: 'd'(91.31 d), a bare number whendefaultUnitis a month or a year, and any value held inmooryr(2 weekswithcanonicalUnit: 'mo'). Text uses no calendar unit whenapprox(duration())with the sameclockand a fixed-lengthdefaultUnitreads it; the codec checks exactly that, so the rule can't drift fromduration's grammar. AdefaultUnittable that mixes calendar and fixed-length units across systems is a definition-time error, since that check can't see the locale. - Other units are approximate only when marked, with
approx's markers (about 3 days,2h-ish), and exact otherwise (90 min). - The structural check rejects a month or a year with
approximate: false, so such a value can't be stored andformatthrows on it.formatprefixes~when approximate (~3 mo), asapproxdoes, and round-trips. - It's a separate codec rather than a
durationoption, so each type is honest: aduration()value never holdsmoand is never wrapped. The codec exposesunits, soconvert(codec, value.value, 'd')andcomparework on the quantity inside. It isn't a quantity codec itself, sorange()doesn't take it. It isn't calendar arithmetic either: one month after Jan 31 is a question fordateOffsetand Temporal, not for 30.44 days.
- Units:
durationreads clock notation as compound input, so it sums exactly:1:30:15is always h:mm:ss (5415 s). Two parts depend on what follows:1:30 his h:mm (90 min),1:30 minis m:ss (90 s), and a fraction means seconds (1:30.5is 90.5 s). Bare1:30follows theclockoption,'h:mm'(the default; meeting lengths, cook times) or'm:ss'(lap times). Minutes and seconds must be two digits under 60, and clock notation followed by any other unit is unparseable.lengthhas nonm: it's nanometers in one field and nautical miles in another, so nautical miles arenmi. It has the Planck length (ℓₚ,lP,planck length), for fun; NFKC readsℓₚaslp. And the light year (ly,light year), the IAU's exact 9,460,730,472,580,800 m, for scale at the other end, with the astronomical unit (au, exactly 149,597,870,700 m) and the parsec (pc, 648,000/π au, the one length factor that isn't an exact decimal).pcis the parsec, not the typographic pica, which isn't in the table. Also the ångström (Å,angstrom, exactly 10⁻¹⁰ m), the fathom (ftm, 6 ft), Gunter's chain (ch, 66 ft), the furlong (fur, 660 ft) and the smoot (5 ft 7 in, Oliver Smoot's height when MIT students measured the Harvard Bridge with him).massreads#as pounds (150#).volumereadsccandcm³as milliliters.masshas noton: it means different masses in US, UK and metric use (t/tonneis the metric ton).volume's customary units are US measures; UK imperial pints and gallons need a custom table.dataSizeis bytes only:KB,MB, … are decimal (1000) andKiB,MiB, … binary (1024). With no bit units, a lowercasebmeans bytes too (100b,Mb).dataRatehas both: bit and byte aliases that differ only by case match exactly, all-lowercasembps,kbpsandgbpsare listed as bits, andmb/sis ambiguous and rejected.computecounts floating-point operations (FLOP,kFLOP, …YFLOP, andpfsDay, a petaflop/s for a day) andcomputeRatemeasures their speed (FLOPS, …YFLOPS). People writeFLOPsandFLOPSfor both, so each codec reads them as its own kind;/sis what's unambiguous, and onlycomputeRateaccepts it. A plainTFLOPorteraflopin acomputeRatefield isunknown_unit. Neither has milli- units, somflopsis mega-.- Units that differ only by case are both in the table where both are real, so each matches exactly as written:
force'smNandMN,voltage'smVandMV,resistance'smΩandMΩ. Where only one exists (mA,mF,mSv), any case means it. Micro isµoru(µF,uF);Ωis the ohm, in either code point. - Scales. A unit can name a
scale; units convert only within their scale, each scale against its own base, and units with noscaleshare one, as in most tables. It's for units of one quantity that are both legitimate readings yet don't convert: weighted sound levels, and later things likemg/dLandmmol/L, which need the substance. Across scales,convertandcomparethrow (a programmer error, like a unit outside the table); parsing into acanonicalUniton another scale is anincompatible_unitissue; compound input must stay on one scale; and a range's ends must be on one scale (80 dB-90 dBAisincompatible_unit, and fails the range's structural check). Asubunitmust be on its unit's scale, checked at definition time. soundLevelis sound pressure level in decibels (dB SPL), with weighted readings on their own scales:dBA(dB(A)) anddBC. A-weighting depends on frequency, so 85 dB(A) says nothing exact about the unweighted level. Pascals are on the unweighted scale as a function unit (94 dB is about 1 Pa). Gain and other relative decibels (dBm,dBV) aren't sound levels and aren't here.torquereads pound-feet either way round (lb-ft,ft-lb), as people write them.acceleration'sgis standard gravity.flowRateanddensityuse US gallons, asvolumedoes.chargeholds battery capacity (mAh,Ah); watt hours areenergy. Light is three quantities:luminousFlux(lumens, a bulb's output),illuminance(lux, light on a surface) andluminance(nits, a screen's brightness).colorTemperatureis a light's correlated color temperature, in kelvins and mireds (a million over the kelvin, so a function unit); it's separate fromtemperature, which has no mireds and whose °C and °F make no sense for light. Radiation is two:radiationDose(sieverts and rem, the dose to a person) andabsorbedDose(grays and rad); they don't convert, so they're separate codecs.energy'scal,Calandcaloriesare kilocalories, as on food labels; the small calorie isn't included.powerhas bothmWandMW, matched exactly.pressureandfrequencyhave no milli- units, sompaandmhzmean mega-.frequencyhasrpmbut not beats per minute, which nobody converts to hertz.angleuses arcseconds as its base, so40°26'46"compounds exactly (degrees take arcminutes as their subunit, arcminutes take arcseconds).fuelEconomyhas km/L as its base; L/100km is a function unit.mpgis US; UK mpg ismpg (imp), as withvolume.paceis aquantity()with its own number syntax: it reads clock notation (5:30 /km,1:05:00 /mi) as seconds and a plain number (5.5 min/km) as minutes, and formats to a tenth of a second. Its unit table is seconds per km and per mile, with the minutes word folded into the aliases (min/km,minutes per mile). A negative pace is rejected when typed, and clock notation has no negative form, so a stored negative fails the structural check (its syntax hasmin: 0):schemarejects it andformatthrows, rather than printing-6:-30 /km.
Money lives in @quantojs/common/money: the money codec, the Money type, its operations, moneyRange and intlMoney. It's in @quantojs/common because it's complete: the value shape, the currency table, the ambiguity rules, the operations and the range rules are settled, and its data changes (a country adopting the euro) are rare, additive refreshes. It has its own subpath because it has its own types and operations. Its region data (each region's currency and where it writes the symbol) is generated by its own script, scripts/generate-money-data.ts.
Money is not a quantity. Converting currencies has no fixed factor: it needs exchange rates, which are external data that change over time.
type Money<C extends string = string> = { minorUnits: number; currency: C }; // C: ISO 4217 code
// $12.34 → { minorUnits: 1234, currency: 'USD' }
// ¥500 → { minorUnits: 500, currency: 'JPY' } (JPY has 0 decimal places)
// BHD 1.234 → { minorUnits: 1234, currency: 'BHD' } (BHD has 3)- Amounts are integers in the currency's minor unit, never floats. Floats can't represent most decimal fractions (
0.1 + 0.2 !== 0.3). Unlike measurement drift, money errors survive summing, comparison and storage, so "round in format" isn't enough. - The number of decimal places comes from a bundled ISO 4217 minor-units table (two decimals by default plus the known exceptions).
Intlis not consulted: unknown codes make it throw, and small-ICU builds report wrong digits. - A JS number holds integers exactly up to 2^53 (~$90 trillion in cents), which is enough.
bigintis used only for exact internal arithmetic; public values stay JSON-safe numbers. - The field name
minorUnitsis deliberately explicit, so nobody mistakes 1234 for $1234.
Opt-in, in their own subpath, with the same status as quantity operations. Unlike quantities, money keeps its arithmetic: the currency and safe-integer checks, rounding modes and loss-free splitting are exactly what apps get wrong on their own, and money has no affine or non-linear cases to rule on.
import { add, subtract, compare, scale, allocate, convert } from '@quantojs/common/money';add,subtractandcomparerequire the same currency. Mismatches always throw at runtime, and are also a type error when both currencies are literal types (Money<'USD'>vsMoney<'EUR'>). Values typed as plainMoneyare only checked at runtime.scale(m, factor, { rounding })andconvert(m, 'EUR', { rate, rounding })always round to a whole minor unit of the result currency. The caller must choose the rounding mode, because the right one depends on the domain (tax, invoicing, display). Modes useIntl.NumberFormat'sroundingModenames (halfExpand,halfEven,trunc, …).convert'srateis how many major units of the target currency one major unit of the source buys. Differing minor units (USD → JPY) are handled. quanto never fetches rates.allocate(m, ratios)splits without losing a cent (e.g. $10 three ways → 334 / 333 / 333): each share gets its rounded-down part, and the remainder goes one minor unit at a time to shares with non-zero ratios, in order.- Factors, rates and ratios are interpreted as their shortest decimal representations. Arithmetic uses exact integer fractions internally and rounds once, so
scaletreats1005 × 1.1as exactly 1105.5 without losing safe-integer digits. A computed factor such as0.1 + 0.2means the decimal0.30000000000000004; quanto does not infer a different intended factor. scaleandconvertround to a whole minor unit only; the operations never produce fractional minor units.
The codec is money({ defaultCurrency?, schema?, format? }).
- Accepts
$12,12 USD,USD 12,€12,50,12.50 eur,$1.2k,$3M,$1.5bn, and names (12 dollars,12 bucks,12 euros,12 quid,500 yen). Suffixes (k,m,b/bn,t) are case-insensitive and attached to the number:3mand3Mboth mean million in a money field. - Magnitude words follow the number:
thousand,grand,million,mil,mn,mm(finance's million, attached or not),billionandtrillion($12 million,12 grand,$12mm). Once only, and not after a suffix ($12k grandis unparseable). - Minor units:
50¢,50c,50 cents,5p,50 pence. Cents resolve like$(dollars with cents, plus the euro), so50 centsis USD by default, CAD in en-CA and EUR in en-IE; pence are GBP. A minor unit stands alone, with no symbol or code ($50¢is unparseable), and has no decimals. - Accounting negatives:
($12)is -$12. - Amounts in words (
twenty dollars,five hundred bucks,half a million dollars), and cents after an amount:five dollars and fifty cents,$5 and 50 cents,ten pounds five pence. The cents must be a whole number under 100 of the same currency's minor unit. The inside can't have its own sign ((-$12)is unparseable). - A currency can be written before the number, after it, or both, if they agree:
$12 CADis CAD,€12 USDis unparseable. Disambiguated symbols are accepted (US$,C$,CA$,A$,NZ$,HK$,R$,CN¥…). - A sign goes before the symbol or the number (
-$12,$-12), not both. - A bare number takes
defaultCurrency; without it, it's amissing_currencyissue. - A three-letter word that isn't an ISO 4217 code, or any other word in a currency position, is
unknown_currency. - Ambiguous symbols (
$is used by USD, CAD, AUD, MXN…;krby SEK, NOK, DKK, ISK;¥by JPY and CNY) resolve to the codec'sdefaultCurrencyif that currency uses the symbol, then toctx.locale's currency if it does ($in en-CA → CAD,$in de-DE → USD), then to the symbol's first currency (USD, SEK, JPY). - Parsing retains the written digits until exact minor units are computed; formatting inserts the decimal point into minor-unit digits without converting to a floating-point major amount. Every safe-integer amount round-trips exactly.
- Input with more precision than the currency allows (
$3.459) is anexcess_precisionissue. Sub-minor-unit prices are deferred (see below).
Betting odds live in @quantojs/common/odds: the odds codec, the Odds type and its operations. They're in @quantojs/common because the notations and the arithmetic are settled. They have their own subpath, like money, because they have their own value type and operations. Odds aren't a ratio: they have a direction (against, unless on), three notations for one value, and an implied probability.
type Odds =
| { kind: 'fractional'; numerator: number; denominator: number } // 11/4, against
| { kind: 'decimal'; value: number } // 3.75, above 1
| { kind: 'american'; value: number }; // +275, -200; |value| >= 100- The value keeps the notation it was typed in, so it formats as typed. Fractional odds are whole numbers kept as written, not reduced: bookmakers quote conventional fractions (
100/30,6/4), which a decimal can't give back.canonicalKindstores every value in one notation. - The structural check: whole positive numerator and denominator, decimal above 1, American at least 100 either way (and the
canonicalKind, if set).
- Fractional:
5/1,5-1,5:1,5 to 1, optionallyagainst;onreverses it (2/1 onis1/2);evens,evsandeven moneyare1/1. Both numbers are whole and above 0 (2.5/1and5/0are unparseable). - American: a signed number,
+500,-110, at least 100 either way (+50is unparseable). - Decimal: an unsigned number above 1 (
6.0,3,75in German).1.0and below is unparseable: it would return only the stake. - A bare whole number of 100 or more (
150) isambiguous, with the American and decimal readings as alternatives (in that order: decimal odds that long are rare). A decimal point says decimal (150.0). In a field withcanonicalKind: 'american'or'decimal', it's read in that notation. - Implied probability:
25%,25 percent, between 0 and 100 exclusive, stored as decimal (4). There's no probability notation to store, since odds fields show prices. - A trailing
odds(5/1 odds,+275 odds,70 odds) is allowed after any of them. It isn't a unit and isn't stored: it says the text is odds, so in a merge, where11/4is a date,+275a number and16:9a ratio, those codecs fail on it and odds reads it. - Left out: the Hong Kong, Malay and Indonesian notations, statistical odds (
1:4 in favour; useratio), and odds ratios (anumber).
In the value's notation: 11/4, 3.75 (always two decimals, as quoted), +275 and -200 (grouped like any number, +1,000). All read back.
toDecimal(odds): the total return per unit staked, the notation everything converts through.impliedProbability(odds): 1 / decimal, ignoring the bookmaker's margin.convert(odds, kind)andfromDecimal(decimal, kind): into another notation. Fractional results are reduced, recovered by continued fractions with a denominator up to 10,000, so 4/3 decimal is1/3, not a 16-digit fraction. American results are rounded to 9 decimal places, to drop float noise.compare(a, b): by decimal value, across notations, with a relative tolerance of 1e-9; -1 whenais the shorter price.- Malformed odds throw, as in
@quantojs/common/money.
Dates and times live in @quantojs/datetime: the four codecs, their grammar, dateRange and the Intl date formatters. Its grammar is the most heuristic part of quanto and the likeliest to change, so it versions on its own. The core keeps what's shared, ctx.now() and context.now; the package owns its locale data (month and weekday names, numeric date order, 12- or 24-hour clock), generated by its own script.
Values are ISO 8601 strings. They're JSON-native and parse losslessly into Temporal (PlainDate, PlainTime, PlainDateTime, Instant) if an app needs date math. quanto does no date math. A future separate package may provide it, built on Temporal.
| Codec | Value | Example |
|---|---|---|
date() |
calendar date | '2026-10-02' |
time() |
wall-clock time | '15:00:00' |
localDateTime() |
date and time, no offset | '2026-10-02T15:00:00' |
dateTime() |
exact moment, with the UTC offset it was entered in | '2026-10-02T15:00:00-04:00' |
- Times are always
HH:MM:SS, with no fractional seconds, in every codec. Equal values are therefore equal strings, and3pmparses to15:00:00. - No time zones in the first-party codecs.
dateTime()stores a UTC offset, not an IANA zone. An offset is exact and needs no tz database. Input with an explicit offset orZkeeps it; input without one takes the offset ofctx.now, which defaults to the machine's local offset. The limitation is DST:3pm next monthgets today's offset. Apps that need real zones write a custom codec on Temporal. date(),time()andlocalDateTime()values sort lexicographically.dateTime()values with different offsets don't; compare them as instants.- Only
dateTime()values are safe withnew Date().Datereads date-only strings as UTC and offset-less date-times as local time, and rejects bare times. Use Temporal or plain string handling for the other forms. - Relative input (
tomorrow,next fri,in 3 days) is computed fromctx.now. Its offset defines the local calendar in which "tomorrow" is computed. Whennowis missing, the machine's clock and local offset are used and reported incontext.now. In a browser that's what the user means. A server parsing on a user's behalf should pass the user'snow, because the server's offset is usually wrong for them. - Because the parsed value is captured at entry time and stored, relative input is never re-parsed later (see the stored envelope below).
- Numeric dates follow the region's date order.
03/04/2026is March 4 in en-US (the default) and 3 April in en-GB and en-AU. Unambiguous forms (2026-03-04,4 Mar,13/04) are accepted in any locale. - Yearless dates (
4 Mar,13/04) take the year ofctx.now, with the same machine default. - Month and weekday names: English is built in; other languages are opt-in data. Each of
date(),localDateTime()anddateTime()takes anamesoption:date({ names: [de] }).@quantojs/datetime/namesexports name sets for Spanish, French, German, Italian, Portuguese and Dutch (es,fr,de,it,pt,nl), generated from ICU at development time; any other language is just aNamesobject (twelve months and seven weekdays, each a list of accepted forms, plus optional filler words).- Why opt-in rather than picked from
ctx.locale. Opt-in sets tree-shake, and a codec's behaviour changes only when the app changes it: a later release adding a language can't silently change how existing fields parse. Locale-aware numbers stay automatic, because separators matter to every field and don't depend on vocabulary. - Parsing accepts the passed sets, in order, then English. Matching ignores case, accents (
fevrierisfévrier) and a trailing period. When a form means two things, months win over weekdays (Spanish, French and Italianmaris March;martesstill works) and earlier sets win over later ones. A set'sfillersare skipped between parts (2 de octubre de 2026), and a period after the day number is accepted (2. Oktober). - Formatting uses the set whose language matches
ctx.locale's language, or English:2 Okt 2026in de-DE, still the unambiguous day-month-year form, not the full native style (2. Okt. 2026). It round-trips. - Names are data, grammar is code. A name set can't add relative words in another language (
mañana), ordinals (1er), or dates that aren't written with names (2026年10月2日); those need a custom codec.
- Why opt-in rather than picked from
The four codecs share one grammar, built for the common case. Anything it doesn't fully consume is unparseable; nothing is silently dropped except a weekday written next to an explicit date (Fri, Oct 2 isn't checked against the date).
- ISO:
2026-10-02,2026/10/02,2026-10-02T15:00,2026-10-02 15:00:30, with an optional offset (Z,+02:00,+0200). Fractional seconds are dropped. - Numeric dates:
10/2/2026,2.10.26,13/04, with/,.or-, in the region's order. A month over 12 swaps with the day (13/04is April 13 everywhere). Regions whose order is YMD read a year-last date as day/month (03/04/2026is 3 April in en-CA). Two-digit years fall in the 100 years centred onnow's year. - Month names:
Oct 2,October 2nd, 2026,2 Oct 2026,the 2nd of October,Sept 30,Oct. 2, in English or any name set passed to the codec. A missing year isnow's year, even when that puts the date in the past (Jan 5, typed in September, is last January). - Relative dates:
today,tomorrow,yesterday,in 3 days,in a week,2 weeks ago, and weekdays.friandthis friare today if today is Friday, otherwise the coming Friday;next friis the first Friday strictly after today;last friis the most recent Friday before today. - Times:
3pm,3 p.m.,3p,3:30pm,15:00,15:00:30,noon,midnight,now.12amis 00:00 and12pmis 12:00. A bare number (3) isn't a time. - Combinations: a date and a time in either order, with optional
at,onand commas:tomorrow 3pm,3pm tomorrow,Oct 2 at 15:00,Oct 2, 2026, 3:00 PM,fri 9am. - Offsets (
dateTime()only):Z,UTC,GMT,±HH:MM,±HHMM.Z,UTCandGMTstore as+00:00. - What each codec requires:
date()a date and no time;time()a time and no date;localDateTime()a time, with today's date if none is written, and no offset;dateTime()a time, today's date andnow's offset when missing. A date alone in a date-time field is unparseable rather than midnight.
Write a custom codec for any of these:
- Month and year arithmetic (
in 2 months,next year) and week or month references (next week,end of month): they have no single common-sense answer. Kept as an offset rather than resolved to a date,in 2 monthsisdateOffset(below). next frimeaning Friday of next week.- Time zone names and abbreviations (
EST,Europe/Paris), and DST: an input without an offset takesnow's offset, even for a date on the other side of a DST change. - Clock forms like
15h30,3.30pmand1500, and fractional seconds in stored values. - Month-and-year without a day (
Oct 2026), week numbers, ordinal dates, and non-Gregorian calendars. - Numbers written as words (
three pm), excepta/an/oneinin a day. - Checking a written weekday against the date (
Mon, Oct 2).
dateOffset() reads an offset in calendar units from a date the app supplies: 3 days, 2 weeks before, in 6 months, 1 year and 6 months ago. It's for fields whose meaning is relative (remind me n before the due date, expires n after signup, repeat every n), as opposed to date(), where in 3 days is a quick way to type an absolute date. The field decides which: the same text means a resolved date in a date() field and an offset in a dateOffset() field, and nobody has to guess what the person meant.
- The value is a signed ISO 8601 duration in years, months, weeks and days:
P3D,-P2W,P1Y6M,P1W2D. Zero isP0D. The string is canonical, so equal offsets as written are equal strings: zero parts are dropped and only a non-zero offset has a sign. Weeks combine with other parts (P1W2D), as ISO 8601-2 and Temporal allow. Each part is below 2³², Temporal's limit. - Applying it is Temporal's job:
Temporal.PlainDate.from(due).add(offset). quanto does no date math (see Non-goals), and month arithmetic (Jan 31 plus one month) is exactly what Temporal settles. That's why the value is a string Temporal reads as it is, rather than an object. nowis never read.in 3 daysstaysP3D, three days from whatever it's applied to, so a stored offset is stable without a context.- Units are kept as written, like a quantity's:
2 weeksisP2W, notP14D. Compound input goes from larger to smaller parts without repeats (1y 6mo 2w 3d), joined by spaces,,,andor&. Units:d/day(s),w/wk(s)/week(s),mo/mos/mth(s)/month(s)andy/yr(s)/year(s).misn't a unit: it's minutes in a duration field. Hours and minutes areunknown_unit: an offset between dates has no time of day. - Whole numbers only, in digits or words (
three months,a week): ISO 8601 and Temporal allow no fraction in a calendar part, and1.5 monthshas no single answer. A bare number ismissing_unit. - Direction: a bare amount is forward. Backward is
-,ago,before,earlierorprior; forward can be said with+,in,after,laterorfrom now. One way per offset:in 3 days agoand-3 days beforeare unparseable. A sign or direction word applies to the whole offset, and only the first part may carry a sign. - Phrases:
today,same dayandthe same dayareP0D,tomorrowisP1D,yesterdayis-P1D, andthe day/week/month/year before/afteris one of that unit. ISO input (P3D,-P1M) reads too, in either case. - Rules aren't offsets:
next fri,next weekandend of monthare unparseable, since storing them means storing a rule, which is a scheduling concern. - Formatting is English and neutral about the anchor, since the field's label names it:
3 days,1 year 6 months, and3 days beforefor a negative offset;0 daysfor zero. Numbers use the locale's grouping (1.000 daysin de-DE). A different phrasing (in 3 days,3 days ago) is aformatoption. - Not in
@quantojs/anything:3 daysis already a duration there, and offsets are only meaningful in a field that says what they're from.
Mailing addresses live in @quantojs/libpostal, an external codec over libpostal, the open-source address parser: a statistical model (a conditional random field) trained on over a billion addresses from OpenStreetMap and OpenAddresses, covering most countries, CPU-only and fast (10–30k addresses a second per thread). Splitting an address into its parts is a statistical problem with no common case across countries, so it can't be a built-in sync codec; libpostal is the best owned option, and the package's name says that's what it's built on.
import { address } from '@quantojs/libpostal';
import postal from 'node-postal';
const field = address({ libpostal: async (text) => postal.parser.parse_address(text), defaultCountry: 'US' });
await field.parse('1600 amphitheatre pkwy, mountain view, california 94043');
// { lines: ['1600 amphitheatre pkwy'], locality: 'mountain view', region: 'CA', postalCode: '94043', country: 'US' }- The app runs libpostal. Its model is about 2 GB, so it's never a dependency: the
libpostaloption is a function from text to libpostal's[{ label, value }], whether that's node-postal in-process or a request to a libpostal service (pelias/libpostal-service). The package has no runtime dependencies, and its fixtures run against a stub of libpostal's answers. - The value is
Address:lines(building, street, unit, PO box, in the order written; at least one), and optionallydependentLocality,locality,region,postalCodeandcountry(ISO 3166-1 alpha-2). Absent parts are absent. - Text stays as typed. libpostal lowercases its output; the codec finds each part in the text and keeps it as written. It doesn't title-case:
mcdonaldmight beMcDonald, and guessing would change what was typed. - Normalized where there's a convention: regions to codes in the US, Canada and Australia (
California,Québec→CA,QC), postal codes to their country's format where it has a fixed one (sw1a2aa→SW1A 2AA,101180110→10118-0110; others as typed, uppercased), and countries, written in any of the bundled languages or common aliases (Deutschland,UK,USA), to their codes. defaultCountryis the country of addresses that don't name one. It's an option, not read from the locale: the locale says how someone writes, not where the address is. Formatting leaves the default country out and names any other, in the reader's language.- Issues:
unparseablewhen there's no street line, no city or postal code, or a country it can't identify. - Formatting is one line in the country's order:
1600 Amphitheatre Pkwy, Mountain View, CA 94043(city, region, postcode),Hauptstraße 5, 10115 Berlin, Germany(postcode first, most of Europe),10 Downing St, London SW1A 2AA, United Kingdom(city, postcode). It reads back through libpostal. - Not included: checking that an address exists (that needs a postal database) and completions (an address index, such as Pelias or Photon over OpenAddresses). A geocoding API covers both; wiring one up is an app's own external codec, and the value shape can be the same.
Pitches and time signatures live in @quantojs/music. Note naming varies by country and tradition, and the grammar will grow (tempo, intervals), so it versions on its own. Chords are out of scope: they aren't quantities.
pitch() is a quantity in two units: note, a MIDI note number (A4 is 69, middle C is 60) with cents as the fraction (A4 +15¢ is 69.15), and Hz. The codec carries its unit table (pitchUnits(a4), Hz as the base and note as a function unit), so convert and compare work, and canonicalUnit: 'Hz' makes A4 440 Hz.
- Notes: a letter, at most one accidental, an octave and optional signed cents:
A4,C♯5,Db4,B♭3,C##4/Cx4/C𝄪4,D𝄫4,C♮4,C sharp 4,B-flat 3,A4 +15¢,A4 -20 cents. Octaves can be negative (C-1is 0). Letters are case-insensitive, sobb3is B♭3. - Frequencies and MIDI numbers:
440 Hz,1 kHz(stored as 1000 Hz),four hundred forty hertz,MIDI 60. A bare number ismissing_unitunless the codec has adefaultUnit. A frequency must be more than 0 Hz. - Octave: required.
F#isunparseable("Add an octave") unless the codec has adefaultOctave.middleC: 3switches to the numbering where middle C is C3 (Yamaha, and French usage). - Tuning:
a4(default 440) sets A4's frequency for conversions. - German and Nordic names (de, da, nb, nn, no, sv, fi, et, pl, cs, sk, hu, sl, hr, sr):
His B and a bareBis B♭, with-isand-esaccidentals:Fis,Cis,Es,As,Des,Ces,Gisis,Heses. The-is/-esnames read in every locale; only the bareBdepends on the locale (B4is 70 in de-DE and 71 in en-US).Bwith a symbol is the English B (B♭4,B#4), andHreads as B everywhere. - Solfège reads in every locale:
Do/Ut,Ré/Re,Mi,Fa/Fá,Sol/So,La/Lá,Si/Ti, with symbol accidentals (Sib3,Fa#4) or words (Fa dièse 4,Si bémol 3,diesis/bemolle,sostenido/bemol,sustenido). - Formatting prints the nearest note and the remaining cents to a tenth (
A4 +15¢,A♯4 -50¢), or the frequency to two decimals (261.63 Hz). Names followctx.locale's language:B♭4in English,B4(B♭) andH4in German,Si♭4in French,Fá4in Portuguese. Accidentals print as♯ ♭ 𝄪 𝄫. - Spelling follows
ctx.music.key(see Extensions). A key is a tonic and a mode (F major,Eb,F# minor,Dm,C dorian; all seven modes). The key's own notes are spelled as the key spells them (E♯ in F♯ major, C♭ in G♭ major); other notes take sharps in keys whose signature is sharp or empty, and flats in flat keys. Minor keys also spell their raised sixth and seventh (C♯ in D minor, not D♭). Without a key, notes take sharps. The key only affectsformat: a note parses to the pitch it names, whatever the key. A malformed key throws, since it comes from the app. - Merging and ranges. Note names overlap other codecs (
A4is a paper size,Bis bytes); in amerge, the order decides, as usual.defineRange(pitch())reads vocal and instrument ranges (C3-C5,E2 to G#4).
timeSignature() values are { numerator, denominator }, with groups for additive meters: 3+2+2/8 is { numerator: 7, denominator: 8, groups: [3, 2, 2] }.
- Accepted:
4/4,6/8,6 / 8,3/4 time,¾and⁶⁄₈(throughnormalize),C,common timeand𝄴(4/4), andcut time,alla breveand𝄵(2/2). Additive meters can be parenthesized:(3+2+2)/8. - The bottom number is a note value: 1, 2, 4, 8, 16, 32 or 64. Irrational meters (
4/3) areunparseable, as are zero, negative and fractional counts. formatprints6/8or3+2+2/8. Common time prints as4/4, since the value doesn't record the symbol.- It's its own codec rather than a fraction: a time signature never reduces (
6/8isn't3/4).
Phone numbers live in @quantojs/libphonenumber, a sync codec over libphonenumber-js, the JavaScript port of Google's libphonenumber. Which numbers are valid follows numbering plans, which change, and the library's metadata changes with them, so it versions on its own; the name says what it's built on, as @quantojs/libpostal's does.
phoneNumber(): the value is an E.164 string ('+14155552671'), with an extension as RFC 3966's;ext=suffix ('+14155552671;ext=123'). A string, because E.164 is the standard storage form and needs no wrapper.- National numbers are read in
defaultCountry(ISO 3166-1 alpha-2), else the locale's region, asmoneyreads$. So(415) 555-2671is a US number in en-US and a German one in de-DE.+,00(where the country's own prefix is another) and the country's international prefix need no country.tel:URIs and extensions (ext. 123,x123,#123) are read; other words and vanity letters areunparseable. - Valid at parse, possible when stored. Parsing requires a valid number per the metadata (an allocated range, not just the right length). The structural check requires only a possible one, so a number stored under one release still passes
schemaand formats under the next. - Formatting is international (
+1 415 555 2671), or withstyle: 'national', the country's own form ((415) 555-2671) for numbers that read back in the field's country; others keep their country code, so formatted text always reads back. - Issues are
unparseable, with messages that say why (not valid in the country, unknown country code, wrong length). Uses theminmetadata; telling mobile numbers from landlines needs the largermaxmetadata and is deferred.
Coordinates live in @quantojs/geo. Latitude and longitude are settled, but the notations will grow (UTM, MGRS), so it versions on its own. Coordinates aren't dimensions: the two parts have roles and ranges, and hemispheres act as signs.
coordinates(): the value is{ lat, lng }in decimal degrees (WGS 84), lat in [-90, 90], lng in [-180, 180].- Notations: decimal degrees; hemisphere letters or words before or after each part (
40.7128° N,N40.7128); labels (lat,lng,lon,latitude, …); degrees, minutes and seconds (40°42'46" N) and degrees and decimal minutes (40°42.767' N), with minutes and seconds under 60; and full plus codes (849VCWC8+R9), read as the center of their area. Short plus codes need a reference location and areunparseable. With letters or labels the parts come in either order; a sign that contradicts its letter isunparseable. - Order: a bare pair is latitude first, as people write it and map apps copy it;
order: 'lngLat'reads GeoJSON's order. A pair out of range in the field's order isunparseable, never swapped silently, and when the swapped reading is in range it's offered as an alternative (see Alternatives). - Separators: a comma, a semicolon or a space. A point is always a decimal point. A comma between digits is a decimal comma where the locale writes decimals with one (
40,7128; -74,0060in de-DE) and a separator elsewhere, so a copied40.7128, -74.0060reads in every locale. Numbers are read by the codec's own lexer, notreadNumber, since grouping separators and words would collide with the pair separator. - Geohashes are opt-in (
geohash: true): ordinary words can be geohashes (denver). At least 5 characters, with a letter; text that reads both as coordinates and as a geohash isambiguous. - Formatting: decimal degrees to 6 places (about 0.1 m) with hemisphere letters, in the field's order:
40.7128° N, 74.006° W.degreesMinutesSeconds,degreesDecimalMinutes,plusCodeandgeohashare formatters for theformatoption. - Operations:
toPlusCode,toGeohashanddistance(great-circle, in meters, on a sphere of the mean Earth radius, 6,371,008.8 m).
Ring and shoe sizes live in @quantojs/sizes: regional numbering systems over a physical length, with conversions that are partly chart-dependent and other categories to come, so it versions on its own. Clothing sizes are left out: they vary by brand and letter sizes don't convert.
- Sizes are quantities: each system is a unit over a base length, with linear or affine conversions, and the codecs expose
units, soconvertandcomparefromquanto/quantitywork. They're custom codecs rather thanquantity(), since sizes are written system first (US 7,UK N) and UK ring sizes are letters.nearestSize(codec, size, unit)rounds a conversion to the target system's standard steps. ringSize(), over the inner circumference in mm, every system exact by definition:us(US and Canada),uk(letters with halves,Z+1on; stored as the letter's place, A = 1),eu(ISO 8653: the circumference),ch(the circumference less 40) anddiameter. Letters always mean UK.54 mmis the circumference, with the diameter as an alternative. Japanese sizes have no defining formula and are left out.shoeSize(), adult sizes over the foot length:usMen,usWomen,uk,eu(Paris points),mm(Mondopoint) andcm. A US size needs its fit, written (men's 10,US W 8;M/Wonly before the number, since after it they're widths) or from thefitoption; otherwise it'sambiguous. Conversions follow the sizing formulas (each system on the last, two size steps of allowance to the foot), not a retail chart, so US men's 10 is UK 9 and EU 42.6, where most charts say EU 44. The formulas are defined rather than chosen from one brand, which makes them the better default; an app that wants a retailer's chart supplies it in its ownformat. Kids' sizes and widths areunknown_unit.- Bare numbers need a
defaultUnit, which can follow the measurement system ({ us: 'usMen', uk: 'uk', metric: 'eu' }). The system is never inferred from the number.
CSS colors are a finished domain in @quantojs/common/css-color: CSS Color 4 is settled, and what it adds over time (color-mix(), relative colors) is computed from other colors, which this codec deliberately leaves out.
cssColor()reads every CSS color with a value of its own: hex (3, 4, 6 or 8 digits), the 148 named colors andtransparent,rgb(),hsl(),hwb(),lab(),lch(),oklab(),oklch(), andcolor()in its predefined spaces. The modern and legacy (comma) syntaxes both read, as CSS reads them: percentages,none, hue angles, and clamping where CSS clamps (rgb()channels, alpha, lightness, chroma).color()doesn't clamp, so out-of-gamut values are kept.- Left out: colors that depend on context (
currentcolor, system colors,light-dark(),device-cmyk(),var()) have no value of their own; colors computed from others (color-mix(), relative colors) would need conversion between spaces. They'reunparseable, with a message saying why. - The value is
{ space, coords, alpha }, in the space it was written in. Nothing is converted, so a color comes back as written and no conversion math is needed to parse or format. Coordinates are CSS's own numbers without percentages (sRGB 0 to 1,hslsaturation and lightness 0 to 100,oklchlightness 0 to 1), with hues in degrees in [0, 360) andnoneas 0. These are the space names and numbers color libraries take, so conversion and gamut mapping are left to them (Principle 4). - Formatting: hex for an sRGB color whose channels and alpha are whole bytes,
rgb()for other in-gamut sRGB,color(srgb …)out of gamut, and otherwise the space's own function. Numbers print to 12 significant digits, so every format reads back as the same value. Names aren't kept (redis#ff0000). bare(on by default) also reads hex without#and three 0–255 channels (0, 170, 255), as design tools copy them. Fields shared with other values turn it off:badand123are hex.- The locale doesn't apply: CSS syntax is the same everywhere.
@quantojs/anything is one codec that reads anything the first-party packages can: anything(). It's the site's big field, as a package. It sits on top of the codec packages rather than being one of them, so it's the one package that depends on the others (@quantojs/common, @quantojs/datetime, @quantojs/geo, @quantojs/music, @quantojs/sizes), and its order changes as codecs are added, so it versions on its own.
- It's a
merge, ininfinite, inapprox: dates and times;dimensions(length()); length, mass, duration and temperature; money; pitch; the other quantities; coordinates; ring and shoe sizes; CSS colors, withoutbare; then the catch-alls, plain numbers, ratios and odds; then ranges of every quantity, date, time and amount of money. The value isApprox<Tagged<unknown>>: switch onvalue.codec. - Infinities come from
infinitedirectly on the merge, insideapprox, so it sees the merge's tagged values when it reads a unit. One with a unit or a kind is tagged with the member that reads it (unlimited GBisdataSize,infinite feetandinfinite lengtharelength). A bare one names no member, soanythingtags it'infinite'({ codec: 'infinite', value: { infinite: 1 } }) and untags it to format, keeping every value tagged. The infinity words come before every member, and no member reads them, so nothing that read before reads differently;about ∞ GBis approximate like anything else. - The order settles every conflict, since the first codec that reads the text wins and the others' readings are its alternatives:
24 × 36 inis dimensions, since length reads×between numbers as a product;ftis feet before it's the forint;455hzis a pitch, not a frequency;gis grams, not standard gravity;CandFare temperatures;radis an angle;989 TFLOPSis a rate;11/4is a date;25 bpsis a data rate;US 10is a ring size;70and+275are numbers, not odds;2700 Kis a temperature, with the color temperature as its alternative. The fixtures pin each one. - Failures are tidied: forty quantities say they don't know
bananas, so the issues are the first of each informative code, in codec order, or oneunparseablewhen nothing had more to say. includeadds codecs after the built-in singles and before the catch-alls, so4155552671reaches a phone codec beforenumber. Phone numbers come this way rather than built in: libphonenumber-js's metadata is about 80 kB, and an app may want to load it later.phoneNumberis re-exported for convenience from a subpath,@quantojs/anything/phone, not the main entry: a re-export there would put the metadata in every bundle whose bundler keeps unused re-exports of a module that's also imported dynamically (Bun's code splitting does). An app that loads it lazily imports the subpath dynamically and rebuilds the codec when it arrives.namespasses month and weekday names to the date codecs. Formats are the members' defaults;formatreplaces them, as on any codec.- Not included: addresses (
@quantojs/libpostalis external, andmergetakes sync codecs), percent (proportionreads%), text (it reads everything), time signatures (6/8is a date), date offsets (3 daysis a duration, and an offset needs a field that says what it's from) andapproximateDuration(durationreads the common case, and3 monthsshould fail there rather than turn into 91.31 days).
ctx.locale drives parsing, not just formatting. It's a BCP 47 tag; when it's missing, assume en-US.
Bundled data is keyed at its natural level, and each level resolves on its own. A locale is never swapped wholesale for a different one because its language lacks data: en-AU must not become en-US and read 03/04 as March 4.
The core resolves a tag to a language and region, and owns the data every codec needs. Each domain owns its own data (money's currencies, dates' orders and names), keyed the same way and looked up with lookupRegional, so adding a language's month names is a @quantojs/datetime release that doesn't touch the core (and, being opt-in, doesn't change any existing field).
| Data | Owned by | Keyed by | Coverage |
|---|---|---|---|
| Decimal and grouping separators, grouping pattern | quanto |
region, with language exceptions (fr-CA, de-CH, …) |
Every region |
Measurement system (us, uk, metric) |
quanto |
region: US → us, GB → uk, everything else → metric |
Every region |
| Likely region of a language | quanto |
language | About 90 languages |
| Currency | @quantojs/common/money |
region | Every region |
| Currency symbol position (before or after the amount) | @quantojs/common/money |
region, with language exceptions | Every region |
| Numeric date order (MDY, DMY, YMD) | @quantojs/datetime |
region, with language exceptions | Every region |
| Hour cycle (12- or 24-hour clock) | @quantojs/datetime |
region, with language exceptions | Every region |
| Numbers in words | quanto |
language | English built in; other languages are opt-in grammars passed in ctx.grammars. |
| Month and weekday names | @quantojs/datetime |
language | English built in; es, fr, de, it, pt, nl exported as opt-in name sets, passed with the codecs' names option. |
- Region data is small (about 250 regions, a few fields each), so it ships complete. Only the language data needs a supported list.
- Each package generates its data from ICU at development time, with shared helpers in the repo's
scripts/icu.ts, and commits the output: nothing readsIntlat runtime. A date order ICU reports that quanto doesn't support (Kyrgyzstan's YDM) maps by whether the day comes before the month. - A tag without a region takes its language's likely region from a small bundled table (
de→ DE,en→ US). An unknown language with no region resolves to US. - So
en-AUgets DMY dates, AUD and metric, andpt-BRgets DMY,,decimals and BRL; Portuguese month names are onenames: [pt]away. context.localerecords the canonicalized tag, not the resolved data. Region data can still change between versions (a country adopting the euro); name sets can't change a codec's behaviour unless the app passes a new one.
Always make a best effort:
- A separator character that appears more than once is a grouping separator (
12,345,678). - Both separator characters present: the last one is the decimal separator.
1.234,5→ 1234.5;1,234.5→ 1234.5, regardless of locale. - A single separator followed by exactly three digits is ambiguous (
1,500,1.500). The locale decides; en-US reads1,500as 1500 and1.500as 1.5. - A single separator followed by one, two, or four or more digits is a decimal separator in any locale:
1,5 m→ 1.5 m,2,25→ 2.25. - Thousands grouping must be consistent: every group three digits, or the Indian grouping by two (
1,50,000), which is accepted in every locale since it has no other reading. - Space grouping (
1 000 000) is accepted in every locale; it's unambiguous. Apostrophe grouping (1'000) only where the locale groups with it, and in money, where there are no feet to confuse it with (CHF 1'234.50). - A mixed fraction can be written with a hyphen, as in US building trades (
5-1/2 in), and a whole number can end with a period (5. ft). - Other accepted forms: fractions (
1/2,5½), exponents (1e3), suffixes (1.2k,3M) where the codec allows them. - Numbers in words, for dictation as much as typing, through grammars. The built-in English grammar reads one well-formed number, strictly:
- Cardinals:
zero–nineteen, tens and their compounds (twenty-five,twenty five),hundredwith an optional Britishand(one hundred and five,a hundred), hundreds of 11–99 (fifteen hundred), andthousand,million,billion,trillionin decreasing order (three hundred thousand and five). - Decimals with
pointand one digit word per place (one point two five,point five), signs (minus five,negative two), and a scale after a decimal or fraction (one point five million,half a million). - Fractions:
half,third,quarter,fourth,fifth,eighth,sixteenthand their plurals, with a numerator, spaced or hyphenated (three quarters,three-quarters,five sixteenths), an article (a third), or alone (half), andof/a/anafter them (three quarters of an inch,half a mile); mixed withand(two and three quarters). a/anas one before a word that isn't a number (a mile,an hour), andand a half/and a quarterafter a single quantity (an hour and a half).- Rejected rather than guessed: two numbers in a row (
two fifty,nineteen eighty four,five eleven), scales out of order (two million thousand), a barehundred, homophones (to,too,foraren't numbers; dictation already chooses),ohfor zero, and ordinals (secondstays a unit of time). - Where words meet existing syntax:
andis inside a number only afterhundredor a scale and before a smaller number, or before a fraction; elsewhere it joins compound input (five feet and eleven inches). A hyphen is inside a number only between a tens word and a unit (twenty-five), and range splitting skips separators inside a number in words, sotwenty-five to thirty feetandfive-ten feetboth read as ranges. percentrejects a bare fraction in words, sincehalfis more likely 50% than 0.5%;fiftyis 50%.
- Cardinals:
- Arithmetic:
readNumberreads a product of powers:10^3,10^-3,2*5,6.02×10^23, with*,×,·or⋅for multiplication and optional spaces around operators. Only^and multiplication, because the other operators already mean something:-is a sign and a range separator,+a sign,/a fraction and a unit separator (km/h),xa dimension (2x4).- Exponents are integers, and a power can't chain (
2^3^2) or have a signed base (-2^2could mean 4 or -4). A fraction after a power is rejected (2^3/4). Any of these makes the number unreadable, so the codec's usual issue applies. - The result is computed exactly and rounded once (
1.1*3is 3.3). A result that overflows or underflows to zero is unreadable (10^400). - A superscript exponent right after a digit or a standalone constant is normalized to
^(10³,6.02×10²³,10⁻³,π²); after any other letter it stays part of the unit (m²), and so does^(m^2). - Math constants can be factors:
π/pi,φ/ϕ/phi(the golden ratio) ande. A constant can follow a decimal directly (2π,2 pi,2,5π), take a whole-number divisor (π/2,2π/3) or a power (π^2,π²,e^-1), and join a product (2*e).eneeds the operator, since2estarts an exponent or a unit (2eV). The words need a boundary, soPiBstays pebibytes; the symbols can touch a unit (πrad). UppercaseΠandEaren't constants, and1/2πisn't read (it could mean either way round). The rational part stays exact and the constant multiplies it once. There's noi: values are real numbers. - Money reads its numbers exactly through its own lexer and doesn't accept arithmetic (
$10^6,$2*3); it has magnitude suffixes for large amounts. A quantity's magnitude suffix doesn't apply to an expression (2*3k ftisunknown_unit). Formatting never prints expressions.
- Exponents are integers, and a power can't chain (
- Input is normalized before lexing (
normalize): smart quotes (’ ”→' "), prime marks (′ ″), two apostrophes as inches (5' 11''), the degree sign's look-alikes (ºfrom Spanish and Portuguese keyboards,˚from iOS) to°,^after a letter as part of the unit (m^2→m2, likem²), Unicode fractions (5½,1¹⁄₂), superscript exponents (10³→10^3), non-breaking and thin spaces. Case is handled by matching (see Unit tables), not by normalization.
Default formatters use only bundled data, so they're deterministic and round-trip under the same ctx, as the round-trip rule requires of first-party codecs:
- Numbers use the region's bundled separators (
formatNumber). - Quantities print the number and the unit's first alias:
71 in,1,8 m. This works for custom unit tables without any extra data. - Money prints exactly the currency's minor digits, and the currency's symbol when that symbol parses back to the same currency under the same
ctxand codec options, and the ISO code otherwise:$12.34for USD in en-US, but12.34 USDin en-CA, where$means CAD. The symbol goes where the locale puts it (bundled per region, like the separators):$12.34,12,34 €,R$12,50, with a space after a symbol that ends in a letter (Rp 12,50). ISO codes always follow the amount:12.34 CHF. - Dates use unambiguous forms:
Oct 2, 2026in MDY regions,2 Oct 2026in DMY regions (month names from the codec's name set for the locale's language, or English), and ISO2026-10-02in YMD regions. Never a numeric day/month order. - Times follow the region's hour cycle (bundled, like the separators):
3:00 PMor15:00, with seconds only when they aren't zero. Date-times join the two (Oct 2, 2026, 3:00 PM;2026-10-02 15:00in YMD regions), anddateTime()adds the offset (… -04:00), so the instant survives a round trip.
quanto/formats has the Formatter<T> type and compoundFormatter(name, units, parts, { separator?, trailingZeros? }), which builds a compound formatter over any unit table. @quantojs/common/formats has ready-made formatters built with it, for a codec's format option:
- Compound formatters, from bundled data only, so they're deterministic and round-trip:
feetInches(5'11",6'0",11"),poundsOunces(1 lb 4 oz),stonesPounds(11 st 4 lb) andhoursMinutes(2h 30min; days show as hours). The smallest part is rounded to a whole number and carried (71.6 inis6'0"), leading zero parts are left out, and later zero parts are kept (6'0",2 lb 0 oz).hoursMinutesis the exception: durations are written compactly and a whole number of hours stands alone (2h, not2h 0min). It writesminrather thanmso its output isn't a length when the codec is one of several in amerge. Each works with its codec in@quantojs/common(length,mass,duration); given a unit outside that table, it throws.compoundFormatterthrows at definition time for a part that isn't a linear unit of its table. Intlformatters, opt-in, for richer output:intlUnit({ unitDisplay })in@quantojs/common/formats(localized unit names:5 feet,5 Fuß),intlMoney({ currencyDisplay })in@quantojs/common/money(12.34 US dollars), andintlDate({ dateStyle }),intlTime({ timeStyle })andintlDateTime({ dateStyle, timeStyle })in@quantojs/datetime. They're outside the determinism guarantee and display-only: their output varies between ICU versions and isn't guaranteed to parse back, so a field using one shows raw text for editing (display="raw").intlMoneytakes decimal places from quanto's bundled table, not Intl's.intlUnitcovers@quantojs/common's units that Intl knows, and prints the number and unit ID for the rest.intlDateTimeshows a date-time's wall-clock time as entered, without its offset. They have no fixtures, since exact output depends on the runtime.
A range is two values of one codec, entered in one field. The core has the machinery and quantity ranges; money and dates bring their own range functions:
import { range } from 'quanto';
import { moneyRange } from '@quantojs/common/money';
import { dateRange } from '@quantojs/datetime';
range(length({ defaultUnit: 'ft' })).parse('5-7 ft'); // { start: { value: 5, unit: 'ft' }, end: { value: 7, unit: 'ft' } }
moneyRange(money()).parse('$10-20k');
dateRange(date()).parse('Oct 3-5');range(codec)takes a quantity codec (one with a unit table, includingpace); the type system enforces it.moneyRangeanddateRange(for all four date and time codecs) come from their packages.defineRange(codec, rules?, options?)is what they're all built on, and it's public, likedefineCodec. It does everything that isn't domain-specific: splitting, trying completions, choosing between them, the user's schema, thestart/endschema and formatting.rulessupply the domain part:propose(left, right, ctx)returns textual completions of the two sides, most preferred first. The sides as typed are always tried after them. Completion is textual, soctx.now()throws in it: relative words (tomorrow) are left for the sides' parse, which is where the one clock reading for the range happens and is reported.- A proposal may carry
adjustEnd(end): if the sides parse out of order, try this end instead (checked against the inner codec's schema). Dates use it to roll into the next day or year, since "the next day" can't be written back into text liketomorrow. inOrder(start, end)says whether the range is in order, orundefinedwhen it can't tell.incompatible(start, end)returns an issue when two values can't be the ends of one range at all, orundefined. Such a pair is skipped like a side that didn't parse (its issue is reported if nothing else parses), and fails the structural check, soschemarejects it andformatthrows. Quantities use it for units on different scales.format(start, end, ctx)may return a shorter text for a closed range that shares what its sides have in common, orundefinedfor the defaultstart – end. Like every format, it must read back throughproposeto the same range. Dates use it when both sides are in one year:Oct 3–5, 2026,Oct 30 – Nov 2, 2026,3–5 Oct 2026. Adate()with its ownformatoption keeps the full form, since a shortened range wouldn't match its look.- With no rules, both sides must be written in full. That's the right default for a custom codec with no shorthand.
- The value is
{ start: T; end: T }using the inner codec's values. There is onerawfor the whole field, not one per endpoint. - Open ranges are opt-in:
range(codec, { open: true })(and the same option onmoneyRange,dateRangeanddefineRange) also reads one bound, and its value type widens toOpenRange<T>:{ start: T | null; end: T | null; startExclusive?: true; endExclusive?: true }. It's an option rather than always on because the wider type would make every consumer handle a missing end, and a schema can't narrow a TypeScript type.- Lower bounds:
5+ ft,5 ft+,$500+,≥5,>=5,at least,min,minimum,from,since,no less than, and after the valueor more,or above,or over,or greater,or later,and up,and above,and over,onwards. Exclusive:>5,over,above,more than,greater than,after. - Upper bounds:
≤7,<=7,up to,at most,max,maximum,until,till,by,no more than, and after the valueor less,or fewer,or under,or below,or earlier,and under,and below. Exclusive:<7,under,below,less than,fewer than,before. - An exclusive flag is set only when the bound itself is excluded, and only on the bound of an open range; the schema rejects it anywhere else. Both sides null is malformed.
- Formatting uses symbols, which read back in any language:
≥ 5 ft,> 5 ft,≤ 7 ft,< 7 ft. - Closed ranges are tried first, so
from 5 to 7 ftis closed andfrom 5 ftis open.
- Lower bounds:
- Every range function takes the shared options (
{ schema?, format? }). Theidis derived like merge's,range(length), somerge([length(), range(length())])is valid and can accept both5 ftand5-7 ft. - Inner codecs know nothing about ranges. Codecs have no range hook; range rules live with the range function of their domain.
- Parsing is two passes:
- Split. Separators:
-,–,—,to,until,through, andandafter a leadingbetween(between 5 and 7 ft). A leadingfromis dropped (from 5 to 7 ft). Hyphens also appear inside values (negative numbers, ISO dates2026-10-03), so every candidate split is tried. - Complete and parse. For each split, the rules propose completions. Both sides of each are parsed with the inner codec, and the first completion where both parse and
start <= endwins; then the first that's in order afteradjustEnd; then the first that parsed at all.
- Split. Separators:
- The completion rules of each domain. The rule throughout: a side borrows what it's missing from the other side, and when borrowing could go two ways, the reading that keeps
start <= endwins.- Quantities (
range): a side with no unit borrows the other side's unit text.5-7 ft→ 5 ft–7 ft;5'10"-6'needs nothing. Each side's number is read with the codec's own number syntax, so pace ranges complete too:5:00-5:30 /km, as do duration's clock times:1:00-1:30 min. A side with no magnitude borrows the other side's only if the result stays in order, as for money:2-3k ft→ 2,000–3,000 ft,2-3 million ft, but500-1k ft→ 500–1,000 ft. - Money (
moneyRange): a side with no currency (symbol or code) borrows the other side's:$10-20→ $10–$20,10-20 EUR→ €10–€20. A side with no magnitude suffix borrows the other side's only if the result stays in order:$10-20k→ $10k–$20k and$1.5-2M→ $1.5M–$2M, but$500-1k→ $500–$1,000. - Dates (
dateRangeoverdate()): a side with only a day number borrows the other side's month and year:Oct 3-5→ Oct 3–Oct 5. A side with no year borrows the other side's. When neither side has a year and the end would fall before the start, the end moves to the next year:Dec 30 - Jan 2→ Dec 30, 2026–Jan 2, 2027. - Times (
dateRangeovertime()): a side with no meridiem borrows the other side's, unless that would put the start after the end, in which case the start takes the opposite meridiem.9-11pm→ 9pm–11pm;9-5pm→ 9am–5pm. - Date-times (
dateRangeoverlocalDateTime()ordateTime()): a side with no date borrows the other side's, wherever the date was written:Oct 3 3-5pm,tomorrow 3-5pmand3-5pm tomorrowall put both times on one day. The times complete as for times. When both sides share a borrowed date and the end time is before the start time, the end moves to the next day:Oct 3 10pm-1am→ Oct 3, 10pm–Oct 4, 1am. FordateTime, a side with no UTC offset borrows the other side's written offset, before falling back toctx.now's.
- Quantities (
- Which split wins: splits are tried left to right, and the first split with any completion where both sides parse provides the value. If none does, the result carries the issues of the first side that failed, which is the most specific failure (
5-7 kgreportsunknown_unit, from5 kg). Text with no separator isunparseable. - Where ordering is knowable: quantities (compared in the base unit, with
compare's tolerance), money in a single currency, dates, times and local date-times (as strings), and date-times (as instants). A range built withdefineRangeand noinOrdercounts every reading as in order. - Formatting prints both sides in full, separated by
–(an en dash with spaces):5 ft – 7 ft,$10.00 – $20.00. It reads back under the samectx, even with negative values. - Ordering only picks between completions. It isn't enforced:
7-5 ftparses as typed, and rejecting it is left to the user's schema, which can usecomparefromquanto/quantityor@quantojs/common/money. - Open-ended ranges (
5ft+,under 10 kg) are deferred.
dimensions(codec, { count }) reads several values written together: 24 × 36 in, 2 m × 50 cm, 1920x1080, 24 x 36 x 10 cm. It wraps any sync codec, and the value is an array of the inner codec's values. The id is dimensions(<inner id>).
countis required: a whole number (2for a print,3for a box), or{ min, max }, inclusive, either end optional ({ min: 2 }). Explicit, because no default is right for both sheets and boxes. Too many or too few parts is awrong_countissue; a malformed count throws at definition time.- Separators:
×,*,by, andx(any case) when it follows something and a number follows it, so24x36,24in x 36inand2 by 4split, and units containing anx(lx) don't. A×orxbefore10^is scientific notation (6.02×10^23), not a separator. An empty part is unparseable. - Shared units: a unit after the last part applies to the bare numbers before it, as written on signs and spec sheets (
24 × 36 in,1.5k x 2k ft), and any part may carry its own (2 m × 50 cm,5'6" × 8'). A unit only on the first part isn't shared (24 in × 36ismissing_uniton the second); adefaultUnitfills it, as with any bare number. The last part is parsed first, so its issue is the one reported; issues carry the part's index in theirpath. - Formatting joins the parts with
×and writes a unit every part shares once, after the last (24 × 36 in), which reads back the same. Parts with different units, or a custom inner format, are written in full. - It doesn't pass on the parts' alternatives, as
rangedoesn't: there would be one per combination of readings.
Wherever a value is entered by a person, store both the text and the parsed value:
type QuantoValue<T> =
| { raw: string; value: T } // parsed and valid
| { raw: string; issues: Issue[] }; // didn't parse, or failed validationrawis exactly what was typed, orformat(value)if the value was set by a picker or default.- There is never a value alongside issues. A value that fails validation is not stored, so nothing downstream can mistake it for a valid one.
valueis parsed at entry time and is authoritative.rawis kept for audit, display and re-editing.- The parse
contextis not part of the envelope. Apps that want replay store it separately (see Parse context). - Servers validate
valuewithcodec.schema, and store the schema's output, which equalsvaluewhen the schema only validates. They never need to re-parseraw, which would give different results for relative input. - A restored
rawis never re-parsed unless the user edits it. - Value shapes (
Quantity,Money, the date forms) are stored contracts and change only with a major version.
@quantojs/react is a thin UI over a codec's parse and format, so the core package has no UI code and no React dependency. Everything UI-specific (accessories, keyboard hints, display modes, echo) is a component prop, never a codec property. It has three layers:
- The field state machine (
reduce,initialState,echo): plain TypeScript,(state, event) → { state, commit? }. It holds every rule below, so a React Native adapter can share it later; it moves to its own package when there's a second user. Its spec is a JSON fixtures file of event scripts, like a codec's. useQuanto(codec, options), the real API: it owns the text and returnsinputPropsto spread on any<input>, plusecho,showEcho,issues,value,pick,alternatives,chooseandcommit, for building your own field.<QuantoInput>, an unstyled default built on the hook: an input, the echo, the issues and an optional accessory, targeted bydata-quantoattributes. It takes sync and external codecs alike (see External codecs).
Features go in the hook; the component is the minimal default. Most apps are expected to build their field on the hook, with their own markup or a form library's. <QuantoInput> exists for the quickstart and as the reference wiring (its aria-describedby always points at elements that exist), and grows only when the default experience needs it. Anything an app's markup decides (where a list goes, how it looks, how it's positioned) stays out of it: completions, for instance, are in the hook only.
import { QuantoInput, QuantoProvider } from '@quantojs/react';
import { length } from '@quantojs/common';
<QuantoProvider ctx={{ locale: 'de-DE' }}>
<QuantoInput
codec={length({ defaultUnit: 'in' })}
defaultValue={{ value: 70, unit: 'in' }} // raw = format(value)
defaultRaw={`5'10"`} // with defaultValue: restore exactly, no re-parse
onChange={(v, { context }) => {}} // QuantoValue<T>, on commit only: blur, Enter, a pick
display="formatted-on-blur" // | 'raw' | 'formatted'
restoreOnEdit // focus puts back the typed text; off by default
inputMode="text" // mobile keyboard; see below
accessory={CalendarPicker} // optional
/>
</QuantoProvider>- Uncontrolled by default, since it owns the envelope internally. An optional controlled
value: QuantoValue<T>is available;nullclears the field. While someone is editing, their text wins: a new controlled value that arrives then is dropped, not queued, and their commit on blur settles it (the parent gets it throughonChangeand can setvalueagain). Passing back the envelope the field just emitted is a no-op. A parent that rejects a commit by leavingvalueunchanged doesn't reset the text, since an unchangedvalueisn't an update; to reject one, set a differentvalue. - Commits happen on blur, Enter and a pick, never per keystroke. A commit emits the stored envelope:
{ raw, value }, or{ raw, issues }when the text didn't parse or the schema rejected it. Text that wasn't edited since the last commit, default or controlled value never commits again, so focusing and leaving a field can't change its value. An untouched empty field doesn't commit. Clearing the text commits whatever the codec makes of'': anemptyissue, ornullfor anoptionalcodec. Enter is left to bubble, so a form still submits. onChange's second argument carries the parsecontext(absent when the value didn't parse), for apps that store it for replay.- Issues show after a failed commit and clear as soon as the text parses again, rather than waiting for the next commit.
- Display modes say what the input shows after a commit. The stored
rawis always what was typed.formatted-on-blur(default): a commit on blur replaces the text withformat(value); Enter leaves the typed text. Focusing again keeps the formatted text, which is safe because first-party formatters round-trip.formatted: every commit, including Enter, showsformat(value).raw: the text stays as typed. Use it with a display-only formatter (feetInchesrounds, so180 cmwould show as5'11").
restoreOnEdit(off by default) is for the formatted modes: focusing the field puts back the committedraw, so people edit the text they typed rather than the formatted value. The restored text counts as unedited, so it isn't re-parsed, it echoes the stored value, and leaving it untouched showsformat(value)again without committing. A pick or a default has no typed text, so there is nothing to restore. With it on, a display-only formatter is safe in a formatted mode, since the rounded text is never what gets edited.- Live echo: the hook parses on every keystroke to show its interpretation (
5'11→5'11"), but only emits on commit. There is no "incomplete" parse state; while the text doesn't parse, there's simply no echo. The hook'sshowEchosays whether to display the echo: not when it only repeats the text, and not while issues show, so every field built on it follows the same rule.echoitself stays set either way, since its value and alternatives are still the reading. The hook'salternativesare the live parse's while typing (anambiguousfailure's too) and otherwise the last commit's, so a field can offer a chooser, shown with the issue after anambiguouscommit;choose(value)takes one. Alternatives from a commit remain in transient field state across blur and Enter, so choosing one is not interrupted by the blur that precedes a click. They clear on editing, a new external value or a pick; they never enter the stored envelope, and restored text is not re-parsed to reconstruct them. Composing an IME character is never parsed. - Raw display always shows the interpretation next to the text, so stale relative input (
tomorrow) is never misleading. - Accessories are a component prop (e.g. a calendar icon that opens the OS picker). An accessory receives
{ value, onChange, focused }, and choosing a value sets the text toformat(value)and commits it, after the codec's schema. Text entry always stays available. - Context comes from
<QuantoProvider ctx>or a field'sctxprop, and defaults toen-US. The browser's locale is never read implicitly, so server and client render the same text. In a browser, leavenowunset. - Keyboard hint is the
inputModeprop. Height needs a keyboard that can type'and", which a numeric keypad can't, so the default istext. - Accessibility:
aria-invalidfollows the shown issues,aria-describedbypoints at the echo and the issues, and the issues are arole="alert"region, so they're announced on commit. The echo isn't live, so nothing is announced per keystroke. - The codec needn't be stable across renders: parsing is cheap and pure, so
codec={length({ … })}inline is fine. - Form libraries are not a design driver. The field exposes
value/onChange/onBlur/ref, which is enough for React Hook Form'sControllerand TanStack Form; dedicated adapters may follow. - No
codecmeans plain text, liketext()from@quantojs/common(the package defines its own, so it doesn't depend on@quantojs/common): the value is the trimmed string as typed (blank is anemptyissue, unless wrapped inoptional). An "accept anything" codec is@quantojs/anything(see Anything). - External codecs get their own hook,
useExternalQuanto, which parses on commit only, and use the same<QuantoInput>(see External codecs). - Not yet: compound parts (
Quanto.Root,Quanto.Input, …) for custom layouts; the hook covers custom layouts meanwhile. - Platforms: the core is pure TypeScript with no runtime dependencies and no UI code. Web is first, with React 19. React Native is another separate adapter over the same state machine.
-
ESM only, with
"sideEffects": false. -
No runtime dependencies. The Standard Schema interface is vendored into
src/(types only), as the Standard Schema spec recommends, so there is no dependency on@standard-schema/spec. -
Protocol and codecs.
quantois the protocol: what every codec must do and the helpers to do it, held to strong opinions and expected conformity. It ships no codecs. The codecs are a bootstrap, in@quantojs/commonand the dedicated packages: use them, copy and modify them, or write your own. The line isn't size;quantity()and the compound formatter builder are in the core because they're how codecs are defined, not codecs themselves. -
quanto's subpath exports:quanto: the protocol.defineCodec;defineExternalCodec,isExternalCodecandparseFromCompletions;formatWithFallbackandisInvalidValueError;quantity; the primitivesnormalize,readNumber,formatNumber,readNumberToken,readWordToken,formatDecimalParts,numberSpanandlookupRegional;merge,optional,approx,infinite,isInfinite,range,defineRangeanddimensions; and the typesCodec,ExternalCodec,ExternalCodecDefinition,CodecOptions,Ctx,CtxExtensions,Signal,ResolvedCtx,Locale,ParseResult,ParseOutcome,ParseContext,Issue,IssueCode,Grammar,NumberGrammar,QuantoValue,Quantity,Approx,Infinite,InfiniteOptions,WithInfinite,Range,OpenRange,RangeOptions,RangeRules,RangeProposal,QuantityCodec,QuantityOptions,QuantityDefinition,UnitTable,UnitDefinition,DefaultUnit,NumberSyntaxandNumberToken.quanto/quantity: quantity operations (convert,compare).quanto/formats: theFormatter<T>type andcompoundFormatter.quanto/testing:roundTrip,quantityWithin(its rounding allowance for quantities) andrunFixtures, the generic fixture runner.
-
@quantojs/common's subpath exports:@quantojs/common: the quantity codecs and their unit tables (length/lengthUnits,mass/massUnits, …);number,percent,ratioandtext.@quantojs/common/money:money, theMoneytype,add,subtract,compare,scale,convert,allocate,roundWithMode,moneyRange,intlMoney, and the currency data (isKnownCurrency,minorDigits).@quantojs/common/css-color:cssColor, theCssColorandCssColorSpacetypes,formatCssColor,checkCssColor,cssColorSpacesandcssColorNames(see CSS colors).@quantojs/common/odds:odds, theOddstype,convert,compare,fromDecimal,toDecimalandimpliedProbability.@quantojs/common/formats: ready-made formatters (feetInches,poundsOunces,stonesPounds,hoursMinutes,intlUnit).
-
Repository layout. A bun workspace:
packages/quanto(the core, npmquanto),packages/common(@quantojs/common),packages/datetime(@quantojs/datetime),packages/music(@quantojs/music),packages/react(@quantojs/react),packages/libpostal(@quantojs/libpostal),packages/libphonenumber(@quantojs/libphonenumber),packages/geo(@quantojs/geo),packages/sizes(@quantojs/sizes),packages/anything(@quantojs/anything) andapps/site(the website). The private root holds the shared dev tooling (TypeScript, tsdown, vitest,tsconfig.base.json, onevitest.config.tsfor every package) and the repo docs (DESIGN.md,AGENTS.md).bun run typecheck,bun run buildandbun run testat the root cover every package. -
UI adapters are separate packages (web React first, React Native later). The core package never imports them.
-
@quantojs/common(packages/common) has the common codecs (see above).@quantojs/datetime(packages/datetime) is a separate codec package:date,time,localDateTime,dateTime,dateRange,intlDate,intlTime,intlDateTime. So is@quantojs/libpostal(packages/libpostal):address(see Addresses),@quantojs/music(packages/music):pitchandtimeSignature(see Music),@quantojs/libphonenumber(packages/libphonenumber):phoneNumber(see Phone numbers),@quantojs/geo(packages/geo):coordinates(see Coordinates), and@quantojs/sizes(packages/sizes):ringSizeandshoeSize(see Sizes).@quantojs/anything(packages/anything) isanything(), over all of them (see Anything). -
Core, common or package? A codec never goes in the core. A new first-party codec goes in
@quantojs/commonwhen its behaviour is complete and settled: unit tables (length,dataSize), small parsers (percent,pacewith its number syntax), and finished domains with their own subpath (@quantojs/common/money,@quantojs/common/css-color). A domain that's intentionally incomplete or still evolving, so its parse results are expected to change between releases, gets a dedicated package:@quantojs/datetime, whose grammar covers the common case with a list of omissions that will shrink. What goes in the core is what codecs share: the contract, definition helpers and primitives a codec would otherwise reimplement, decided once. -
Why packages. Not bundle size: everything tree-shakes. The protocol and the codecs change for different reasons: the protocol rarely, and only by deliberate decision; codecs whenever their parsing improves, and parse changes affect replay (see Parse context). Separate packages let the codecs evolve without moving the protocol's version, and an evolving domain without moving the settled ones.
-
How they're built. A codec package uses only quanto's public API, imported by name (
quanto,quanto/quantity,quanto/formats,quanto/testing), exactly as a custom codec would. That keeps Principle 5 honest: anything a package needs that isn't public is a gap in the API, not a reason to reach into internals.quantois a peer dependency (workspace:^in the repo, replaced by the real version on publish). In the repo,quantoand the@quantojspackages resolve to their source (tsconfig paths for typechecking, a vitest alias for tests), so nothing needs building first.- The core's own tests of composition (
range,merge,dimensions, …) use@quantojs/common's codecs, as a dev dependency. The cycle is test-only: the published core never imports a codec package. - Codec packages don't depend on each other.
@quantojs/reactdefines its own plain-text codec rather than importingtext(). - Their codecs all follow the same rules: extensive fixtures, round-trip properties, and
DESIGN.mdas the source of truth.
-
Nothing is attached to the component or a namespace object.
-
Releasing.
quantoand the@quantojspackages release in lockstep, from 0.1.0, andscripts/packages.tslists them in publish order:quantofirst, since the others' peer dependency points at it, and@quantojs/anythinglast, since it depends on the codec packages. To release, runbun scripts/release.ts X.Y.Zon an up-to-date main. It bumps every package, runsbun install(bun fillsworkspace:^ranges in from the lockfile),bun run checkandbun run build, packs withscripts/pack.ts(which fails on mismatched versions or a stale range), lints the tarballs, and commits and tagsvX.Y.Z; if a step fails, it undoes the bump. It commits only the package.json files and bun.lock, so other uncommitted work stays put. Then push the commit and the tag (--pushdoes it), and.github/workflows/publish.ymlchecks, builds, packs, lints again and publishes through npm trusted publishing, with provenance. There's no token: each package's settings on npmjs.com trustendograph/quanto'spublish.yml. The workflow skips versions already on npm, so a failed run can be re-run.- A new package is published locally once, since trusted publishing can only be set up on a package that exists:
bun publish --access public --otp <code>in its directory, afternpm login, with a code from the npm account's authenticator app. Then add the trusted publisher on npmjs.com and add it toscripts/packages.ts.
CI runs the same pack and lint steps on every push.
- A new package is published locally once, since trusted publishing can only be set up on a package that exists:
-
Consumer reference. The README's "Using quanto" section and the TSDoc on the exported types are the reference for apps and agents using quanto. The README ships in every npm package, so it's at
node_modules/quanto/README.md; the TSDoc reaches agents at the point of use, through the published.d.tsfiles. The storage rules (store{ raw, value },valueis authoritative, never re-parseraw, validate withcodec.schema,contextis opt-in) must appear in both, in particular onParseResult,QuantoValue,ParseContextandCodec.schema. A SKILL.md, if one is ever shipped, is generated from the README, never the source of truth. -
The package ships an
AUTHORING.md(see below) so it is discoverable insidenode_modules. It is deliberately not calledAGENTS.md: the repo's rootAGENTS.mdholds instructions for agents working on quanto itself and is not shipped.
Every codec, first-party or custom, ships fixtures, and CI enforces it. Fixtures are the codec's spec: they pin down everything finicky about it. For first-party codecs they are extensive, covering common inputs, locales, compound forms, every issue code the codec can produce, alternatives, parse context and formatting. Fixtures are JSON, next to the codec, so they can be read and written without touching TypeScript and run by one generic runner:
// packages/quanto/src/merge/fixtures.length-duration.json
[
{ "parse": "1m",
"value": { "codec": "length", "value": { "value": 1, "unit": "m" } },
"alternatives": [{ "codec": "duration", "value": { "value": 1, "unit": "min" } }] }
]
// packages/datetime/src/date/fixtures.json
[
{ "parse": "tomorrow", "ctx": { "now": "2026-09-30T23:30:00-04:00" }, "value": "2026-10-01",
"context": { "locale": "en-US", "now": "2026-09-30T23:30:00-04:00" } }
]- A parse fixture is
{ parse, ctx?, options?, value | issues, alternatives?, completions?, context? }.issueslists the expected issue codes, in order.alternativesmay go with eithervalueorissues; a missingalternativesmeans none are expected, and likewisecompletions(external codecs only; see Completions).contextis checked only when given. - A complete fixture is
{ complete, ctx?, options?, completions }, for external codecs with completions. Each expected completion is{ label, value }: the runner resolves lazy ones through the stub before comparing, so the file stays JSON. - A format fixture is
{ format, ctx?, options?, text }. optionsare passed to the codec factory, so one file covers the factory's JSON-expressible options (defaultUnit,canonicalUnit,defaultCurrency…).- A codec can have several fixture files (
fixtures.json,fixtures.<variant>.json). Wrappers (merge,range,optional,approx,infinite) have fixtures too, one file per inner-codec combination worth pinning down:rangeoverdatefor completion,mergeoverlengthanddurationfor alternatives. runFixtures(factory, fixtures, { test })inquanto/testingruns a file. It takes the test function (vitest'stest,node:test…) rather than importing a framework. Each case is named after its input and context, so a failure reads as input, expected and actual.- Fixtures must not depend on the machine. The runner fails any fixture whose result
contexthas anowthe fixture didn't pass; time-dependent fixtures passctx.now. - An LLM adding a codec writes the fixtures first, then the parser.
- Round-trip property. Required for every first-party codec, and for any custom codec whose formatter is meant to round-trip:
parse(format(v))round-trips within the formatter's rounding across a set of values. TheroundTrip(codec, values, { test, same? })helper inquanto/testingdoes this;valuesis a plain array, so no property-testing library is needed. It checks the value as well as the text, since stable text can hide a changed value (a formatter that drops an offset the parser then assumes): the parsed value must besameas the original, by default deep-equal (numbers to an ulp), and formatting it again must give the same text. A formatter that rounds declares how much:quantityWithin(codec, { places, unit? })allows half a unit in the last printed place, inunitor the original's unit ({ places: 3 }for the default quantity formatter,{ unit: 'in', places: 0 }forfeetInches). - What isn't tested: plumbing. No unit tests that a factory returns a codec, that
defineCodecwires upschema, that definition-time errors fire, or that types infer. Behaviour that matters shows up in fixtures; the rest shows up in typecheck or on first use. - First-party codecs follow the same rules. They have no private escape hatches.
- One check command.
bun run checkruns typecheck, unit tests, all fixture files and the round-trip properties. In the quanto repo, CI runs it; implementing agents don't (seeAGENTS.md). App authors run it for their own codecs. AUTHORING.md, shipped in the package, is the codec authoring guide:defineCodec, the primitives, the file layout (src/codecs/<name>/{index.ts, fixtures.json}), the issue codes, the fixture format, the round-trip rule and the check command. It stays short.
Additive: no existing type or function changed for them. A first-party LLM codec is not built yet (see the end of this section). An external codec hands parsing to something outside the field: a model, a server, a worker. The marquee example is an LLM-powered codec, where a model parses free text into a structured value.
Parsing never lives in two places. Once a parse is external, all of it is external, including whatever merge, range or approx would have done: a service that should accept a length or a duration, a range, or about 5, does that itself. That is why only parsing is async, and why the wrappers don't take external codecs (except optional, which doesn't parse; see below). It is the reason for the name: "external" says where the parse lives, which is also what explains the limits; "async" would only describe the mechanism.
interface ExternalCodec<T> {
readonly external: true; // what optional() and the guards read
readonly id: string;
parse(text: string, ctx?: Ctx): Promise<ExternalParseResult<T>>;
complete?(text: string, ctx?: Ctx): Promise<Completion<T>[]>; // optional; see Completions
format(value: T, ctx?: Ctx): string; // sync, like every codec's
readonly schema: StandardSchemaV1<T, T>; // sync, like every codec's
}
// Either branch may also carry completions; see Completions.
type ExternalParseResult<T> = ParseResult<T> & { completions?: Completion<T>[] };- A separate interface, not a flag on
Codec.Codecdoesn't change. Sinceparsereturns a Promise, anExternalCodecisn't assignable toCodec, so passing one tomerge,range,approxoruseQuantois a type error.external: truemarks it at runtime:optionalreads it to pick its return kind, and those others throw a programmer error that says where the work belongs, for JavaScript callers.isExternalCodec(codec)tests it. formatis sync. Formatting is local and deterministic, from the value alone, so anything that displays a value (a list view, the field after a commit, server rendering) works the same for every codec, with no pending state. An LLM codec formats with a template, which also round-trips. Prose from a model, if wanted, is the app's own display-only call, never used to setraw.schemais sync, as for every codec. quanto has no async validation: checks that need a server (is this name taken?) belong to the app or form layer, after commit. Theschemaoption is synchronous everywhere, external codecs included.checkis sync. It's structural.
defineExternalCodec is defineCodec with an async author parse, and it does the same work: empty input (returned without calling the service), resolving ctx, the structural check, the user's schema, the parse context, the format override and the composed schema.
interface RecipeYieldOptions extends CodecOptions<Quantity> {
// The service: text in, a value or issues out. Rejects when it can't answer.
service(request: { text: string; locale: string }, init: { signal?: AbortSignal }): Promise<ParseOutcome<Quantity>>;
}
export const recipeYield = (options: RecipeYieldOptions) =>
defineExternalCodec<Quantity>({
id: 'recipe-yield',
parse: (text, ctx) => options.service({ text, locale: ctx.locale.tag }, { signal: ctx.signal }),
format: (value, ctx) => `${formatNumber(value.value, ctx)} ${value.unit}`,
check: checkQuantity,
options,
});- It is the one builder for external codecs, first-party or custom, so Principle 5 still holds. It shares its machinery with
defineCodec. - The author's
parsereturnsPromise<ParseOutcome<T>>. ItsResolvedCtxalso carriessignal(below), to pass to whatever it calls. - The definition is
ExternalCodecDefinition<T>:CodecDefinition<T>with that asyncparse.
- Bad input is an issue; a failed service is a rejection. When the service understood the text and it isn't valid,
parseresolves with issues, as for any codec. When the service couldn't answer (network error, rate limit, timeout, a malformed reply),parserejects with that error. An outage isn't the user's mistake, so it must never become{ raw, issues }in a stored envelope. The host decides the retry policy. ctx.signal?: AbortSignalcancels an in-flight parse, which rejects withsignal.reason(the platform convention).defineExternalCodecchecks it before calling the service, and races the service against it, listening before the service starts, so an abort rejects at once (even one raised while the service is starting, and even if the service ignores the signal), and withsignal.reasoneven if the service then fails with its own error. Sync codecs ignore it. It isn't recorded inParseContext. The core has no DOM or Node types, so itsSignaltype is the platform'sAbortSignalwherever that's declared, and otherwise theabortedandreasonthat quanto reads.- Context. A successful parse reports
contextlike any other. An external parse may not be reproducible, but Principle 8 only requires the stored value to be stable. Recording provenance (which model produced a value) is not planned; if it's needed later, it goes oncontext.
A completion is a candidate value for the text as it stands, which may not be finished. There is no point at which input is complete: 1600 Amph wants completions, and so does an address that already parses but could be more precise (a suite number, a nearby match). So completions are offered for whatever the text is, parsed or not.
type Completion<T> =
| { label: string; id?: string; value: T } // the value is known
| { label: string; id?: string; resolve(ctx?: Ctx): Promise<T> }; // fetched only when chosen (a place's details)ididentifies a completion when labels repeat (12 Main Stin two towns); the field uses it for list keys.completeis optional, onExternalCodecDefinitionand on the codec. It uses the same service asparse, so an app wires the provider once.defineExternalCodecreturns[]for empty text without calling the service, runscheckand the schema on known values (dropping ones the schema rejects), and wrapsresolveso its value goes throughcheck(a failure is a bug, so it throws) and the signal. The schema runs on a resolved value when it's picked, as for any pick: one it rejects commits{ raw, issues }.- A completion is never applied without being chosen. Commit parses the typed text; it never takes the first completion. That is the line between completing and guessing.
- Choosing one is a pick: the text becomes
format(value),rawis set from it, and it commits without a parse, after the schema, as for any pick. This is the difference from an alternative, which is a reading of the typed text and keepsraw. - Parse results carry completions. An external parse may return
completionson either branch: with anambiguousissue, the candidates it couldn't choose between; on success, refinements of the value. They're the candidates the parse actually saw, without a second call to the service. Lazy candidates (labels that need a fetch to become values) go here; candidates that are already values can go inalternativesinstead. Only external results carry completions:resolveis async, and only the external field can wait for a pick. - Parse and complete are different operations on one service.
completeanswers "what might this become?" and an empty list is a normal answer;parseanswers "what is this?" and has to commit. Parse is complete plus a choosing rule, and the rule is the codec's to state. parseFromCompletions(complete, { accept? }), fromquanto, builds an authorparsefrom acompletefunction for services that only complete. The defaultaccept: no completions isunparseable; exactly one is resolved and becomes the value; several are anambiguousfailure carrying them ascompletions. A codec with a better signal (an exact match with the text, a confidence score) passes its ownaccept. "Take the first" is never a default: completion services treat text as a prefix, so12 Main Stcompletes to120 Main St.- Failures are quiet. A
completethat rejects shows no list; it doesn't put the field infailed, since completions are help, not the value. Aresolvethat rejects is a service failure like a parse's (below). ctx.session?: stringidentifies a completion session, for services that bill completions and the details fetch as one (Google's session tokens). The field sets it: a new session starts with the first edit and ends when a value is committed (by a parse or a pick), andcomplete,resolveandparsewithin it get the same one. It isn't recorded inParseContext.ctx.signalcancelscompleteandresolveas it doesparse.- Not to be confused with a range's textual completions (see Ranges), which are internal to
defineRange.
optionalaccepts an external codec: it maps empty text tonullwithout calling the inner codec, so it adds no parsing, and passescomplete, completions and alternatives through. Its return type follows its argument:optional(external)is anExternalCodec<T | null>. That takes two overloads, sync and external, an exception to Principle 2 (asrange'sopenis), because a conditional type over the codec would change the type parameter from the value to the codec and break existingoptional<T>(codec)calls.merge,range,defineRange,approx,infiniteanddimensionsaccept sync codecs only, per the rule above.
External codecs get their own hook, useExternalQuanto, over their own field state machine (reduceExternal, initialExternalState). The sync field's behaviour is built on parsing every keystroke; an external field parses only on commit, and is a separate, smaller machine rather than a mode of the sync one. The component is shared: <QuantoInput> takes either kind of codec.
- Kept from the sync field: commits on blur, Enter and a pick (a pick commits at once, without a parse, after the codec's sync schema); display modes and
restoreOnEdit(formatis sync, so they work unchanged); controlled and uncontrolled use; unedited text never re-commits; issues show after a failed commit. Unlike the sync field, issues stay until the next commit settles, since nothing is parsed while typing. - Dropped: the live echo. Nothing is parsed per keystroke; completions take its place (below).
- Alternatives come from a commit: a successful one's, or an
ambiguousfailure's alongside its issue. They're kept and cleared as in the sync field, and choosing one commits it with the typedraw. - The machine stays pure.
reduceExternalreturns, alongside the state and any commit, effects:request: { id, text }, a parse to start, andabort: ids, requests that are no longer wanted. The adapter runs them and dispatches the outcome back asresolved { id, result }orrejected { id, error }. A result whoseidisn't the current request is ignored. Its spec is a fixtures file of event scripts, like the sync field's, and a React Native adapter can share it. - States add
pending(a parse is in flight, and what started it: blur or Enter, which decides whether the result is shown formatted) andfailed(the service rejected). A failure keeps the text, still edited, and commits nothing, so the next blur or Enter parses it again; aretryevent does it now. An edit clears it. - Cancelling. The hook owns an
AbortControllerper request. Editing the text or a pick aborts the one in flight, and so does unmounting. React can also tear the hook's effects down and set them up again while keeping its state (StrictMode, a hidden<Activity>): the teardown aborts the parse, and the setup starts it again under the same id, so the field picks up where it left off rather than staying pending. The service gets a signal that also follows the caller'sctx.signal(a form-level timeout, say); a cancellation from there isn't the field's own, so it comes back as a rejection and the field settles as failed. Blurring and refocusing without editing lets it finish and commit. A blur while an Enter's parse is pending doesn't start another. - The hook exposes
pending,failed,error,retry(),alternatives,choose(value)andsettled(): Promise<QuantoValue<T> | undefined>, which resolves once no parse (or chosen completion's fetch) is in flight, with the committed envelope, and rejects with the service's error if that parse failed, or with anAbortErrorif the field is unmounted or hidden first (and at once, when called while it is), so a submit awaiting it never hangs. An app awaits it before submitting.inputPropsaddaria-busywhile pending. <QuantoInput>with an external codec renders the input, an optional accessory and the alert region, withdata-quanto-pendinganddata-quanto-failedon the wrapper while those hold. A failure showsfailedMessage(English by default) in the alert region. It has no echo. It switches oncodec.external, rendering one of two inner components by the codec's kind, so each calls one hook and a change of kind remounts the field rather than breaking the rules of hooks.failedMessageis accepted with either kind and only used by an external field, so the props stay one plain type.- Controlled values. As in the sync field, an edit wins over a controlled value that arrives meanwhile, and that includes an edit whose parse is still in flight after blur.
- Completions are in the hook only, and opt-in:
useExternalQuanto(codec, { completions: true }). With a codec that hascomplete, it asks while typing; without one, the list still shows a commit's completions and alternatives. Opt-in for two reasons: the input's combobox attributes are only correct when the app renders the list, and a billed service shouldn't be asked for completions nobody shows. No component renders them; the list's markup, look and positioning are the app's (see the recipe below).-
An edit emits a
complete: { id, text }effect; the hook debounces it (completionDelay, default 150 ms), so the machine has no timers. Starting a parse cancels the completions request in flight, since the parse's answer supersedes it. Results come back ascompleted { id, completions }; stale ids are ignored, and a rejection just leaves the list empty. -
One list. While typing it shows the completions for the current text; after a commit, the result's completions and alternatives. Each entry says which it is, since choosing them sets
rawdifferently. -
Choosing a known value picks it at once. A lazy one enters
resolvinguntilresolvesettles: an edit or another pick aborts it,settled()waits for it, and a rejection puts the field infailed, whereretry()or Enter fetches it again. The text is still what was last committed, so a blur does nothing: unedited text is never parsed or committed again. -
Keyboard: the arrow keys move the highlight (wrapping, and reopening a list Escape closed); Enter picks the highlighted entry, without letting the form submit, or with none commits the text as usual; Escape closes the list until the next edit or arrow key. The pointer highlights what it's over.
listPropsanditemPropskeep focus in the input on mousedown, so choosing with the pointer isn't a blur that would start a parse. The hook'scommit()always commits the text (acommitevent), whatever is highlighted. -
An edit counts as focus. Text only changes in a focused input, so an edit that arrives without a focus event (autofill, automation) still opens the list.
-
A blur while a chosen completion is fetched doesn't commit the text: the fetch will. A controlled value that arrives meanwhile is dropped.
-
The machine numbers sessions, and the hook turns each into a token (a UUID where the platform has one) for
ctx.session(see Completions). -
The hook exposes
completions, undefined unless the option is on, so the opt-in shows in the types:completions?: { open: boolean; items: CompletionItem<T>[]; // { kind: 'completion' | 'alternative'; label: string; … } highlighted: CompletionItem<T> | undefined; resolving: boolean; listProps: …; // id, role="listbox", and focus moving here isn't a blur itemProps(item): …; // id, role="option", aria-selected, choose on click select(item): void; }
With it on,
inputPropsaddrole="combobox",aria-expanded,aria-controls,aria-activedescendant,aria-autocomplete="list"and the keyboard handling. Alternatives are items too, withkind: 'alternative', so an ambiguous commit opens the list;alternativesstays on the field for apps without completions. -
The recipe, in the README and the site, is the reference, not a shipped component:
const field = useExternalQuanto(codec, { completions: true }); <input {...field.inputProps} /> {field.completions?.open && ( <ul {...field.completions.listProps}> {field.completions.items.map((item) => ( <li key={item.key} {...field.completions.itemProps(item)}>{item.label}</li> ))} </ul> )}
item.keyis the completion'sidwhen it has one, so labels can repeat (12 Main Stin two towns).
-
- Enter still bubbles, unless it picks a highlighted entry. Whether a form may submit while a parse is pending is the app's decision;
pendingandsettled()give it what it needs.
An external service isn't part of the codec's code and may not be deterministic, so fixtures and the round-trip property run against a stub:
- The service is injected. An external codec takes what it calls as an option: a plain async function from request to response (a
modelfor an LLM codec). quanto has no SDK or network dependency, and swapping in a stub is just passing a different function. - Every external codec ships a stub next to its fixtures (
src/codecs/<name>/stub.ts): hard-coded and deterministic, typically a lookup table from request to canned response. Fixtures run through a factory that closes over it. Fixture files stay plain JSON and follow the same rules as any other codec. runFixturesandroundTripaccept external codecs and await their parses: the test function they register is async for them. Round-trip goes through the same stub:parse(format(v))must give backv.- Completions are fixtures too:
completefixtures, andcompletionson parse fixtures (see Testing). The stub answerscompleteandresolveas well asparse. - What this covers: the codec's own code (building the request, reading the reply, validation, issue mapping,
context). What it doesn't: how accurate a real model is. That's an eval, run outside CI, and not part of the codec's definition of done. - Not built yet: a first-party LLM codec. It's evolving by nature, so it gets its own package (see Packaging). Its request shape (likely an instruction, the text and a JSON Schema for the value) depends on the deferred JSON Schema exports for value shapes.
- A validation or constraint API (delegated to Standard Schema).
- Object or array schema composition (that's what Zod, Valibot and ArkType are for).
- Date math (possible future separate package, built on Temporal).
- Dimensional algebra.
- Fetching exchange rates or any other external data.
- Configuration knobs for edge cases in first-party codecs (write a custom codec instead).
- IANA time zones in first-party codecs (write a custom codec on Temporal instead).
Decided in principle, not in v1:
- Sub-minor-unit money (
$3.459): an optionalprecisionoption on the money codec. - Grammars for other languages: a separate package, since they're incomplete and will change, starting with the languages dates have names for (
es,fr,de,it,pt,nl). Month and weekday names could later become a grammar part too, replacing thenamesoption. - Ranges over a merge (
5-7 kgagainstmerge([length(), mass()])):range()takes a quantity codec, and a merged codec has no single unit table. Dropped on purpose whenrange()became quantity-only;defineRange(merged, rules)with custom rules covers it if needed. - Gas mark (
gas mark 4): the number follows the unit, and only a few discrete marks exist, so it's a custom codec rather than a function unit. - Agent tooling beyond the basics above: an
explain(codec, text, ctx)trace, a CLI with JSON output, anew-codecscaffold, and JSON Schema exports for the value shapes. - A first-party LLM codec, as an external codec in its own package: see External codecs.
- React Native adapter, including how accessories are split between platforms.
- Completions for sync codecs (
5 kilo→ kilograms or kilometres,next fr→ Friday): known values only, since a sync field can't wait for a pick.Completion<T>already allows it; the sync field's echo and alternatives cover most of the need meanwhile. - Knowing whether the region was given (
ctx.knownRegion): a missing locale defaults toen-US, and a language-only tag infers its region (es→ ES), sophoneNumberreads(415) 555-2671as a US number in an app that never set a locale. A per-call field naming the region only when a passed locale wrote it would let such codecs refuse instead. Replay stays exact if a guessed region only ever makes a codec refuse, never read differently. Deferred: people expect US defaults, and it isn't worth the extra API until someone reports a problem. - Sync address-part codecs (
postalCode,country,subdivision): the normalization@quantojs/libpostaldoes inside its parse, as codecs of their own for fields that ask for one part.
- The input component name (
QuantoInputin@quantojs/reactfor now). - Whether the issue-code union stays closed, growing by additions like
ambiguous, or custom codecs get a way to add codes of their own.