Skip to content
JavaScript Basics: Label Language and Region Codes with Intl.DisplayNames

JavaScript Basics: Label Language and Region Codes with Intl.DisplayNames

October 25, 2026

A language selector should store a stable code such as ko while showing a label a reader understands. Intl.DisplayNames converts supported language, region, and other codes into localized names. It avoids maintaining a small translation dictionary in every menu, while keeping the underlying values separate from their presentation.

These three independent examples were executed on Linux with Node.js v24.19.0. Use Node.js 24 for this tutorial; only that installed version was tested. They use built-in features and assertions, with no packages, network requests, or data files. The locale and code type are explicit in every formatter. The output below is the observed output.

Prepare a scratch directory

You need Bash, Node.js on your PATH, a text editor, and the mktemp, rm, and rmdir utilities. Keep this terminal open. These commands stop the shell if setup fails; save no sources until both directory commands succeed. Save each named file as UTF-8 in the new directory. The .mjs extension selects an ES module.

start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

1. Keep menu values stable while translating labels

Save this as label-language-menu.mjs:

import assert from 'node:assert/strict';

const locales = ['en-US', 'ko'];
assert.deepEqual(Intl.DisplayNames.supportedLocalesOf(locales), locales);
const codes = ['en', 'ko', 'ja'];

function languageMenu(displayLocale) {
  const names = new Intl.DisplayNames(displayLocale, {
    type: 'language',
    languageDisplay: 'standard',
    fallback: 'code',
  });
  return codes.map(code => ({ value: code, label: names.of(code) }));
}

const english = languageMenu('en-US');
const korean = languageMenu('ko');
assert.deepEqual(english.map(item => item.value), codes);
assert.deepEqual(korean.map(item => item.value), codes);
assert.deepEqual(english.map(item => item.label), ['English', 'Korean', 'Japanese']);
assert.equal(korean[1].label, '한국어');
console.log(`English UI: ${english.map(item => `${item.value}=${item.label}`).join(' | ')}`);
console.log(`Korean UI: ${korean.map(item => `${item.value}=${item.label}`).join(' | ')}`);
console.log(`stored selection: ${korean[1].value}`);

Run it:

node label-language-menu.mjs

Output:

English UI: en=English | ko=Korean | ja=Japanese
Korean UI: en=영어 | ko=한국어 | ja=일본어
stored selection: ko

The constructor locale chooses the language of the label, not the language being named. The input code to of identifies that language. Changing the UI locale leaves the selected value ko unchanged. Store that value and regenerate the label when the user’s display language changes. The Intl.DisplayNames specification defines this separation between locale, type, and code.

The supportedLocalesOf assertion makes a missing Korean locale visible rather than quietly accepting a fallback on a reduced-data runtime. Reusing one formatter for the whole menu also avoids constructing it separately for every option.

2. Use a separate formatter for each code type

Save this as label-regions-and-currencies.mjs:

import assert from 'node:assert/strict';

const regions = new Intl.DisplayNames('en-US', { type: 'region' });
const currencies = new Intl.DisplayNames('en-US', { type: 'currency' });
const choices = [
  { region: 'US', currency: 'USD' },
  { region: 'KR', currency: 'KRW' },
  { region: 'JP', currency: 'JPY' },
];

const rows = choices.map(choice => ({
  ...choice,
  regionLabel: regions.of(choice.region),
  currencyLabel: currencies.of(choice.currency),
}));
assert.deepEqual(rows.map(row => row.regionLabel), ['United States', 'South Korea', 'Japan']);
assert.equal(currencies.of('usd'), currencies.of('USD'));
assert.throws(() => regions.of('USD'), RangeError);
for (const row of rows) {
  console.log(`${row.region}: ${row.regionLabel}; ${row.currency}: ${row.currencyLabel}`);
}
console.log('lowercase currency code: same label');
console.log('currency code passed as a region: rejected');

Run it:

node label-regions-and-currencies.mjs

Output:

US: United States; USD: US Dollar
KR: South Korea; KRW: South Korean Won
JP: Japan; JPY: Japanese Yen
lowercase currency code: same label
currency code passed as a region: rejected

The choice records provide the region-to-currency relationship; DisplayNames does not discover it. That relationship is deliberately a small application fixture, not a universal rule about which currencies can be used in a region. Each formatter only looks up the corresponding label.

A currency name is also different from a formatted amount. Use Intl.NumberFormat when you need an amount such as a price. The failed region lookup demonstrates why a generic “translate any code” helper needs to know the code’s type first.

3. Distinguish missing names from malformed codes

Save this as handle-missing-display-names.mjs:

import assert from 'node:assert/strict';

const codeFallback = new Intl.DisplayNames('en-US', {
  type: 'currency', fallback: 'code',
});
const noFallback = new Intl.DisplayNames('en-US', {
  type: 'currency', fallback: 'none',
});

assert.equal(codeFallback.of('ZZZ'), 'ZZZ');
assert.equal(noFallback.of('ZZZ'), undefined);
assert.throws(() => noFallback.of('US'), RangeError);

function currencyLabel(code) {
  if (typeof code !== 'string') throw new TypeError('Expected a currency code string');
  try {
    return noFallback.of(code) ?? `Unlisted (${code.toUpperCase()})`;
  } catch (error) {
    if (error instanceof RangeError) return 'Invalid currency code';
    throw error;
  }
}

assert.equal(currencyLabel('zzz'), 'Unlisted (ZZZ)');
assert.equal(currencyLabel('US'), 'Invalid currency code');
assert.throws(() => currencyLabel(123), TypeError);
console.log(`default fallback: ${codeFallback.of('ZZZ')}`);
console.log(`no fallback: ${String(noFallback.of('ZZZ'))}`);
console.log(`application label: ${currencyLabel('zzz')}`);
console.log(`malformed input: ${currencyLabel('US')}`);
console.log('non-string input: rejected');

Run it:

node handle-missing-display-names.mjs

Output:

default fallback: ZZZ
no fallback: undefined
application label: Unlisted (ZZZ)
malformed input: Invalid currency code
non-string input: rejected

In the tested locale data, ZZZ has no currency name. With fallback set to none, that becomes undefined, which the helper replaces with an application label. US is malformed for the currency type, so it throws instead. Fallback controls a missing display name; it does not turn invalid input into valid input. The specification’s of method describes the lookup and fallback behavior.

The wrapper rejects non-strings before the underlying API can coerce them. It catches only RangeError from the lookup and lets unrelated failures propagate. Treat the resulting label as presentation, not as approval to accept a currency in a payment flow.

Practical limits

DisplayNames is not a translation service for arbitrary text and does not enumerate the set of choices your application supports. Keep a separate list of allowed codes. A structurally valid code or a returned label is not evidence that a value is currently appropriate for your product. Locale data can change, so do not use translated names as database keys or cross-version equality contracts. Exact labels depend on the runtime’s ICU data configuration; this tutorial keeps codes stable and treats labels as display text.

Clean up

In the same terminal, remove only the three source files, return to your starting directory, and remove the empty scratch directory:

rm -- label-language-menu.mjs label-regions-and-currencies.mjs handle-missing-display-names.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

Acknowledgements

This post was created with assistance from AI.

Last updated on