One Way to Format, Everywhere

Centralized formatters for consistency, with cached Intl objects for performance.

The way you do anything is the way you do everything. At least, that's how it should be... especially when it comes to how dates, numbers, and user-facing strings are formatted across your app. When it comes to design engineering, details are everything. Being consistent with how you format dates, numbers, and strings in your application matters. If you solve the problem every time you encounter it, you're left riddled with unhandled edge cases, client-side crashes from unexpected data, or worst of all... multiple ways to show the exact same thing. However, many teams don't establish a clear pattern for formatting data, and even if they do, developers rarely have an easy way to stay consistent.

Javascript exposes a beautiful API we know as Intl (MDN).

The Intl namespace object contains several constructors as well as functionality common to the internationalization constructors and other language sensitive functions. Collectively, they comprise the ECMAScript Internationalization API, which provides language sensitive string comparison, number formatting, date and time formatting, and more.

Intl solves most of the hard cases for us, defining a standard way to format different data types, in different cases. It gives us the primitives for correctness and localization—but it intentionally leaves validation, fallbacks, and product-level decisions up to us. Its namespace is extensive, including:

  1. Intl.DateTimeFormat: Constructor for objects that enable language-sensitive date and time formatting.
  2. Intl.DurationFormat: Constructor for objects that enable locale-sensitive duration formatting (where supported).
  3. Intl.ListFormat: Constructor for objects that enable language-sensitive list formatting.
  4. Intl.NumberFormat: Constructor for objects that enable language-sensitive number formatting.
  5. Intl.PluralRules: Constructor for objects that enable plural-sensitive formatting and language-specific rules for plurals.
  6. Intl.RelativeTimeFormat: Constructor for objects that enable language-sensitive relative time formatting.

Using Intl intelligently gets you 50% of the way there. There's still so many possible failure cases that simply using this namespace won't solve:

  1. Unexpected value types: Formatter receives null, undefined, NaN, Infinity, objects, or strings instead of the expected primitive type.
  2. Semantically invalid values: Values are structurally valid but meaningless, such as Invalid Date, negative durations, or out-of-range timestamps.
  3. Timezone ambiguity: Dates lack explicit timezone context or mix UTC and local assumptions, producing incorrect or misleading output—especially around DST boundaries.
  4. Locale availability and fallback behavior: Requested locales are unsupported or partially supported, causing silent, environment-dependent fallbacks and inconsistent UI.
  5. Server-client locale mismatch: SSR renders formatted values in one locale while the client hydrates in another, leading to visual inconsistencies or hydration errors.
  6. Ambiguous numeric meaning: Numbers lack semantic intent (count vs currency vs percentage), resulting in inconsistent formatting for the same value across the UI.
  7. Precision and rounding ambiguity: Floating-point precision issues and unclear rounding rules cause the same number to display differently in different UI contexts.
  8. Relative vs absolute time ambiguity: Dates oscillate between relative and absolute representations without a shared rule, leading to UX inconsistency.
  9. Inconsistent fallback representations: Missing or invalid data is rendered differently (, N/A, empty string), breaking visual and behavioral consistency.
  10. No pre-runtime validation signal: Developers have no way to know at build time or during development whether a formatting case is properly handled, allowing runtime-only failures instead of typed, asserted, or logged feedback.

This isn't an exhaustive list—these are the failure modes that most directly impact UI correctness, consistency, and user trust in real-world applications. Not only are all those questions left unanswered, but repeatedly constructing Intl formatters also comes at a real cost—both in runtime performance and in long-term developer experience.

Given this, there are actionable ways to leverage the Intl namespace, maximize rendering speed while minimizing compute needs, optimize developer experience, and optimize user experience.

Cached Intl objects

Every time you call new Intl.DateTimeFormat(...) or new Intl.NumberFormat(...), the engine has to resolve the locale, parse your options, and construct an internal formatter. That work isn't free. It's negligible when you're formatting a single value, but the moment you're rendering tables, charts, or dense data views, those costs add up quickly.

The fix is simple: cache the formatter instance, and reuse it. Formatter construction should be a one-time cost per configuration, not something paid on every render. A Map keyed on a stable serialization of the options is all you need.

The idea: generate a stable string key from the locale + options, check the cache, and return the existing instance if it's there. If not, create it, store it, and evict the oldest entry if the cache exceeds its cap. FIFO, nothing fancy.

That's it. Every formatter method now calls getDTF(...) or getNF(...) instead of new Intl.DateTimeFormat(...) directly. The first call for a given option set pays the construction cost, and every subsequent call is a Map lookup.

Uncached

0

0ms

Cached

0

0ms

Opinionated, safe, formatters

At scale, formatting stops being a utility concern and becomes a product concern. These formatters aren’t meant to be flexible—they’re meant to be correct, predictable, and safe by default across an entire monorepo. Here, “safe” means a formatter never throws at runtime, preserves type safety, and provides clear development-time signals when something goes wrong—without breaking the UI.

Everything starts with safe coercion. Before any formatter touches Intl, the input is parsed and validated through a thin layer that handles the messy reality of real-world data — null, undefined, NaN, strings that look like numbers, timestamps that might be ISO strings or Unix epochs. Each data type gets its own coercion function and input type, so the formatter always receives what it expects. In development, invalid inputs produce console warnings instead of silent failures or runtime exceptions.

Every coercion function follows the same pattern: accept the widest reasonable input, normalize it, validate it, and warn in development if something is off. The nullable variants (toDateNullable, toNumberNullable, safeStringNullable) return sentinel values (NaN, null) that downstream validators catch, ensuring the formatter falls through to a placeholder like “—” instead of rendering garbage.

Core Principles

  1. Be opinionated: Do not allow case-by-case configuration. If you find yourself needing to pass options, it’s a signal that the formatter’s responsibility is unclear or that a new formatter should exist.
  2. Be minimal: Only expose the formatters that are absolutely necessary. A small, intentional surface area makes consistency easier to enforce.
  3. Be context-aware: Fallbacks don’t need to be globally consistent—they need to be correct for the environment they’re used in (tables, forms, analytics, dashboards).
  4. Be comprehensive: Handle the edge cases that will arise in real data—unexpected types, invalid values, missing context—before they reach the UI.
  5. Be helpful: Make the correct usage obvious, the incorrect usage hard, and failures visible during development—not in production.

A formatter’s contract is simple: given any input, it returns a predictable, user-safe output—everywhere it’s used.

Number formatters

Numbers are the most common formatting target and the most prone to inconsistency. number.grouped vs number.compact vs number.usd — each method encodes a specific product decision about how a number should appear in context. The input type is always number | string, coerced through toNumber, and every method gates on isValidNumber before touching Intl.

Date formatters

Dates carry more implicit complexity than numbers — timezone ambiguity, relative vs absolute display, duration calculation, and the ever-present Invalid Date. The date namespace covers the full spectrum: absolute formats (short, long, dateTime), relative formats (relative, relativeCompact), durations, and serialization helpers. String inputs without timezone indicators are automatically treated as UTC to prevent the most common class of date bugs.

String formatters

String formatting is less about Intl and more about consistent text transformation across the UI. Case conversion, pluralization via Intl.PluralRules, list joining via Intl.ListFormat, and MIME type display all live here. The safeString coercion converts empty strings to null, so formatters can cleanly fall through to a placeholder or empty state.