Formatters

Reactive wrappers over the Intl.* APIs for dates, numbers, currency, percent, units, lists, relative time, and display names. They read the active locale from a signal, so a price or a date reformats itself when the language changes.

@mmstack/translatenpm

A translated string handles the words; a formatter handles the numbers, dates, and lists between them. These wrap the platform Intl APIs, which means no new formatting engine and no zone dependency, and because they read the locale from injectDynamicLocale(), a value wrapped in a computed recomputes on a locale switch without you wiring anything up. (Inline number and date formatting inside ICU messages is deliberately not the focus; format here and pass the result as a variable to a translation.)

#Recipe: format a price

injectFormatters() returns all of them as one facade, and the injected functions resolve the locale for you. This is the recommended path in components and services. Here a price signal and the active locale both feed one computed: change either and the displayed string updates.

import { computed, signal } from '@angular/core';
import { injectFormatters } from '@mmstack/translate';

export class ProductComponent {
  private readonly fmt = injectFormatters();

  readonly price = signal(1234.56);
  readonly date = new Date();

  // recomputes on price changes AND locale changes, no explicit locale needed
  readonly displayPrice = computed(() => this.fmt.currency(this.price(), 'EUR'));
  readonly displayDate = computed(() => this.fmt.date(this.date));
}

On en-US that reads €1,234.56; switch to de-DE and the same value becomes 1.234,56 €, grouping and symbol placement included, with no extra code. If you only need one formatter, each has a single-purpose companion (injectFormatDate, injectFormatCurrency, and so on) that works the same way.

#Recipe: relative time (3 days ago)

formatRelativeTime wraps Intl.RelativeTimeFormat. Give it a value and a unit and it phrases the difference in the active language. Set numeric: 'auto' and it prefers words like "yesterday" over "1 day ago" where the locale has them.

import { computed, signal } from '@angular/core';
import { injectFormatRelativeTime } from '@mmstack/translate';

export class ActivityComponent {
  private readonly formatRelative = injectFormatRelativeTime();

  readonly daysAgo = signal(-3);

  // en-US: "3 days ago"  ·  de-DE: "vor 3 Tagen"
  readonly when = computed(() =>
    this.formatRelative(this.daysAgo(), 'day', { numeric: 'auto' }),
  );
}

formatRelativeTime formats a value against a unit; it does not compute the difference for you. When you have a timestamp and want the diff computed, reach for its sibling formatRelativeTimeToNow (and injectFormatRelativeTimeToNow). Hand it a date or timestamp and it diffs against now, picks the largest fitting unit, and phrases it: toNow(post.createdAt) reads "3 days ago". Pass numeric: 'auto' for the natural phrasing, or a fixed now instant when you need a stable value in a test.

#Recipe: units and plural categories

formatUnit renders a number with a measurement unit through Intl.NumberFormat's unit style. Give it any ECMA-402 unit or a -per- compound and a display style: formatUnit(16, 'kilometer-per-hour') reads "16 km/h" on en-US, or "16 kilometers per hour" with unitDisplay: 'long'.

selectPluralCategory returns the CLDR plural category (one, few, other, and the rest) for a value via Intl.PluralRules. Reach for it when you want to branch a class or a custom message map on plurality without writing a full ICU plural arm, and it re-resolves on a locale switch like the others. injectSelectPlural is the injected form.

import { computed, signal } from '@angular/core';
import { injectFormatUnit, injectSelectPlural } from '@mmstack/translate';

export class WeatherComponent {
  private readonly formatUnit = injectFormatUnit();
  private readonly plural = injectSelectPlural();

  readonly speed = signal(16);
  readonly count = signal(3);

  // en-US: "16 km/h"
  readonly wind = computed(() =>
    this.formatUnit(this.speed(), 'kilometer-per-hour'),
  );

  // CLDR category for class/branch logic: 'one' | 'few' | 'other' | ...
  readonly badgeClass = computed(() => 'badge--' + this.plural(this.count()));
}

#What ships

Eight formatters, each a thin reactive wrapper over one Intl API. formatRelativeTimeToNow rides on Intl.RelativeTimeFormat too, as the auto-unit sibling of formatRelativeTime, and selectPluralCategory pairs alongside them over Intl.PluralRules.

FormatterWraps
formatDateIntl.DateTimeFormat
formatNumberIntl.NumberFormat
formatCurrencyIntl.NumberFormat (currency style)
formatPercentIntl.NumberFormat (percent style)
formatListIntl.ListFormat
formatUnitIntl.NumberFormat (unit style)
formatRelativeTimeIntl.RelativeTimeFormat
formatDisplayNameIntl.DisplayNames

#App-wide defaults

If you always want dates in a medium format or currency shown as a code, set it once with provideFormatDefaults (or the per-formatter provideFormat*Defaults). The injected formatters merge these with the active locale, so call sites stay short and consistent.

import { provideFormatDefaults } from '@mmstack/translate';

bootstrapApplication(AppComponent, {
  providers: [
    provideFormatDefaults({
      date: { format: 'mediumDate' },
      number: { useGrouping: true, maxFractionDigits: 2 },
      currency: { display: 'code' },
      relativeTime: { numeric: 'auto' },
      list: { type: 'disjunction' },
      percent: { maxFractionDigits: 1 },
      displayName: { style: 'short' },
    }),
  ],
});

#Escape hatch: injectIntl

For the rare case that no wrapper covers, injectIntl() hands you the underlying Signal<IntlShape> from @formatjs/intl that the store keeps in step with the active locale. Read it inside a computed and call intl().formatMessage(...) or any other formatjs API directly. Prefer the named formatters where they fit; reach here only when they do not.