47.5 Typing Date and Number Formatters
Right, let’s talk about making your dates and numbers look like they belong on this planet. You’ve probably reached for Intl.DateTimeFormat and Intl.NumberFormat and thought, “This is great! I’ll just… uh…” and then your TypeScript compiler started screaming at you. That’s because these APIs are incredibly powerful, but their types are a glorious mess of string literals and overloads. TypeScript knows what’s valid, but it often refuses to hold your hand. It’s our job to be smarter than the average type assertion.
The Core Problem: TypeScript’s Loose Grip
Here’s the thing: the Intl APIs are designed around a universe of possible options. TypeScript’s built-in types for them are intentionally loose, often using string for things like localeMatcher or calendar. This is a pragmatic choice by the TypeScript teamβtrying to type every possible BCP 47 language tag and Unicode extension would be a fool’s errand. But it leaves us, the application developers, in a lurch. We want autocomplete and type safety. We don’t want to ship a typo like 'full-lenght' to our users.
// This is perfectly valid TypeScript, but will throw a runtime error.
const formatter = new Intl.DateTimeFormat('en-US', { weekday: 'fulll' }); // π¬
// The type for 'weekday' is basically `string`, not a union of valid options.
Taming the Beast with Constrained Types
So, we fight back. We don’t accept string. We create our own constrained types, pulling from the very same spec that JavaScript implements. This isn’t over-engineering; it’s basic hygiene.
// Define a type for the common options we actually use
type WeekdayFormat = 'long' | 'short' | 'narrow';
type EraFormat = 'long' | 'short' | 'narrow';
type YearFormat = 'numeric' | '2-digit';
// ... you get the idea
interface SafeDateTimeFormatOptions {
weekday?: WeekdayFormat;
era?: EraFormat;
year?: YearFormat;
month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow';
day?: 'numeric' | '2-digit';
hour?: 'numeric' | '2-digit';
minute?: 'numeric' | '2-digit';
second?: 'numeric' | '2-digit';
timeZoneName?: 'short' | 'long';
// timeZone? is trickier, best left as string for now.
}
// Now use our safer type. Autocomplete works, typos are caught.
const goodFormatter = new Intl.DateTimeFormat('en-US', {
weekday: 'long', // β
Autocomplete blesses you
year: 'numeric',
month: 'short',
// weekday: 'fulll' // β TypeScript immediately complains
});
The Number Formatting Minefield
Number formatting is where the real fun begins. The style option dictates the entire shape of the object you’re allowed to pass. This is a classic case of a discriminated union, but the built-in types don’t model it strictly. Let’s fix that.
// If style is 'currency', you MUST provide a currency.
// If it's 'percent', providing a currency is nonsense.
type NumberFormatOptions =
| {
style?: 'decimal' | 'percent';
currency?: never; // Explicitly disallow
currencyDisplay?: never;
currencySign?: never;
unit?: never;
unitDisplay?: never;
// ... other common options
}
| {
style: 'currency';
currency: string; // Mandatory
currencyDisplay?: 'symbol' | 'code' | 'name';
currencySign?: 'standard' | 'accounting';
unit?: never;
unitDisplay?: never;
}
| {
style: 'unit';
unit: string; // Mandatory
unitDisplay?: 'long' | 'short' | 'narrow';
currency?: never;
currencyDisplay?: never;
currencySign?: never;
};
// A wrapper function for maximum safety
function createNumberFormatter(
locale: string,
options: NumberFormatOptions
): Intl.NumberFormat {
return new Intl.NumberFormat(locale, options);
}
// Usage - the union type works perfectly.
const priceFormatter = createNumberFormatter('de-DE', {
style: 'currency',
currency: 'EUR', // β
Required
currencyDisplay: 'symbol',
});
const lengthFormatter = createNumberFormatter('en-US', {
style: 'unit',
unit: 'inch', // β
Required
});
// const badFormatter = createNumberFormatter('en-US', { style: 'currency' });
// β Property 'currency' is missing. Beautiful.
The Locale is a Land of Mystery
You’ll notice I’ve been hard-coding locales like 'en-US'. That’s fine for a demo, but a real app needs to dynamically use the user’s locale. The type for a locale is string, because, again, the list is effectively infinite. The best practice here is to have a list of your supported locales. You’re not supporting every locale on earth; you’re supporting the five your product manager agreed to.
// Define what you actually support
const SUPPORTED_LOCALES = ['en-US', 'de-DE', 'fr-FR', 'ja-JP', 'es-ES'] as const;
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
// Use this type everywhere you accept a locale
function getUserLocale(preferred: string): SupportedLocale {
// ... some logic to negotiate and fall back
return 'en-US'; // simplified
}
const userLocale = getUserLocale(navigator.language);
const formatter = new Intl.DateTimeFormat(userLocale, { weekday: 'long' });
This approach turns a runtime mystery (is this a valid locale?) into a compile-time certainty. You’ve drawn a box around the chaos, and that’s 90% of the battle. Now go make things that don’t just work, but work correctly for everyone.