ADR-0003: Masked inputs for every formattable value
- Status: Accepted
- Date: 2026-05-31
- Scope: core (any frontend with formatted user input)
Context
Many fields have a canonical raw value but a human-friendly display format:
money (1234.56 → R$ 1.234,56), dates entered as raw strings, Brazilian
documents (CPF 123.456.789-00, CNPJ), postal codes (CEP 01310-100). Free-typing
the raw value is error-prone and ugly, and a <input type="number"> for money
silently bypasses any formatting. These all want the same treatment, so it should
be one consistent mechanism — not a bespoke handler per field.
Decision
Any formattable value is entered through a mask, via the useMaskedInput hook
- a
MaskStrategy(src/utils/masks/). The strategy is the contract
(MaskStrategy<TRaw>):
mask(input) → { display, raw }— runs on every keystroke: it formats
the visible string and asserts/shapes format validity as the user types (e.g.
only digits, grouped to the format), returning the parsed raw value.format(raw) → display— turns a stored raw value back into the display
string (edit/init mode).placeholder.
Rules:
- Form state and the API always hold the RAW value; the input only ever shows
the display string. (TRaw isnumberfor currency,stringfor CPF/CEP/date, …) - The mask asserts format validity as you type; deeper semantic validity
(CPF checksum, a real calendar date, ranges) is the Zod schema’s job — the two
are complementary. - Adding a new masked type = a strategy factory + a FormField that passes it to
useMaskedInput— no new plumbing. Shipped strategies today:currency,cep.
Addcpf,cnpj,date, etc. the same way. - Never
<input type="number">/<TextInput type="number">for a masked value
(money, etc.) — it bypasses the mask and stores an unformatted value.
type="number"is acceptable only for genuine integer fields with no formatting
(payment day, month, year, recurrence count).
Consequences
- Consistent input UX; the API receives clean raw values; new formats are cheap.
- One contract to learn;
currency/cepare the worked examples to mirror.
Related
- 0001-feature-vertical-slices — forms use react-hook-form + Zod
- The app’s
CLAUDE.md(Currency input rule); in Expenses,
src/utils/masks/types.tsis the contract