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:
- Intl.DateTimeFormat: Constructor for objects that enable language-sensitive date and time formatting.
- Intl.DurationFormat: Constructor for objects that enable locale-sensitive duration formatting (where supported).
- Intl.ListFormat: Constructor for objects that enable language-sensitive list formatting.
- Intl.NumberFormat: Constructor for objects that enable language-sensitive number formatting.
- Intl.PluralRules: Constructor for objects that enable plural-sensitive formatting and language-specific rules for plurals.
- 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:
- Unexpected value types: Formatter receives
null,undefined,NaN,Infinity, objects, or strings instead of the expected primitive type. - Semantically invalid values: Values are structurally valid but meaningless, such as
Invalid Date, negative durations, or out-of-range timestamps. - Timezone ambiguity: Dates lack explicit timezone context or mix UTC and local assumptions, producing incorrect or misleading output—especially around DST boundaries.
- Locale availability and fallback behavior: Requested locales are unsupported or partially supported, causing silent, environment-dependent fallbacks and inconsistent UI.
- Server-client locale mismatch: SSR renders formatted values in one locale while the client hydrates in another, leading to visual inconsistencies or hydration errors.
- Ambiguous numeric meaning: Numbers lack semantic intent (count vs currency vs percentage), resulting in inconsistent formatting for the same value across the UI.
- Precision and rounding ambiguity: Floating-point precision issues and unclear rounding rules cause the same number to display differently in different UI contexts.
- Relative vs absolute time ambiguity: Dates oscillate between relative and absolute representations without a shared rule, leading to UX inconsistency.
- Inconsistent fallback representations: Missing or invalid data is rendered differently (
—,N/A, empty string), breaking visual and behavioral consistency. - 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.
1/** Stable cache key from an Intl options object. */2function stableKey(opts?: Record<string, unknown>): string {3 if (!opts) return '';4 return Object.keys(opts)5 .sort()6 .map((k) => `${k}:${String(opts[k])}`)7 .join('|');8}9
10/**11 * Creates a cached getter for any Intl formatter.12 * Same locale + options = same instance. First call pays the cost, every call after is a Map lookup.13 */14export function createCachedIntlGetter<TOptions, TFormatter>(15 factory: (locale: string | undefined, opts?: TOptions) => TFormatter,16 maxSize = 200,17): (opts?: TOptions, locale?: string) => TFormatter {18 const cache = new Map<string, TFormatter>();19
20 return (opts?: TOptions, locale?: string) => {21 const key = `${locale ?? 'default'}::${stableKey(opts as Record<string, unknown>)}`;22
23 const cached = cache.get(key);24 if (cached) return cached;25
26 const formatter = factory(locale, opts);27 cache.set(key, formatter);28
29 // FIFO eviction — Map preserves insertion order30 while (cache.size > maxSize) {31 cache.delete(cache.keys().next().value as string);32 }33
34 return formatter;35 };36}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.
1// Date formatters — one cached getter for all DateTimeFormat instances2const getDTF = createCachedIntlGetter<Intl.DateTimeFormatOptions, Intl.DateTimeFormat>(3 (locale, opts) => new Intl.DateTimeFormat(locale, opts)4);5
6// Number formatters — one cached getter for all NumberFormat instances7const getNF = createCachedIntlGetter<Intl.NumberFormatOptions, Intl.NumberFormat>(8 (locale, opts) => new Intl.NumberFormat(locale, opts)9);10
11// Usage — getDTF returns a cached instance for these options12getDTF({ year: 'numeric', month: 'short', day: 'numeric' }).format(new Date());13// => "Feb 9, 2026"14
15getNF({ style: 'currency', currency: 'USD' }).format(1234.5);16// => "$1,234.50"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
View source
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.
1// =============================================================================2// Cache Utilities3// =============================================================================4
5/** Stable key for Intl options objects (sorted keys, stringified values). */6export function stableKeyFromOptions(opts?: Record<string, unknown>): string {7 if (!opts) return "";8 return Object.keys(opts)9 .sort()10 .map((k) => `${k}:${String(opts[k])}`)11 .join("|");12}13
14/** FIFO cap for Map caches (Map preserves insertion order). */15export function capFifoCache<K, V>(cache: Map<K, V>, maxSize: number): void {16 while (cache.size > maxSize) {17 const firstKey = cache.keys().next().value as K | undefined;18 if (firstKey === undefined) break;19 cache.delete(firstKey);20 }21}22
23/**24 * Creates a cached getter for Intl formatter instances.25 * Handles cache key generation and FIFO eviction automatically.26 *27 * @param factory - Function that creates the Intl formatter28 * @param maxSize - Maximum cache size (default: 200)29 * @returns A function that returns cached formatter instances30 */31export function createCachedIntlGetter<TOptions, TFormatter>(32 factory: (locale: string | undefined, opts?: TOptions) => TFormatter,33 maxSize = 20034): (opts?: TOptions, locale?: string) => TFormatter {35 const cache = new Map<string, TFormatter>();36
37 return (opts?: TOptions, locale?: string) => {38 const key = `${locale ?? "default"}::${stableKeyFromOptions(opts as Record<string, unknown>)}`;39 const cached = cache.get(key);40 if (cached) return cached;41
42 const fmt = factory(locale, opts);43 cache.set(key, fmt);44 capFifoCache(cache, maxSize);45 return fmt;46 };47}48
49// =============================================================================50// Date Input Coercion51// =============================================================================52
53/** Date coercion input type - strict, does not accept null/undefined. */54export type DateInput = Date | string | number;55
56/** Date input type that accepts null/undefined for cases where it's explicitly expected. */57export type DateInputNullable = DateInput | null | undefined;58
59const TZ_OFFSET_PATTERN = /[+-]\d{2}(:?\d{2})?$/;60
61/**62 * Ensures a date string is treated as UTC if no timezone indicator exists.63 * This prevents JavaScript from interpreting timezone-less strings as local time.64 * Only handles valid ISO 8601 formats; invalid strings will fail naturally in new Date().65 */66function ensureUTC(dateString: string): string {67 // Check for UTC indicator (Z) or GMT68 if (dateString.endsWith("Z") || dateString.endsWith("GMT")) {69 return dateString;70 }71
72 // Check for ISO 8601 timezone offset pattern: +HH:MM, -HH:MM, +HHMM, or -HHMM73 // Matches valid timezone offsets at the end of the string only74 // Pattern: [+-] followed by 2 digits (hours) optionally followed by : and 2 digits (minutes)75 if (TZ_OFFSET_PATTERN.test(dateString)) {76 return dateString;77 }78
79 // No timezone indicator found, append Z to treat as UTC80 // Invalid date strings will produce Invalid Date when passed to new Date()81 return `${dateString}Z`;82}83
84/**85 * Coerces input to a Date object.86 * String inputs without timezone indicators are treated as UTC.87 */88export function toDate(input: DateInput): Date {89 if (input instanceof Date) return input;90 if (typeof input === "string") {91 const d = new Date(ensureUTC(input));92 if (process.env.NODE_ENV !== "production" && !isValidDate(d)) {93 console.warn(`[formatters] toDate: unparseable string "${input}"`);94 }95 return d;96 }97 if (process.env.NODE_ENV !== "production" && !Number.isFinite(input)) {98 console.warn(`[formatters] toDate: non-finite number ${input}`);99 }100 return new Date(input);101}102
103/**104 * Coerces nullable input to a Date object.105 * Null/undefined inputs return an invalid Date (detected by isValidDate).106 */107export function toDateNullable(input: DateInputNullable): Date {108 if (input === null) return new Date(NaN);109 if (input === undefined) return new Date(NaN);110 return toDate(input);111}112
113/** Checks if a Date is valid. */114export function isValidDate(d: Date): boolean {115 return !Number.isNaN(d.getTime());116}117
118// =============================================================================119// Number Input Coercion120// =============================================================================121
122/** Number coercion input type - strict, does not accept null/undefined. */123export type NumberInput = number | string;124
125/** Number input type that accepts null/undefined for cases where it's explicitly expected. */126export type NumberInputNullable = NumberInput | null | undefined;127
128/** Coerces input to a number. */129export function toNumber(input: NumberInput): number {130 const num = typeof input === "number" ? input : Number(input);131 if (process.env.NODE_ENV !== "production" && !Number.isFinite(num)) {132 console.warn(133 `[formatters] toNumber: non-finite result from input ${String(input)}`134 );135 }136 return num;137}138
139/**140 * Coerces nullable input to a number.141 * Null/undefined inputs return NaN (detected by isValidNumber).142 */143export function toNumberNullable(input: NumberInputNullable): number {144 if (input === null) return NaN;145 if (input === undefined) return NaN;146 return toNumber(input);147}148
149/** Checks if a number is finite (valid for formatting). */150export function isValidNumber(n: number): boolean {151 return Number.isFinite(n);152}153
154// =============================================================================155// String Input Coercion156// =============================================================================157
158/** String coercion input type - strict, does not accept null/undefined. */159export type StringInput = string;160
161/** String input type that accepts null/undefined for cases where it's explicitly expected. */162export type StringInputNullable = StringInput | null | undefined;163
164/** Coerces input to a string, returning null for empty values. */165export function safeString(input: StringInput): string | null {166 if (process.env.NODE_ENV !== "production" && typeof input !== "string") {167 console.warn(168 `[formatters] safeString: expected string, got ${typeof input}`169 );170 }171 if (input === "") return null;172 return input;173}174
175/** Coerces nullable input to a string, returning null for empty/invalid values. */176export function safeStringNullable(input: StringInputNullable): string | null {177 if (input === null) return null;178 if (input === undefined) return null;179 if (input === "") return null;180 return input;181}182
183// =============================================================================184// Constants185// =============================================================================186
187/** Placeholder for invalid/missing values. */188export const INVALID_PLACEHOLDER = "—";189
190/** Single source of truth for the default locale used across all formatters. */191export const DEFAULT_LOCALE = "en-US";192
193/** Whether Intl.DurationFormat is available (Chrome 129+, Safari 17.4+, Node 22+). */194export const supportsDurationFormat =195 typeof Intl !== "undefined" &&196 "DurationFormat" in Intl &&197 typeof (Intl as { DurationFormat?: unknown }).DurationFormat === "function";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
- 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.
- Be minimal: Only expose the formatters that are absolutely necessary. A small, intentional surface area makes consistency easier to enforce.
- 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).
- Be comprehensive: Handle the edge cases that will arise in real data—unexpected types, invalid values, missing context—before they reach the UI.
- 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.
1export { toNumber, toNumberNullable, isValidNumber };2export type { NumberInput, NumberInputNullable };3
4const getNF = createCachedIntlGetter<5 Intl.NumberFormatOptions,6 Intl.NumberFormat7>((locale, opts) => new Intl.NumberFormat(locale, opts));8
9const getOrdinalPluralRules = createCachedIntlGetter<10 Intl.PluralRulesOptions,11 Intl.PluralRules12>(13 (locale, opts) =>14 new Intl.PluralRules(locale ?? DEFAULT_LOCALE, { type: "ordinal", ...opts })15);16
17/** English ordinal suffixes; other locales would need locale-specific mappings. */18const ordinalSuffixesEn: Record<Intl.LDMLPluralRule, string> = {19 zero: "th",20 one: "st",21 two: "nd",22 few: "rd",23 other: "th",24 many: "th",25};26
27/**28 * Number formatting namespace.29 * All methods accept number | string and return a formatted string.30 */31export const number = {32 /** 1234567 → "1,234,567" */33 grouped(n: NumberInput) {34 const num = toNumber(n);35 if (!isValidNumber(num)) return INVALID_PLACEHOLDER;36 return getNF({ useGrouping: true }).format(num);37 },38
39 /** 1500 → "1.5K", 2500000 → "2.5M" */40 compact(n: NumberInput) {41 const num = toNumber(n);42 if (!isValidNumber(num)) return INVALID_PLACEHOLDER;43 return getNF({ notation: "compact", maximumFractionDigits: 1 }).format(num);44 },45
46 /** 0.156 → "16%" */47 percent(n: NumberInput) {48 const num = toNumber(n);49 if (!isValidNumber(num)) return INVALID_PLACEHOLDER;50 return getNF({ style: "percent", maximumFractionDigits: 0 }).format(num);51 },52
53 /** 0.1567 → "15.7%" */54 percentPrecise(n: NumberInput) {55 const num = toNumber(n);56 if (!isValidNumber(num)) return INVALID_PLACEHOLDER;57 return getNF({ style: "percent", maximumFractionDigits: 1 }).format(num);58 },59
60 /** 1234.5 → "$1,234.50" */61 usd(n: NumberInput) {62 const num = toNumber(n);63 if (!isValidNumber(num)) return INVALID_PLACEHOLDER;64 return getNF({ style: "currency", currency: "USD" }).format(num);65 },66
67 /** 1234.5 → "$1234.50" — simple prefix with fixed 2-decimal precision */68 price(n: NumberInput) {69 const num = toNumber(n);70 if (!isValidNumber(num)) return INVALID_PLACEHOLDER;71 return `$${num.toFixed(2)}`;72 },73
74 /** 1234.5678 → "1,234.57" */75 decimal(n: NumberInput) {76 const num = toNumber(n);77 if (!isValidNumber(num)) return INVALID_PLACEHOLDER;78 return getNF({ minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(79 num80 );81 },82
83 /** 5 → "5th", 1 → "1st" — locale-aware PluralRules; suffixes are English for now */84 ordinal(n: NumberInput, locale?: string) {85 const num = toNumber(n);86 if (!isValidNumber(num)) return INVALID_PLACEHOLDER;87 const rounded = Math.round(num);88 const rule = getOrdinalPluralRules({ type: "ordinal" }, locale).select(89 rounded90 );91 return `${rounded}${ordinalSuffixesEn[rule]}`;92 },93
94 /** 1024 → "1 KB", 1048576 → "1 MB" */95 fileSize(bytes: NumberInput) {96 const num = toNumber(bytes);97 if (!isValidNumber(num) || num < 0) return INVALID_PLACEHOLDER;98 if (num === 0) return "0 Bytes";99
100 const k = 1024;101 const units = ["Bytes", "KB", "MB", "GB", "TB", "PB"];102
103 // clamp index so huge numbers don’t overflow units[]104 const i = Math.min(105 units.length - 1,106 Math.floor(Math.log(num) / Math.log(k))107 );108 const value = num / k ** i;109
110 const nf = getNF({111 maximumFractionDigits: i === 0 ? 0 : 2,112 minimumFractionDigits: 0,113 });114 return `${nf.format(value)} ${units[i]}`;115 },116
117 /** 1000 → "1 kB", 1000000 → "1 MB" — metric (1000 base) via Intl.NumberFormat style: 'unit' */118 fileSizeMetric(bytes: NumberInput) {119 const num = toNumber(bytes);120 if (!isValidNumber(num) || num < 0) return INVALID_PLACEHOLDER;121 if (num === 0)122 return getNF({123 style: "unit",124 unit: "byte",125 unitDisplay: "short",126 }).format(0);127
128 const k = 1000;129 const units = [130 "byte",131 "kilobyte",132 "megabyte",133 "gigabyte",134 "terabyte",135 "petabyte",136 ] as const;137 const i = Math.min(138 units.length - 1,139 Math.floor(Math.log(num) / Math.log(k))140 );141 const value = num / k ** i;142
143 return getNF({144 style: "unit",145 unit: units[i],146 unitDisplay: "short",147 maximumFractionDigits: i === 0 ? 0 : 2,148 minimumFractionDigits: 0,149 }).format(value);150 },151
152 /** 5 → "+5", -3 → "-3", 0 → "0" */153 signed(n: NumberInput) {154 const num = toNumber(n);155 if (!isValidNumber(num)) return INVALID_PLACEHOLDER;156 return getNF({ signDisplay: "exceptZero" }).format(num);157 },158
159 /** 15000 → "15K", 2500000 → "2.5M" — for view counts. Uses Intl.NumberFormat compact for locale-aware output. */160 views(n: NumberInput) {161 const num = toNumber(n);162 if (!isValidNumber(num)) return INVALID_PLACEHOLDER;163 return getNF({164 notation: "compact",165 compactDisplay: "short",166 maximumFractionDigits: 1,167 minimumFractionDigits: 0,168 }).format(num);169 },170
171 /**172 * 1234 → "1.234s"173 * 500 → "500ms"174 */175 latency(n: NumberInput) {176 const num = toNumber(n);177 if (!isValidNumber(num)) return INVALID_PLACEHOLDER;178 if (num < 1000) return `${num.toFixed(0)}ms`;179 return `${(num / 1000).toFixed(2)}s`;180 },181} as const;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.
1export { toDate, toDateNullable, isValidDate };2export type { DateInput, DateInputNullable };3
4const getDTF = createCachedIntlGetter<5 Intl.DateTimeFormatOptions,6 Intl.DateTimeFormat7>((locale, opts) => new Intl.DateTimeFormat(locale, opts));8
9const getRTF = createCachedIntlGetter<10 Intl.RelativeTimeFormatOptions,11 Intl.RelativeTimeFormat12>(13 (locale, opts) => new Intl.RelativeTimeFormat(locale ?? DEFAULT_LOCALE, opts)14);15
16type DurationFormatOptions = {17 style?: "long" | "short" | "narrow" | "digital";18};19interface DurationFormatter {20 format(d: { hours?: number; minutes?: number; seconds?: number }): string;21}22/** Uses Intl.DurationFormat when available (Chrome 129+, Safari 17.4+). Not in TS lib yet. */23const getDurationFormat = supportsDurationFormat24 ? createCachedIntlGetter<DurationFormatOptions, DurationFormatter>(25 (locale, opts) =>26 new (27 Intl as unknown as {28 DurationFormat: new (l?: string, o?: object) => DurationFormatter;29 }30 ).DurationFormat(locale, { style: "narrow", ...opts })31 )32 : null;33
34/** Converts total seconds to { hours, minutes, seconds } for DurationFormat. Matches current manual output style. */35function secondsToDuration(seconds: number): {36 hours?: number;37 minutes?: number;38 seconds?: number;39} {40 const h = Math.floor(seconds / 3600);41 const m = Math.floor((seconds % 3600) / 60);42 const s = Math.floor(seconds % 60);43 const out: { hours?: number; minutes?: number; seconds?: number } = {};44 if (h > 0) out.hours = h;45 if (m > 0) out.minutes = m;46 if (s > 0 || (out.hours === undefined && out.minutes === undefined))47 out.seconds = s;48 return out;49}50
51/**52 * Date formatting namespace.53 * All methods accept Date | string | number and return a formatted string.54 * String inputs without timezone indicators are automatically treated as UTC.55 */56export const date = {57 /** "Jan 15, 2024" */58 short(d: DateInput) {59 const dt = toDate(d);60 if (!isValidDate(dt)) return INVALID_PLACEHOLDER;61 return getDTF({ year: "numeric", month: "short", day: "numeric" }).format(62 dt63 );64 },65
66 /** "Jan 15, 2024" — formatted in UTC timezone */67 shortUTC(d: DateInput) {68 const dt = toDate(d);69 if (!isValidDate(dt)) return INVALID_PLACEHOLDER;70 return getDTF({71 year: "numeric",72 month: "short",73 day: "numeric",74 timeZone: "UTC",75 }).format(dt);76 },77
78 /** "January 15, 2024" */79 long(d: DateInput) {80 const dt = toDate(d);81 if (!isValidDate(dt)) return INVALID_PLACEHOLDER;82 return getDTF({ year: "numeric", month: "long", day: "numeric" }).format(83 dt84 );85 },86
87 /** "1/15/24" */88 numeric(d: DateInput) {89 const dt = toDate(d);90 if (!isValidDate(dt)) return INVALID_PLACEHOLDER;91 return getDTF({ year: "2-digit", month: "numeric", day: "numeric" }).format(92 dt93 );94 },95
96 /** "Jan 15" */97 monthDay(d: DateInput) {98 const dt = toDate(d);99 if (!isValidDate(dt)) return INVALID_PLACEHOLDER;100 return getDTF({ month: "short", day: "numeric" }).format(dt);101 },102
103 /** "3:45 PM" */104 time(d: DateInput) {105 const dt = toDate(d);106 if (!isValidDate(dt)) return INVALID_PLACEHOLDER;107 return getDTF({ hour: "numeric", minute: "2-digit", hour12: true }).format(108 dt109 );110 },111
112 /** "15:45" */113 time24(d: DateInput) {114 const dt = toDate(d);115 if (!isValidDate(dt)) return INVALID_PLACEHOLDER;116 return getDTF({ hour: "2-digit", minute: "2-digit", hour12: false }).format(117 dt118 );119 },120
121 /** "Jan 15, 2024 • 3:45 PM" — local timezone */122 dateTime(d: DateInput) {123 const dt = toDate(d);124 if (!isValidDate(dt)) return INVALID_PLACEHOLDER;125 const dateStr = getDTF({126 year: "numeric",127 month: "short",128 day: "numeric",129 }).format(dt);130 const timeStr = getDTF({131 hour: "numeric",132 minute: "2-digit",133 hour12: true,134 }).format(dt);135 return `${dateStr} • ${timeStr}`;136 },137
138 /** "PST" */139 timezone() {140 const parts = getDTF({ timeZoneName: "short" }).formatToParts(new Date());141 return parts.find((p) => p.type === "timeZoneName")?.value ?? "";142 },143
144 /** "Jan 15, 2024 • 3:45 PM" — UTC timezone */145 dateTimeUTC(d: DateInput) {146 const dt = toDate(d);147 if (!isValidDate(dt)) return INVALID_PLACEHOLDER;148 const dateStr = getDTF({149 year: "numeric",150 month: "short",151 day: "numeric",152 timeZone: "UTC",153 }).format(dt);154 const timeStr = getDTF({155 hour: "numeric",156 minute: "2-digit",157 hour12: true,158 timeZone: "UTC",159 }).format(dt);160 return `${dateStr} • ${timeStr}`;161 },162
163 /** "2 days ago", "in 3 hours", "yesterday", "tomorrow" — full relative time */164 relative(d: DateInput) {165 const dt = toDate(d);166 if (!isValidDate(dt)) return INVALID_PLACEHOLDER;167
168 const diffSeconds = Math.floor((Date.now() - dt.getTime()) / 1000);169
170 // Special case: "just now" for very recent past/future (within 1 minute)171 if (Math.abs(diffSeconds) < 60) return "just now";172
173 // Calculate the best unit and value for Intl.RelativeTimeFormat174 const units: [Intl.RelativeTimeFormatUnit, number][] = [175 ["year", 365 * 24 * 60 * 60],176 ["month", 30 * 24 * 60 * 60],177 ["week", 7 * 24 * 60 * 60],178 ["day", 24 * 60 * 60],179 ["hour", 60 * 60],180 ["minute", 60],181 ];182
183 for (const [unit, secondsInUnit] of units) {184 if (Math.abs(diffSeconds) >= secondsInUnit) {185 const value = Math.floor(Math.abs(diffSeconds) / secondsInUnit);186 // Negative value for past dates, positive for future dates187 return getRTF({ style: "long", numeric: "auto" }).format(188 diffSeconds < 0 ? value : -value,189 unit190 );191 }192 }193
194 return "just now";195 },196
197 /** "2d ago", "18h ago", "9m ago" — compact relative time via Intl.RelativeTimeFormat narrow */198 relativeCompact(d: DateInput) {199 const dt = toDate(d);200 if (!isValidDate(dt)) return INVALID_PLACEHOLDER;201
202 const diffSeconds = Math.floor((Date.now() - dt.getTime()) / 1000);203
204 // Special case: "now" for very recent past/future (within 1 minute)205 if (Math.abs(diffSeconds) < 60) return "now";206
207 const units: [Intl.RelativeTimeFormatUnit, number][] = [208 ["year", 365 * 24 * 60 * 60],209 ["month", 30 * 24 * 60 * 60],210 ["week", 7 * 24 * 60 * 60],211 ["day", 24 * 60 * 60],212 ["hour", 60 * 60],213 ["minute", 60],214 ];215
216 for (const [unit, secondsInUnit] of units) {217 if (Math.abs(diffSeconds) >= secondsInUnit) {218 const value = Math.floor(Math.abs(diffSeconds) / secondsInUnit);219 return getRTF({ style: "narrow", numeric: "always" }).format(220 diffSeconds < 0 ? value : -value,221 unit222 );223 }224 }225
226 return "now";227 },228
229 /** ISO string or null */230 toISO(d: DateInput): string {231 const dt = toDate(d);232 if (!isValidDate(dt)) return INVALID_PLACEHOLDER;233 return dt.toISOString();234 },235
236 /** Unix timestamp (seconds) or NaN */237 toUnix(d: DateInput): number {238 const dt = toDate(d);239 if (!isValidDate(dt)) return NaN;240 return Math.floor(dt.getTime() / 1000);241 },242
243 /** "Jan 15, 2024 - Jan 20, 2024" */244 range({ start, end }: { start: DateInput; end: DateInput }) {245 return `${date.short(start)} - ${date.short(end)}`;246 },247
248 /** "Jan 15 - Jan 20" */249 rangeShort({ start, end }: { start: DateInput; end: DateInput }) {250 return `${date.monthDay(start)} - ${date.monthDay(end)}`;251 },252
253 /** "2h 15m 30s" — returns "0s" if end is null (incomplete/ongoing). Uses Intl.DurationFormat when available. */254 duration({ start, end }: { start: DateInput; end: DateInputNullable }) {255 const startDt = toDate(start);256 if (!isValidDate(startDt)) return INVALID_PLACEHOLDER;257
258 if (end == null) return "0s";259
260 const endDt = toDate(end);261 if (!isValidDate(endDt)) return INVALID_PLACEHOLDER;262
263 const totalSeconds = Math.floor(264 (endDt.getTime() - startDt.getTime()) / 1000265 );266 if (totalSeconds < 0) return INVALID_PLACEHOLDER;267
268 if (getDurationFormat) {269 const duration = secondsToDuration(totalSeconds);270 if (Object.keys(duration).length === 0) return "0s";271 return getDurationFormat().format(duration);272 }273
274 const hours = Math.floor(totalSeconds / 3600);275 const remainder = totalSeconds % 3600;276 const minutes = Math.floor(remainder / 60);277 const seconds = remainder % 60;278
279 const parts: string[] = [];280 if (hours) parts.push(`${hours}h`);281 if (minutes) parts.push(`${minutes}m`);282 if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);283 return parts.join(" ");284 },285
286 /** Duration in milliseconds, or null if either date is invalid/missing. */287 durationMs({288 start,289 end,290 }: {291 start: DateInput;292 end: DateInputNullable;293 }): number | null {294 const startDt = toDate(start);295 if (!isValidDate(startDt)) return null;296 if (end == null) return null;297 const endDt = toDate(end);298 if (!isValidDate(endDt)) return null;299 return endDt.getTime() - startDt.getTime();300 },301
302 /** 3665 → "1h 1m 5s". Uses Intl.DurationFormat when available; shows fractional seconds for values < 60s. */303 fromSeconds(seconds: number) {304 if (!Number.isFinite(seconds) || seconds < 0) return INVALID_PLACEHOLDER;305
306 const totalSeconds = Math.floor(seconds);307 const hasFraction = seconds < 60 && seconds !== totalSeconds;308
309 if (getDurationFormat && !hasFraction) {310 const duration = secondsToDuration(seconds);311 if (Object.keys(duration).length === 0) return "0s";312 return getDurationFormat().format(duration);313 }314
315 const h = Math.floor(seconds / 3600);316 const m = Math.floor((seconds % 3600) / 60);317 const s = seconds % 60;318
319 const parts: string[] = [];320 if (h > 0) parts.push(`${h}h`);321 if (m > 0) parts.push(`${m}m`);322 if (s > 0 || parts.length === 0) {323 parts.push(seconds < 60 ? `${s.toFixed(2)}s` : `${Math.floor(s)}s`);324 }325 return parts.join(" ");326 },327
328 /** "Jan 15, 2024 PST" — with timezone abbreviation */329 withTZ({ date: d, timeZone }: { date: DateInput; timeZone: string }) {330 const dt = toDate(d);331 if (!isValidDate(dt)) return INVALID_PLACEHOLDER;332
333 const base = getDTF({334 year: "numeric",335 month: "short",336 day: "numeric",337 timeZone,338 }).format(dt);339 try {340 const parts = getDTF({ timeZone, timeZoneName: "short" }).formatToParts(341 dt342 );343 const tz = parts.find((p) => p.type === "timeZoneName")?.value;344 return tz ? `${base} ${tz}` : base;345 } catch {346 return base;347 }348 },349} as const;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.
1export { safeString, safeStringNullable };2export type { StringInput, StringInputNullable };3
4const getListFormat = createCachedIntlGetter<5 Intl.ListFormatOptions,6 Intl.ListFormat7>((locale, opts) => new Intl.ListFormat(locale ?? DEFAULT_LOCALE, opts));8
9const getPluralRules = createCachedIntlGetter<10 Intl.PluralRulesOptions,11 Intl.PluralRules12>((locale, opts) => new Intl.PluralRules(locale ?? DEFAULT_LOCALE, opts));13
14/**15 * String formatting namespace.16 * All methods accept string | null | undefined and return a formatted string.17 */18export const string = {19 /** "hello_world" → "Hello World" */20 titleCase(s: StringInput) {21 const str = safeString(s);22 /**23 * If it's an invalid string, return an empty string instead of INVALID_PLACEHOLDER, contrary to most other methods.24 */25 if (!str) return "";26 return str27 .replace(/_/g, " ")28 .toLowerCase()29 .replace(/\b\w/g, (c) => c.toUpperCase());30 },31
32 /** "hello_world" → "Hello world" */33 sentenceCase(s: StringInput) {34 const str = safeString(s);35 if (!str) return INVALID_PLACEHOLDER;36 const spaced = str.replace(/_/g, " ").toLowerCase();37 return spaced.charAt(0).toUpperCase() + spaced.slice(1);38 },39
40 /** "hello world" → "Hello world" */41 capitalize(s: StringInput) {42 const str = safeString(s);43 if (!str) return INVALID_PLACEHOLDER;44 return str.charAt(0).toUpperCase() + str.slice(1);45 },46
47 /** "message-circle" → "MessageCircle" (kebab-case to PascalCase) */48 pascalCase(s: StringInput) {49 const str = safeString(s);50 if (!str) return "";51 return str52 .split("-")53 .map((word) => word.charAt(0).toUpperCase() + word.slice(1))54 .join("");55 },56
57 /** ["a", "b", "c"] → "a, b, and c" */58 joinAnd(parts: string[]) {59 const safe = parts.filter((p): p is string => typeof p === "string" && p.length > 0);60 if (safe.length === 0) return INVALID_PLACEHOLDER;61 return getListFormat({ style: "long", type: "conjunction" }).format(safe);62 },63
64 /** ["a", "b", "c"] → "a, b, or c" */65 joinOr(parts: string[]) {66 const safe = parts.filter((p): p is string => typeof p === "string" && p.length > 0);67 if (safe.length === 0) return INVALID_PLACEHOLDER;68 return getListFormat({ style: "long", type: "disjunction" }).format(safe);69 },70
71 /** "apple" → "an", "banana" → "a" */72 article(word: StringInput) {73 const str = safeString(word);74 if (!str) return "a";75 const first = str.trim().toLowerCase().charAt(0);76 return ["a", "e", "i", "o", "u"].includes(first) ? "an" : "a";77 },78
79 /** "apple" → "an apple" */80 withArticle(word: StringInput) {81 const str = safeString(word);82 if (!str) return INVALID_PLACEHOLDER;83 return `${string.article(str)} ${str}`;84 },85
86 /** (2, "item") → "items" */87 pluralize({88 count,89 singular,90 plural,91 }: {92 count: number;93 singular: string;94 plural?: string;95 }) {96 if (!isValidNumber(count)) return singular;97 const rule = getPluralRules().select(count);98 return rule === "one" ? singular : (plural ?? `${singular}s`);99 },100
101 /** (2, "item") → "2 items" */102 countOf({103 count,104 singular,105 plural,106 }: {107 count: number;108 singular: string;109 plural?: string;110 }) {111 if (!isValidNumber(count)) return INVALID_PLACEHOLDER;112 return `${count} ${string.pluralize({ count, singular, plural })}`;113 },114
115 /** "my_server_name" → "My Server Name" */116 toolName(name: StringInput) {117 const str = safeString(name);118 if (!str) return "";119 return str.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());120 },121
122 /** "image/png" → "PNG Image", "application/pdf" → "PDF Document" */123 mimeType(mime: StringInput) {124 const str = safeString(mime);125 if (!str) return "Unknown";126
127 const slashIdx = str.indexOf("/");128 if (slashIdx === -1) return "Unknown";129
130 const type = str.slice(0, slashIdx);131 const subtype = str.slice(slashIdx + 1);132 if (!subtype) return "Unknown";133
134 /** Strip vendor/x- prefixes: "vnd.openxmlformats..." → "openxmlformats...", "x-tar" → "tar" */135 const cleanSubtype = subtype.replace(/^(vnd\.|x-|x\.)/, "");136
137 /** Use the last segment for compound subtypes: "svg+xml" → "SVG" */138 const firstPart = cleanSubtype.split("+")[0] ?? cleanSubtype;139 const dotParts = firstPart.split(".");140 const label = (dotParts.at(-1) ?? subtype).toUpperCase();141
142 const categoryMap: Record<string, string> = {143 image: "Image",144 video: "Video",145 audio: "Audio",146 text: "File",147 font: "Font",148 application: "Document",149 };150 const category = categoryMap[type] ?? "File";151
152 return `${label} ${category}`;153 },154} as const;