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.56R$ 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 is number for currency, string for 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.
    Add cpf, 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/cep are the worked examples to mirror.
  • 0001-feature-vertical-slices — forms use react-hook-form + Zod
  • The app’s CLAUDE.md (Currency input rule); in Expenses,
    src/utils/masks/types.ts is the contract