Skip to content
DasPeTe Tutorials

Localization Without a Library

Translate a Vue 3 app with one small composable instead of a library: typed keys, placeholders, plurals through Intl.PluralRules, numbers in the reader’s format, and one language shared by every component.

Quest objectives

  • Keep translations in typed files, so a missing key is a compile error
  • Share the current language between all components
  • Fill in placeholders and format numbers for the language
  • Pick plural forms with Intl.PluralRules
  • Remember the language and start in the browser’s

Libraries like vue-i18n do a lot, and for many apps that is more than they need. A small app with two or three languages gets by with one composable of about a hundred lines, and the browser's Intl API does the hard parts: plural rules and number formats for every language.

The composable built here does what most apps need: typed keys, placeholders, plurals, numbers in the right format, a language that is remembered and the same in every component.

The translations

Every language is a TypeScript file with a nested object. English comes first, and its shape becomes the type that every other language has to match:

src/i18n/en.ts
export const en = {
  app: {
    welcome: 'Welcome, {name}!',
    language: 'Language',
  },
  cart: {
    // A plural: none for 0, the rest as Intl.PluralRules names them
    items: {
      none: 'Your cart is empty',
      one: 'One item in your cart',
      other: '{count} items in your cart',
    },
    add: 'Add an item',
  },
};

// Every language has to have exactly these keys
export type Messages = typeof en;
src/i18n/de.ts
import type { Messages } from './en';

export const de: Messages = {
  app: {
    welcome: 'Willkommen, {name}!',
    language: 'Sprache',
  },
  cart: {
    items: {
      none: 'Dein Warenkorb ist leer',
      one: 'Ein Artikel im Warenkorb',
      other: '{count} Artikel im Warenkorb',
    },
    add: 'Artikel hinzufügen',
  },
};

Because de is declared as Messages, a key that is missing in German, or one too many, is a compile error. A translation can't be forgotten.

  • Placeholders in curly braces, such as {name}, are filled in when the text is used.

  • A plural is an object of forms instead of a string. none is an optional sentence of its own for zero. The other forms are named the way Intl.PluralRules names them: English and German need one and other, Polish also few and many, Arabic all six.

The composable

src/composables/useLocalization.ts
import { readonly, ref, watchEffect } from 'vue';
import { de } from '../i18n/de';
import { en, type Messages } from '../i18n/en';

const messages = { en, de };

export type Locale = keyof typeof messages;

export const locales: Record<Locale, string> = {
  en: 'English',
  de: 'Deutsch',
};

const fallback: Locale = 'en';

// A plural: other is required, none and the forms of Intl.PluralRules are optional
type Plural = { other: string; none?: string } & Partial<Record<Intl.LDMLPluralRule, string>>;

// Every key that leads to a message, in dot notation, such as 'app.welcome'
type MessageKey<T = Messages> = {
  [K in keyof T & string]: T[K] extends string | Plural ? K : `${K}.${MessageKey<T[K]>}`;
}[keyof T & string];

type Params = Record<string, string | number>;

// The current language. Outside the function, so every component shares it.
const locale = ref<Locale>(initialLocale());

// Keeps <html lang> in step, for screen readers and search engines
watchEffect(() => {
  document.documentElement.lang = locale.value;
});

function initialLocale(): Locale {
  const saved = localStorage.getItem('locale');
  if (isLocale(saved)) {
    return saved;
  }

  // The first of the browser's languages that we have
  for (const language of navigator.languages) {
    const code = language.split('-')[0];
    if (isLocale(code)) {
      return code;
    }
  }

  return fallback;
}

function isLocale(value: string | null | undefined): value is Locale {
  return value != null && Object.keys(messages).includes(value);
}

function setLocale(next: Locale) {
  locale.value = next;
  localStorage.setItem('locale', next);
}

function lookup(source: Messages, key: string): string | Plural | undefined {
  let node: unknown = source;

  for (const part of key.split('.')) {
    if (typeof node !== 'object' || node === null) {
      return undefined;
    }
    node = (node as Record<string, unknown>)[part];
  }

  return node as string | Plural | undefined;
}

// One Intl.PluralRules per language, made when it is first needed
const pluralRules = new Map<Locale, Intl.PluralRules>();

function pluralForm(forms: Plural, count: number): string {
  if (count === 0 && forms.none !== undefined) {
    return forms.none;
  }

  let rules = pluralRules.get(locale.value);
  if (!rules) {
    rules = new Intl.PluralRules(locale.value);
    pluralRules.set(locale.value, rules);
  }

  return forms[rules.select(count)] ?? forms.other;
}

function t(key: MessageKey, params: Params = {}): string {
  const message = lookup(messages[locale.value], key) ?? lookup(messages[fallback], key);

  if (message === undefined) {
    console.warn(`Missing translation: ${key}`);
    return key;
  }

  const text = typeof message === 'string' ? message : pluralForm(message, Number(params.count ?? 0));

  // Fills in {name} and the like. Numbers are formatted for the language: 1,000 or 1.000.
  return text.replace(/\{(\w+)\}/g, (placeholder, name: string) => {
    const value = params[name];
    if (value === undefined) {
      return placeholder;
    }
    return typeof value === 'number' ? value.toLocaleString(locale.value) : value;
  });
}

export function useLocalization() {
  return { locale: readonly(locale), locales, setLocale, t };
}

From the top:

  • MessageKey builds every valid key from the type of the English file: 'app.welcome' | 'app.language' | 'cart.items' | 'cart.add'. It walks the object and stops at strings and plurals, so t('cart'), a group rather than a text, is an error too. Your editor completes the keys as you type.

  • locale sits outside of useLocalization. That makes it one value for the whole app: switch the language in one component, and every other one follows. Inside the function, each component would get a language of its own. The composable hands it out as readonly, so it only changes through setLocale, which also remembers it.

  • The first language is the one saved last time, otherwise the first of the browser's preferred languages that the app has, otherwise English. de-AT counts as de.

  • watchEffect keeps the lang attribute of the page in step, which screen readers use to pick a voice and search engines to know the language.

  • t looks the key up in the current language and falls back to English, then to the key itself with a warning, so a gap never shows as an empty space. For a plural, Intl.PluralRules picks the form for count. Numbers in placeholders are formatted for the language: 1,000 in English, 1.000 in German.

Using it

A language switcher. v-model needs a value it can write to, so a writable computed passes changes on to setLocale:

src/components/LanguageSwitcher.vue
<script setup lang="ts">
import { computed } from 'vue';
import { useLocalization } from '../composables/useLocalization';

const { locale, locales, setLocale, t } = useLocalization();

// v-model needs something it can write to
const current = computed({
  get: () => locale.value,
  set: setLocale,
});
</script>

<template>
  <label>
    {{ t('app.language') }}
    <select v-model="current">
      <option v-for="(name, code) in locales" :key="code" :value="code">{{ name }}</option>
    </select>
  </label>
</template>

And a component that uses translations:

src/components/CartSummary.vue
<script setup lang="ts">
import { ref } from 'vue';
import { useLocalization } from '../composables/useLocalization';

const { t } = useLocalization();

const count = ref(0);
</script>

<template>
  <h1>{{ t('app.welcome', { name: 'Ada' }) }}</h1>
  <p>{{ t('cart.items', { count }) }}</p>
  <button @click="count++">{{ t('cart.add') }}</button>
</template>

Click the button a few times: the text goes from "Your cart is empty" through "One item in your cart" to "2 items in your cart". Switch to German in the other component, and this one follows at once, because t reads the shared locale while the template renders. After a reload the page starts in the language you chose.

When a library is the better choice

This composable keeps every language in the bundle and shares its state through the module. That is fine for a few languages in a single-page app. With many languages, load each one with import() when it is chosen. With server-side rendering, the module's state would be shared by all visitors of the server, and localStorage and document don't exist there: that is the point where vue-i18n, or Nuxt's i18n module, earns its size. Dates work like numbers, through Intl.DateTimeFormat.

The files of this tutorial:

text
src/
├── components/
│   ├── CartSummary.vue
│   └── LanguageSwitcher.vue
├── composables/
│   └── useLocalization.ts
└── i18n/
    ├── de.ts
    └── en.ts

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.