typesafe-i18n

Last updated: July 31, 2025Author: Jakub Pomykała

Installation

Install typesafe-i18n for Vue.js:

npm install typesafe-i18n
npx typesafe-i18n --setup-auto --adapter vue

This will create the necessary folder structure and configuration files.

Project Structure

After setup, you'll have the following structure:

src/
  i18n/
    en/
      index.ts
    pl/
      index.ts
    es/
      index.ts
    fr/
      index.ts
    i18n-types.ts
    i18n-util.ts
    i18n-vue.ts

Translation File Structure

Define your translations with full TypeScript support:

// src/i18n/en/index.ts
import type { BaseTranslation } from '../i18n-types'

const en = {
  homepage: {
    title: 'Welcome to our website',
    description: 'This is a multilingual Vue.js application',
    subtitle: 'Built with typesafe-i18n'
  },
  navigation: {
    home: 'Home',
    about: 'About',
    contact: 'Contact',
    products: 'Products'
  },
  product: {
    name: 'Product Name',
    price: 'Price: {price:string}',
    available: 'Available since: {date:string}',
    addToCart: 'Add to Cart',
    inStock: 'In Stock',
    outOfStock: 'Out of Stock',
    count: 'You have {count:number} item{count|s} in your cart'
  },
  cart: {
    title: 'Shopping Cart',
    empty: 'Your cart is empty',
    total: 'Total: {amount:string}',
    checkout: 'Checkout'
  },
  user: {
    welcome: 'Welcome, {name:string}!',
    profile: 'Profile',
    logout: 'Logout',
    login: 'Login'
  },
  forms: {
    email: 'Email',
    password: 'Password',
    submit: 'Submit',
    cancel: 'Cancel',
    save: 'Save',
    required: 'This field is required'
  },
  messages: {
    loading: 'Loading...',
    error: 'Something went wrong',
    success: 'Operation completed successfully',
    confirm: 'Are you sure?'
  }
} satisfies BaseTranslation

export default en
// src/i18n/pl/index.ts
import type { Translation } from '../i18n-types'

const pl = {
  homepage: {
    title: 'Witamy na naszej stronie',
    description: 'To jest wielojęzyczna aplikacja Vue.js',
    subtitle: 'Zbudowana z typesafe-i18n'
  },
  navigation: {
    home: 'Strona główna',
    about: 'O nas',
    contact: 'Kontakt',
    products: 'Produkty'
  },
  product: {
    name: 'Nazwa produktu',
    price: 'Cena: {price}',
    available: 'Dostępne od: {date}',
    addToCart: 'Dodaj do koszyka',
    inStock: 'Na stanie',
    outOfStock: 'Brak na stanie',
    count: 'Masz {count} przedmiot{count|ów} w koszyku'
  },
  cart: {
    title: 'Koszyk',
    empty: 'Twój koszyk jest pusty',
    total: 'Razem: {amount}',
    checkout: 'Złóż zamówienie'
  },
  user: {
    welcome: 'Witaj, {name}!',
    profile: 'Profil',
    logout: 'Wyloguj',
    login: 'Zaloguj'
  },
  forms: {
    email: 'Email',
    password: 'Hasło',
    submit: 'Wyślij',
    cancel: 'Anuluj',
    save: 'Zapisz',
    required: 'To pole jest wymagane'
  },
  messages: {
    loading: 'Ładowanie...',
    error: 'Coś poszło nie tak',
    success: 'Operacja zakończona pomyślnie',
    confirm: 'Czy jesteś pewny?'
  }
} satisfies Translation

export default pl
// src/i18n/es/index.ts
import type { Translation } from '../i18n-types'

const es = {
  homepage: {
    title: 'Bienvenido a nuestro sitio web',
    description: 'Esta es una aplicación Vue.js multiidioma',
    subtitle: 'Construida con typesafe-i18n'
  },
  navigation: {
    home: 'Inicio',
    about: 'Acerca de',
    contact: 'Contacto',
    products: 'Productos'
  },
  product: {
    name: 'Nombre del producto',
    price: 'Precio: {price}',
    available: 'Disponible desde: {date}',
    addToCart: 'Añadir al carrito',
    inStock: 'En stock',
    outOfStock: 'Agotado',
    count: 'Tienes {count} artículo{count|s} en tu carrito'
  },
  cart: {
    title: 'Carrito de compras',
    empty: 'Tu carrito está vacío',
    total: 'Total: {amount}',
    checkout: 'Finalizar compra'
  },
  user: {
    welcome: '¡Bienvenido, {name}!',
    profile: 'Perfil',
    logout: 'Cerrar sesión',
    login: 'Iniciar sesión'
  },
  forms: {
    email: 'Correo electrónico',
    password: 'Contraseña',
    submit: 'Enviar',
    cancel: 'Cancelar',
    save: 'Guardar',
    required: 'Este campo es obligatorio'
  },
  messages: {
    loading: 'Cargando...',
    error: 'Algo salió mal',
    success: 'Operación completada exitosamente',
    confirm: '¿Estás seguro?'
  }
} satisfies Translation

export default es
// src/i18n/fr/index.ts
import type { Translation } from '../i18n-types'

const fr = {
  homepage: {
    title: 'Bienvenue sur notre site web',
    description: 'Ceci est une application Vue.js multilingue',
    subtitle: 'Construite avec typesafe-i18n'
  },
  navigation: {
    home: 'Accueil',
    about: 'À propos',
    contact: 'Contact',
    products: 'Produits'
  },
  product: {
    name: 'Nom du produit',
    price: 'Prix: {price}',
    available: 'Disponible depuis: {date}',
    addToCart: 'Ajouter au panier',
    inStock: 'En stock',
    outOfStock: 'Rupture de stock',
    count: 'Vous avez {count} article{count|s} dans votre panier'
  },
  cart: {
    title: 'Panier',
    empty: 'Votre panier est vide',
    total: 'Total: {amount}',
    checkout: 'Commander'
  },
  user: {
    welcome: 'Bienvenue, {name} !',
    profile: 'Profil',
    logout: 'Déconnexion',
    login: 'Connexion'
  },
  forms: {
    email: 'Email',
    password: 'Mot de passe',
    submit: 'Soumettre',
    cancel: 'Annuler',
    save: 'Sauvegarder',
    required: 'Ce champ est requis'
  },
  messages: {
    loading: 'Chargement...',
    error: 'Quelque chose s\'est mal passé',
    success: 'Opération terminée avec succès',
    confirm: 'Êtes-vous sûr ?'
  }
} satisfies Translation

export default fr

Vue.js Configuration

Configure typesafe-i18n for Vue.js:

// src/i18n/i18n-vue.ts
import type { Locales, TranslationFunctions } from './i18n-types'
import { loadLocaleAsync } from './i18n-util.async'
import { i18nObject } from './i18n-util'
import { ref, computed, App } from 'vue'

const locale = ref<Locales>('en')
const LL = ref<TranslationFunctions>()

export const typesafeI18n = {
  locale: computed(() => locale.value),
  LL: computed(() => LL.value),

  setLocale: async (newLocale: Locales) => {
    await loadLocaleAsync(newLocale)
    locale.value = newLocale
    LL.value = i18nObject(newLocale)
  },

  init: async (initialLocale: Locales = 'en') => {
    await loadLocaleAsync(initialLocale)
    locale.value = initialLocale
    LL.value = i18nObject(initialLocale)
  }
}

export const install = (app: App) => {
  app.config.globalProperties.$typesafeI18n = typesafeI18n
  app.provide('typesafeI18n', typesafeI18n)
}

export const useTypesafeI18n = () => typesafeI18n

Main Application Setup

// src/main.ts
import { createApp } from 'vue'
import { install as i18nInstall, typesafeI18n } from './i18n/i18n-vue'
import App from './App.vue'

async function initApp() {
  const app = createApp(App)

  // Install typesafe-i18n
  app.use(i18nInstall)

  // Initialize with saved or detected locale
  const savedLocale = localStorage.getItem('locale') as any
  const browserLocale = navigator.language.split('-')[0] as any
  const initialLocale = savedLocale || browserLocale || 'en'

  await typesafeI18n.init(initialLocale)

  app.mount('#app')
}

initApp()

Usage Examples

Basic Component Usage (Composition API)

<!-- HomePage.vue -->
<template>
  <div class="homepage">
    <h1>{{ LL.homepage.title() }}</h1>
    <p>{{ LL.homepage.description() }}</p>
    <small>{{ LL.homepage.subtitle() }}</small>

    <LanguageSwitcher />
    <ProductShowcase :products="products" />
    <UserProfile :user="currentUser" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useTypesafeI18n } from '../i18n/i18n-vue'
import LanguageSwitcher from './LanguageSwitcher.vue'
import ProductShowcase from './ProductShowcase.vue'
import UserProfile from './UserProfile.vue'

const { LL } = useTypesafeI18n()

const products = ref([
  {
    id: 1,
    name: 'Sample Product',
    price: '$29.99',
    availableDate: '2023-01-15',
    inStock: true,
    count: 3
  }
])

const currentUser = ref({
  name: 'John Doe',
  messageCount: 5
})
</script>

Language Switcher Component

<!-- LanguageSwitcher.vue -->
<template>
  <div class="language-switcher">
    <label for="language-select">{{ LL.ui.selectLanguage() }}:</label>
    <select
      id="language-select"
      :value="locale"
      @change="changeLanguage">
      <option
        v-for="lang in availableLanguages"
        :key="lang.code"
        :value="lang.code">
        {{ lang.name }}
      </option>
    </select>
  </div>
</template>

<script setup lang="ts">
import { useTypesafeI18n } from '../i18n/i18n-vue'
import type { Locales } from '../i18n/i18n-types'

const { locale, setLocale, LL } = useTypesafeI18n()

const availableLanguages = [
  { code: 'en' as Locales, name: 'English' },
  { code: 'pl' as Locales, name: 'Polski' },
  { code: 'es' as Locales, name: 'Español' },
  { code: 'fr' as Locales, name: 'Français' }
]

const changeLanguage = async (event: Event) => {
  const target = event.target as HTMLSelectElement
  const newLocale = target.value as Locales

  await setLocale(newLocale)
  localStorage.setItem('locale', newLocale)
}
</script>

Product Component with Type-Safe Translations

<!-- ProductCard.vue -->
<template>
  <div class="product-card">
    <h3>{{ product.name }}</h3>

    <!-- Type-safe parameter passing -->
    <p>{{ LL.product.price({ price: product.price }) }}</p>
    <p>{{ LL.product.available({ date: product.availableDate }) }}</p>

    <!-- Type-safe pluralization -->
    <p>{{ LL.product.count({ count: product.count }) }}</p>

    <div class="product-status">
      <span :class="stockClass">
        {{ product.inStock ? LL.product.inStock() : LL.product.outOfStock() }}
      </span>
    </div>

    <button
      @click="addToCart"
      :disabled="!product.inStock"
      class="btn btn-primary">
      {{ LL.product.addToCart() }}
    </button>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useTypesafeI18n } from '../i18n/i18n-vue'

interface Product {
  id: number
  name: string
  price: string
  availableDate: string
  inStock: boolean
  count: number
}

const props = defineProps<{
  product: Product
}>()

const { LL } = useTypesafeI18n()

const stockClass = computed(() => ({
  'in-stock': props.product.inStock,
  'out-of-stock': !props.product.inStock
}))

const addToCart = () => {
  console.log('Added to cart:', props.product.name)
}
</script>

Shopping Cart Component

<!-- ShoppingCart.vue -->
<template>
  <div class="shopping-cart">
    <h2>{{ LL.cart.title() }}</h2>

    <div v-if="items.length === 0" class="empty-cart">
      {{ LL.cart.empty() }}
    </div>

    <div v-else class="cart-content">
      <div class="cart-items">
        <div v-for="item in items" :key="item.id" class="cart-item">
          <span>{{ item.name }}</span>
          <span>{{ item.price }}</span>
        </div>
      </div>

      <!-- Type-safe parameter with formatting -->
      <div class="cart-total">
        {{ LL.cart.total({ amount: formatTotal() }) }}
      </div>

      <button @click="checkout" class="btn btn-primary">
        {{ LL.cart.checkout() }}
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useTypesafeI18n } from '../i18n/i18n-vue'

interface CartItem {
  id: number
  name: string
  price: string
  amount: number
}

const props = defineProps<{
  items: CartItem[]
}>()

const { LL } = useTypesafeI18n()

const formatTotal = () => {
  const total = props.items.reduce((sum, item) => sum + item.amount, 0)
  return `$${total.toFixed(2)}`
}

const checkout = () => {
  console.log('Proceeding to checkout')
}
</script>

User Profile Component

<!-- UserProfile.vue -->
<template>
  <div class="user-profile">
    <!-- Type-safe string interpolation -->
    <h2>{{ LL.user.welcome({ name: user.name }) }}</h2>

    <div class="user-actions">
      <button @click="viewProfile" class="btn btn-secondary">
        {{ LL.user.profile() }}
      </button>
      <button @click="logout" class="btn btn-outline">
        {{ LL.user.logout() }}
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useTypesafeI18n } from '../i18n/i18n-vue'

interface User {
  name: string
  messageCount: number
}

const props = defineProps<{
  user: User
}>()

const { LL } = useTypesafeI18n()

const viewProfile = () => {
  console.log('Viewing profile for:', props.user.name)
}

const logout = () => {
  console.log('Logging out:', props.user.name)
}
</script>

Translation Composable

// src/composables/useTranslation.ts
import { computed } from 'vue'
import { useTypesafeI18n } from '../i18n/i18n-vue'
import type { Locales } from '../i18n/i18n-types'

export function useTranslation() {
  const { locale, LL, setLocale } = useTypesafeI18n()

  const currentLocale = computed(() => locale.value)

  const changeLocale = async (newLocale: Locales) => {
    await setLocale(newLocale)
    localStorage.setItem('locale', newLocale)
  }

  // Type-safe translation helpers
  const translateMessage = (key: 'loading' | 'error' | 'success' | 'confirm') => {
    return LL.value.messages[key]()
  }

  const translateNavigation = (key: 'home' | 'about' | 'contact' | 'products') => {
    return LL.value.navigation[key]()
  }

  const translateForm = (key: 'email' | 'password' | 'submit' | 'cancel' | 'save' | 'required') => {
    return LL.value.forms[key]()
  }

  return {
    currentLocale,
    LL: computed(() => LL.value),
    changeLocale,
    translateMessage,
    translateNavigation,
    translateForm
  }
}

Type-safe Form Component

<!-- ContactForm.vue -->
<template>
  <form @submit.prevent="submitForm" class="contact-form">
    <h2>{{ LL.forms.title() }}</h2>

    <div class="form-group">
      <label for="email">{{ LL.forms.email() }}</label>
      <input
        id="email"
        v-model="formData.email"
        type="email"
        :class="{ 'error': errors.email }"
        required>
      <span v-if="errors.email" class="error-message">
        {{ LL.forms.required() }}
      </span>
    </div>

    <div class="form-group">
      <label for="password">{{ LL.forms.password() }}</label>
      <input
        id="password"
        v-model="formData.password"
        type="password"
        :class="{ 'error': errors.password }"
        required>
      <span v-if="errors.password" class="error-message">
        {{ LL.forms.required() }}
      </span>
    </div>

    <div class="form-actions">
      <button type="button" @click="cancel" class="btn btn-secondary">
        {{ LL.forms.cancel() }}
      </button>
      <button type="submit" :disabled="isSubmitting" class="btn btn-primary">
        {{ isSubmitting ? LL.messages.loading() : LL.forms.submit() }}
      </button>
    </div>
  </form>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useTypesafeI18n } from '../i18n/i18n-vue'

const { LL } = useTypesafeI18n()

const formData = reactive({
  email: '',
  password: ''
})

const errors = reactive({
  email: false,
  password: false
})

const isSubmitting = ref(false)

const validateForm = () => {
  errors.email = !formData.email
  errors.password = !formData.password

  return !errors.email && !errors.password
}

const submitForm = async () => {
  if (!validateForm()) return

  isSubmitting.value = true

  try {
    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 1000))
    console.log(LL.value.messages.success())
  } catch (error) {
    console.error(LL.value.messages.error())
  } finally {
    isSubmitting.value = false
  }
}

const cancel = () => {
  formData.email = ''
  formData.password = ''
  errors.email = false
  errors.password = false
}
</script>

SimpleLocalize Configuration

To manage translations with SimpleLocalize, you need to set up the CLI tool.

# macOS / Linux / Windows (WSL) curl -s https://get.simplelocalize.io/2.9/install | bash # Windows (PowerShell) . { iwr -useb https://get.simplelocalize.io/2.9/install-windows } | iex; # npm npm install @simplelocalize/cli
# macOS / Linux / Windows (WSL)
curl -s https://get.simplelocalize.io/2.9/install | bash

# Windows (PowerShell)
. { iwr -useb https://get.simplelocalize.io/2.9/install-windows } | iex;

# npm
npm install @simplelocalize/cli

Create simplelocalize.yml in your project root:

apiKey: YOUR_PROJECT_API_KEY

uploadFormat: single-language-json
uploadPath: ./src/i18n/{lang}/index.ts
uploadLanguageKey: en
uploadOptions:
  - REPLACE_TRANSLATION_IF_FOUND

downloadPath: ./src/i18n/{lang}/
downloadLanguageKeys: ['en', 'pl', 'es', 'fr']
downloadFormat: typesafe-i18n

Work with the CLI to manage your translations:

# Upload translations to SimpleLocalize
simplelocalize upload

# Download translations for all languages
simplelocalize download

# Generate TypeScript types
npx typesafe-i18n

# Build your Vue.js application
npm run build

Key Features

  • Full Type Safety: Complete TypeScript integration with autocomplete and error checking
  • Vue 3 Composition API: Native support for Vue 3's Composition API
  • Pluralization: Built-in pluralization with type-safe parameters
  • Interpolation: Type-safe variable interpolation
  • Tree Shaking: Only include translations you actually use
  • Small Bundle Size: Minimal runtime overhead
  • IDE Support: Full IntelliSense support in VS Code

Type Safety Benefits

// ✅ This will work - proper parameter types
LL.product.price({ price: '$29.99' })

// ❌ TypeScript error - missing required parameter
LL.product.price()

// ❌ TypeScript error - wrong parameter type
LL.product.count({ count: 'invalid' })

// ✅ Autocomplete suggests available translation keys
LL.navigation. // Will show: home, about, contact, products

Best Practices

1. Organize Translations by Feature

const en = {
  pages: {
    home: { title: 'Home Page' },
    about: { title: 'About Us' }
  },
  components: {
    header: { logo: 'Company Logo' },
    footer: { copyright: '© 2025' }
  }
} satisfies BaseTranslation

2. Use Type-safe Parameters

// Define with proper types
const en = {
  welcome: 'Hello {name:string}, you have {count:number} message{count|s}'
} satisfies BaseTranslation

// Use with type safety
LL.welcome({ name: 'John', count: 3 })

3. Leverage Pluralization

const en = {
  items: 'You have {count:number} item{count|s}',
  notifications: '{count:number} notification{count|s} received'
} satisfies BaseTranslation

Resources

Was this helpful?