9ebc9c8868
---ci--- project: atelier phase: 6 milestone: v0.3 status: complete requirements: covered: [ATELIER-60..91] partial: [] ---/ci---
6.3 KiB
6.3 KiB
Formatting — Derived Rules
Derives from
domains/i18n/first-principles.md. Covers P2 (Locale Identifiers Standardized), P4 (Plural/Gender Parameterized), and P5 (Formatting is Locale-Aware). Referenced bylocale-resources.md(the formatter resolves the messages) andtesting-i18n.md(the formatted output is what snapshots assert).
Formatting is Locale-Aware (P5 Formatting is Locale-Aware)
- Dates, times, numbers, currencies, units, and relative time are
formatted via ICU / CLDR / the JavaScript
IntlAPI — never hand-rolled. CLDR is the source of truth for locale data;Intlis the runtime that exposes it. - A hand-rolled formatter encodes one locale's conventions and
silently produces wrong output for every other locale. The
canonical failure is date format:
mm/dd/yyyy(US) vsdd/mm/yyyy(most of the world) vsyyyy-mm-dd(ISO, sortable). Picking one and calling it done is a correctness violation in every locale it is wrong for.
BCP 47 Tags Drive Formatting (P2 Locale Identifiers Standardized)
- Every formatter takes a BCP 47 locale tag. The tag is the contract between the resource layer and the formatting layer: the same tag that selects the resource selects the formatter.
- A locale tag that is not BCP 47 cannot be resolved by
Intl, ICU, or CLDR — the formatter returns the runtime default, which is the developer's locale, not the user's. This is why P2 is a prerequisite of P5: you cannot format for a locale you cannot name.
The Intl Surface (ICU/CLDR in the Browser and Node)
| API | Formats | Notes |
|---|---|---|
Intl.DateTimeFormat |
Dates, times, date+time, time zones | Calendar (buddhist, hebrew, islamic), numbering system (arab, hanidec) via locale tag extensions |
Intl.NumberFormat |
Numbers, currencies, units, percent | Notation (compact, scientific), grouping, sign display |
Intl.RelativeTimeFormat |
"3 days ago", "in 2 months" | Locale-specific phrasing; numeric vs auto |
Intl.PluralRules |
Plural category for a count | one, few, many, other, zero, two per CLDR — the engine ICU MessageFormat uses |
Intl.ListFormat |
"a, b, and c" | Conjunction / disjunction / unit lists, locale-specific separators |
Intl.Collator |
Locale-aware string sorting | Strength (base, accent, case); numeric collation |
- All of these are built on ICU/CLDR; they are the runtime baseline.
Use them. A
moment.js-style hand-rolled format string ("MM/DD/YYYY") is a relic of the pre-Intlera and a P5 violation in any locale-aware code path.
Dates and Times
// Correct — Intl, locale-aware
new Intl.DateTimeFormat("ar-EG", {
dateStyle: "full",
timeStyle: "short",
}).format(new Date());
// "الأربعاء، ٧ نوفمبر ٢٠٢٤، ٣:١٥ م"
// Wrong — hand-rolled, source-locale only
const d = new Date();
const s = (d.getMonth() + 1) + "/" + d.getDate() + "/" + d.getFullYear();
// "11/7/2024" — meaningless in most locales
- Time zones are not locales. A locale tells you how to format a
timestamp; a time zone tells you what instant it refers to. Do
not derive one from the other (
ar-EGis not a time zone). Format with the user's locale; render in the user's time zone; store in UTC.
Numbers, Currencies, Units
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" })
.format(1234.56); // "1.234,56 €"
new Intl.NumberFormat("ar-EG", { style: "currency", currency: "EGP" })
.format(1234.56); // "١٬٢٣٤٫٥٦ ج.م."
new Intl.NumberFormat("en-US", { style: "unit", unit: "kilometer-per-hour" })
.format(100); // "100 km/h"
- The currency code (
EUR,EGP,USD) is ISO 4217; the locale determines the symbol, grouping, and placement. A hand-rolled"$" + amountis wrong forde-DE(symbol, grouping, placement all differ).
Plural Rules (P4 Plural/Gender Parameterized)
-
Intl.PluralRulesreturns the CLDR plural category for a count in a given locale. ICU MessageFormat uses this category to select the variant from the resource (locale-resources.md). -
Never branch on the raw count in code. The count goes to the formatter; the formatter consults
PluralRulesfor the locale; the resource carries the variant for that category.// ICU MessageFormat (FormatJS) new Intl.MessageFormat( "{count, plural, one {# item} other {# items}}", "en-US" ).format({ count: 1 }); // "1 item" // ar-EG — six categories; the code is identical, only the // resource differs.
Gender and Select
- ICU MessageFormat also supports
{gender, select, male {...} female {...} other {...}}for gendered agreement and{case, select, ...}for general disjunction. These live in the resource, not in code branches. - A
switch (gender)in code that picks a string is the same violation asif (n == 1): it encodes one locale's grammar in code and breaks for every locale with different agreement rules.
What Violates Formatting Discipline
| Violation | Principle |
|---|---|
getMonth() + 1 + "/" + getDay() hand-rolled date |
P5 Formatting is Locale-Aware |
"$" + amount hand-rolled currency |
P5 Formatting is Locale-Aware |
if (n === 1) "item" else "items" plural branch |
P4 Plural and Gender are Parameterized |
moment("MM/DD/YYYY") format string in locale-aware code |
P5 Formatting is Locale-Aware |
| Deriving time zone from locale tag | P2 Locale Identifiers are Standardized |
A non-BCP-47 tag passed to Intl (silently falls back) |
P2 Locale Identifiers are Standardized |
switch (gender) selecting strings in code |
P4 Plural and Gender are Parameterized |
| Storing timestamps in local time, not UTC | P5 Formatting is Locale-Aware |
Relationship to Other Domains
domains/api/error-responses.md— API error messages are formatted for the requesting locale; the error code is stable, the message is locale-formatted.domains/data/schema-design.md— locale identifiers, currency codes, and time zones are data contracts; treat them as schema (en-US,EUR,UTC), not free text.domains/i18n/locale-resources.md— the resource layer carries the parameterized messages this formatter resolves.domains/testing/fixtures.md— formatted output per locale is the fixture; snapshot tests assert against it.