JavaScript Basics: Label Language and Region Codes with Intl.DisplayNames
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 11. 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.mjsOutput:
English UI: en=English | ko=Korean | ja=Japanese
Korean UI: en=영어 | ko=한국어 | ja=일본어
stored selection: koThe 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.mjsOutput:
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: rejectedThe 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.mjsOutput:
default fallback: ZZZ
no fallback: undefined
application label: Unlisted (ZZZ)
malformed input: Invalid currency code
non-string input: rejectedIn 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_dirAcknowledgements
This post was created with assistance from AI.