Skip to content

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

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
  • Date has 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): timestamptz for a moment, date for a calendar day, integers and numeric for 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.

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.

Pros

  • Plain functions over Date, imported one by one
  • Since 4.0, first-class time zones: a TZDate that works with every function, and an in: tz("Europe/Lisbon") option on the functions a zone can affect
  • Small: its authors measure TZDate at about 1 kB, and it uses the runtime’s own zone data
  • 4.0 shipped with no major breaking changes

Cons

  • Still Date underneath, with its zero-based months
  • With mixed zones the result follows the first argument, so argument order matters
  • Two packages to keep in step

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.

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
  • numeric with a decimal library — exact, but a second number type in every calculation
  • Integer minor units — exact with plain integers, the form payment providers use

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-fns 4.x (4.4.0 at time of writing) and @date-fns/tz 1.x. Not date-fns-tz, Moment, Day.js, or Luxon
  • A moment is timestamptz: Drizzle’s timestamp({ withTimezone: true }). Never timestamp without a zone. A calendar day is date, kept as a YYYY-MM-DD string (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/Lisbon in 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 bare Date
  • Formatting for people is Intl — Intl.DateTimeFormat and Intl.RelativeTimeFormat with the locale from ADR-0031 and an explicit timeZone, always. The server and the browser then produce the same string. date-fns format is 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.

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.