Formatting Reference

lib/format.ts provides locale-aware formatting utilities for numbers, currency, percentages, pluralization, and bulk-delete messages. Every function accepts a BCP 47 locale string and uses Intl.NumberFormat / Intl.PluralRules internally. Formatter instances are memoized per (locale, options) so rendering hundreds of formatted values on an index page doesn't pay the construction cost per cell.

These are server-side helpers used in generated CRUD routes, list views, and custom templates. They're not exposed as template helpers — use the template-level {= display_currency(value) } / {~ percent(value) } helpers in .ree files instead.

display_currency()

function display_currency(
  val: number,
  locale?: string,
  hide_zero?: boolean,
  symbol?: string,
): string;

Formats a number as EUR currency with locale-aware grouping and the desired symbol.

ParameterTypeDefaultDescription
valnumber(required)The amount to format
localestring"sl-si"BCP 47 locale for number formatting
hide_zerobooleanfalseWhen true, returns "" if the value is 0
symbolstring"€"Currency symbol displayed after the number

Uses Intl.NumberFormat with currencyDisplay: "code" and replaces "EUR" with the custom symbol, so the output always shows regardless of the locale's default currency display:

display_currency(1234.56);                  // → "1.234,56 €"  (sl-si)
display_currency(1234.56, "en-us");         // → "€1,234.56"   (en-us)
display_currency(0, "en-us", true);         // → ""            (hide zero)
display_currency(99.99, "en-us", false, "$"); // → "$99.99"

currency_no_cents()

function currency_no_cents(
  val: number,
  locale?: string,
  hide_zero?: boolean,
): string;

Same as display_currency() but rounds to an integer first — no decimal places. Useful for display-only price summaries where cents are noise:

currency_no_cents(1234.56);              // → "1.235 €"   (sl-si, rounded)
currency_no_cents(1234.56, "en-us");    // → "€1,235"    (en-us, rounded)
currency_no_cents(0, "en-us", true);    // → ""          (hide zero defaults to true)

Note: hide_zero defaults to true for this function (unlike display_currency() where it defaults to false).

decimal()

function decimal(
  val: number,
  locale?: string,
  hide_zero?: boolean,
  fraction_digits?: number,
): string;

Formats a decimal number with locale-aware grouping and configurable fraction digits.

ParameterTypeDefaultDescription
valnumber(required)The number to format
localestring"sl-si"BCP 47 locale
hide_zerobooleanfalseWhen true, returns "" if value is 0
fraction_digitsnumber2Minimum fraction digits to display
decimal(1234.5);                          // → "1.234,50"   (sl-si)
decimal(1234.5, "en-us");                 // → "1,234.50"   (en-us)
decimal(1234.5, "en-us", false, 0);       // → "1,235"      (rounded, no decimals)
decimal(0, "en-us", true);                // → ""           (hide zero)

percent()

function percent(
  val: number,
  locale?: string,
): string;

Formats a percentage value using Intl.NumberFormat with style: "percent". The value is divided by 100 internally per Intl convention — pass 25 to display "25%", not 0.25.

percent(25);                              // → "25%"        (sl-si)
percent(25, "en-us");                     // → "25%"        (en-us)
percent(0);                               // → "0%"
percent(undefined as any);                // → "0%"        (undefined → 0)

display_percent()

function display_percent(
  val: number,
  locale?: string,
): string;

Same as percent() but with configurable fraction digits (0–2). Better for display where exact percentages look cleaner:

display_percent(25);                      // → "25%"        (en-us, no decimals)
display_percent(33.3333);                 // → "33.33%"     (en-us, 2 decimals)
display_percent(0);                       // → "0%"

Uses minimumFractionDigits: 0, maximumFractionDigits: 2 — whole percentages show without decimals, fractional ones show up to 2 places.

plural()

function plural(
  translation_string: string,
  count: number,
  locale?: string,
): string;

Selects the correct plural form from a pipe-separated translation string using Intl.PluralRules. The string must have up to 5 forms separated by |:

"zero|one|two|few|other"
IndexCLDR CategoryExample (English)
0zero"0 records"
1one"1 record"
2two"2 records" (Arabic, Welsh, etc.)
3few"{count} records" (Slavic languages)
4other"{count} records" — the universal fallback

If a form index is missing, it falls back to the last available form. {count} in the selected form is replaced with the locale-formatted number.

// English: "one" for 1, "other" for everything else
plural("0 records|1 record|{count} records|{count} records|{count} records", 1, "en-us");
// → "1 record"

plural("0 records|1 record|{count} records|{count} records|{count} records", 5, "en-us");
// → "5 records"

// Slovenian: "one" for 1, "two" for 2, "few" for 3-4, "other" for 5+
plural("0 zapisov|1 zapis|{count} zapisa|{count} zapisi|{count} zapisov", 2, "sl-si");
// → "2 zapisa"

plural("0 zapisov|1 zapis|{count} zapisa|{count} zapisi|{count} zapisov", 7, "sl-si");
// → "7 zapisov"

format_bulk_delete_message()

function format_bulk_delete_message(
  msg: { bulk_deleted?: string; bulk_errors?: string; },
  deleted_count: number,
  error_count: number,
  label?: string,
  locale?: string,
): string;

Composes a bulk-delete result message from two pluralized translation keys. Used by the generated CRUD bulk-delete handler.

ParameterTypeDefaultDescription
msg{ bulk_deleted?, bulk_errors? }(required)Translation key object from the route's translations
deleted_countnumber(required)Number of successfully deleted records
error_countnumber(required)Number of records that failed to delete
labelstring"record"Fallback noun used when translation keys are missing
localestring"en-us"BCP 47 locale

Each key is a pipe-separated plural string. If a key is missing from msg, a fallback is constructed from the label:

// Successful bulk delete, no errors
format_bulk_delete_message(
  { bulk_deleted: "No records deleted|{count} record deleted|{count} records deleted|{count} records deleted|{count} records deleted" },
  3, 0, "product", "en-us",
);
// → "3 records deleted"

// Partial failure: some records couldn't be deleted
format_bulk_delete_message(
  {
    bulk_deleted: "No records deleted|{count} record deleted|{count} records deleted|{count} records deleted|{count} records deleted",
    bulk_errors: "|{count} record failed|{count} records failed|{count} records failed|{count} records failed",
  },
  5, 2, "product", "en-us",
);
// → "5 records deleted, 2 records failed"

When error_count is 0, the error suffix is omitted entirely — only the deletion message is returned.

Memoization

All formatters use cached Intl.NumberFormat and Intl.PluralRules instances, keyed by (locale, JSON-stringified options). Constructing a new Intl.NumberFormat costs tens of microseconds — negligible for a single call, but rendering 200 rows on an index page would pay that cost 200 times. The cache eliminates the constructor overhead after the first call per (locale, options) pair.

// First call: constructs Intl.NumberFormat("sl-si", { style: "currency", ... })
display_currency(100, "sl-si");

// Subsequent calls: hits the cache, just calls .format()
display_currency(200, "sl-si");
display_currency(300, "sl-si");

The cache is module-scoped and lives for the lifetime of the process — no eviction, no TTL, just a Map that grows with the number of distinct (locale, options) combinations seen.

Template Helpers vs Server Helpers

These functions are server-side — they're used in route handlers (index.ts) and called before the response is rendered. In .ree templates, use the template-level helpers instead:

Server (lib/format.ts)Template (.ree)
display_currency(v, l){~ display_currency(v, l) }
percent(v, l){~ display_percent(v, l) }
plural(s, n, l)Not available — use translation keys with {_ ... }

The template-level display_currency() and display_percent() are defined in lib/template_helpers.ts and available in every .ree file without imports. They delegate to the same Intl APIs but accept props.locale automatically.