ADR-0034 Dates, time zones & money
Company standard for time and money — date-fns 4 with @date-fns/tz for date math, Intl for formatting, timestamptz in Postgres, and money as integer minor units with a currency code.
- Decision
- date-fns 4 + Intl; money in minor unitsBuild the app
- Status
- Accepted
- Date
- Scope
- Company-wide default for all new apps
Connections
Context
Section titled “Context”Dates and money are the values that look like plain numbers and strings and are not.
- The server runs in UTC; users do not. Code that leans on the process’s time zone is right on a laptop in Lisbon and wrong in the container
- We render on the server and hydrate in the browser (ADR-0002): a date formatted in two different zones is two different strings, and hydration fails
Datehas no time zone, and a day is not always 24 hours: “tomorrow at the same time” and “start of the month in Lisbon” need zone-aware math- Postgres has the right types (ADR-0007):
timestamptzfor a moment,datefor a calendar day, integers andnumericfor exact numbers - Floating-point numbers cannot hold 0.1, and an invoice must add up
What is open: the date library, where time zones enter, and how money is stored and computed.
Options considered
Section titled “Options considered”1. Native Date and Intl only
Section titled “1. Native Date and Intl only”Pros: nothing to install; Intl formats in any locale and zone.
Cons: no zone-aware arithmetic — “start of day in Lisbon” or “add a month across a clock change” is hand-written, in every product.
2. date-fns 4 with @date-fns/tz
Section titled “2. date-fns 4 with @date-fns/tz”Pros
- Plain functions over
Date, imported one by one - Since 4.0, first-class time zones: a
TZDatethat works with every function, and anin: tz("Europe/Lisbon")option on the functions a zone can affect - Small: its authors measure
TZDateat about 1 kB, and it uses the runtime’s own zone data - 4.0 shipped with no major breaking changes
Cons
- Still
Dateunderneath, with its zero-based months - With mixed zones the result follows the first argument, so argument order matters
- Two packages to keep in step
3. Temporal
Section titled “3. Temporal”Pros: the language’s own answer — Instant, ZonedDateTime, PlainDate — with no library.
Cons: MDN’s compatibility data lists Node 26, Chrome 144, and Firefox 139, but Safari only from 27.3: we cannot assume it in our users’ browsers yet, and Drizzle and the TanStack libraries speak Date.
4. Luxon or Day.js
Section titled “4. Luxon or Day.js”Pros: zone-aware, with a friendly chained API. Cons: their own date objects crossing every boundary, and nothing date-fns 4 does not cover.
- Floats (
number,double precision) — rounding errors accumulate; rejected numericwith a decimal library — exact, but a second number type in every calculation- Integer minor units — exact with plain integers, the form payment providers use
Decision
Section titled “Decision”date-fns 4 with @date-fns/tz for date math, Intl for everything shown to a person, and money as integer minor units with a currency code.
Concretely:
- Packages:
date-fns4.x (4.4.0 at time of writing) and@date-fns/tz1.x. Notdate-fns-tz, Moment, Day.js, or Luxon - A moment is
timestamptz: Drizzle’stimestamp({ withTimezone: true }). Nevertimestampwithout a zone. A calendar day isdate, kept as aYYYY-MM-DDstring (date({ mode: "string" })) — a birthday or a due date has no time zone to convert - The server and the database run in UTC. A user’s or organization’s time zone is data: an IANA name such as
Europe/Lisbonin a column, never a UTC offset - On the wire dates are ISO 8601 strings, validated with Zod (ADR-0005)
- Math that depends on a zone names it:
TZDate, or{ in: tz(zone) }. Server code never does day or month arithmetic on a bareDate - Formatting for people is
Intl—Intl.DateTimeFormatandIntl.RelativeTimeFormatwith the locale from ADR-0031 and an explicittimeZone, always. The server and the browser then produce the same string. date-fnsformatis for machine formats only (file names, ISO dates) - “Now” is a parameter. Code that needs the current time receives it (ADR-0027), so a test can fix it
- Money is an integer in the currency’s minor unit (cents), in a
bigint({ mode: "number" })column, with an ISO 4217 currency code in the column beside it. Never a float, in Postgres or in JavaScript - Rates, percentages, and decimal quantities are
numeric - Rounding happens once, where a rule says so (tax, a split), and the remainder is assigned on purpose, not lost
- Money is formatted with
Intl.NumberFormat(style: "currency"), from the minor units and the currency code - No money library until a product converts between currencies; that product writes its own ADR
- Temporal: not yet. Revisit, in a new ADR, when every browser we support has it
The deciding factors: zone-aware math in small plain functions, formatting that is identical on the server and in the browser, and money that adds up without a decimal library. We accept Date’s rough edges until Temporal is everywhere.
Consequences
Section titled “Consequences”Easier:
- Time-zone bugs have one cause to look for: a missing zone, never a wrong library
- The same date renders the same string on the server and in the browser
- Totals are integer sums; reports and exports agree to the cent
Harder:
- Every formatted date needs a time zone passed in — there is no safe default
- Developers must know which of the two a field is: a moment or a calendar day
- Minor units differ per currency (the yen has none); the number of decimals comes from
Intl.NumberFormat(…).resolvedOptions(), not from a hard-coded 100
Given up: Temporal’s cleaner types today, and decimal arithmetic for money. Accepted — date-fns 4 and integers work in every runtime we ship to now.

