DEV Community

Cover image for Kotori, strongly typed and modular i18n library for React
Acid Coder
Acid Coder

Posted on • Edited on

Kotori, strongly typed and modular i18n library for React

Most i18n libraries treat translations as stringly-typed key-value lookups. You call t('some.key'), get a string back, and the compiler has nothing useful to say about it. Variable names, key existence, argument types — all invisible to TypeScript.

I wanted something better, so I built kotori.


The Problem

Here is typical react-i18next usage:

// en.json
{
  "welcome": "Welcome, {{name}}! You have {{count}} messages."
}

// component
const { t } = useTranslation()
t('welcome', { name: 'John', count: 5 }) // ✅ works
t('welcme', { name: 'John' })            // ✅ compiles — typo in key, silently broken
t('welcome', { nama: 'John' })           // ✅ compiles — wrong variable name, silently broken
t('welcome')                             // ✅ compiles — missing args, runtime empty string
Enter fullscreen mode Exit fullscreen mode

Everything compiles. Nothing is safe. You find out at runtime, or worse, in production.

To get type safety with react-i18next you need to maintain a separate type declaration file that mirrors your JSON. That is codegen, or manual work that drifts. Either way it is a second source of truth.


The Idea

What if the string literal itself was the schema?

const welcome = dict({
  en: 'Welcome, {{name}}! You have {{count}} messages.',
  zh: '欢迎,{{name}}!你有{{count}}条消息。',
})

t('welcome', { name: 'John', count: 5 }) // ✅
t('welcome', { name: 'John' })           // ❌ compile error: missing 'count'
t('welcome', { name: 'John', extra: 1 }) // ❌ compile error: unknown key 'extra'
t('welcome')                             // ❌ compile error: args required
Enter fullscreen mode Exit fullscreen mode

No JSON files. No codegen. No separate type declarations. The string is parsed at the type level and the variable names fall out as a typed record.


Custom Argument Types

By default, variables are typed as string | number. You can narrow them:

const time = dict({
  en: 'Current time: {{hour}}:{{minute}}',
  zh: '当前时间:{{hour}}:{{minute}}',
})<{ hour: number; minute: number }>

t('time', { hour: 12, minute: 30 })  // ✅
t('time', { hour: '12', minute: 30 }) // ❌ compile error: 'hour' should be number
Enter fullscreen mode Exit fullscreen mode

Template literal types work here too:

const lastLogin = dict({
  en: 'Last login: {{date}}',
  zh: '上次登录:{{date}}',
})<{ date: `${number}-${number}-${number}` }>

t('lastLogin', { date: '2024-04-24' }) // ✅
t('lastLogin', { date: 'yesterday' })  // ❌ compile error
Enter fullscreen mode Exit fullscreen mode

Variable Mismatch Across Languages

There is a subtler problem: what if a translator introduces or drops a variable in a secondary language?

const greeting = dict({
  en: 'Hello {{name}}',
  zh: '你好 {{naam}}', // typo — wrong variable name
})
Enter fullscreen mode Exit fullscreen mode

kotori catches this at the type level too. Secondary language strings are validated against the primary, and any mismatch surfaces as a compile error rather than a silent runtime blank.


Architecture

kotori architecture

One instance per app

// utils.ts
export const { dict, createTranslations } = kotori({
  primaryLanguageTag: 'en',
  secondaryLanguageTags: ['zh', 'ja', 'ms'],
})
Enter fullscreen mode Exit fullscreen mode

Most libraries need a provider, a config file, locale imports, and sometimes a plugin before you can translate a single string. kotori has one setup call.

kotori holds the language state. All createTranslations calls share it — changing language anywhere rerenders all active consumers.

One createTranslations per page

// page1.ts
const { useTranslations } = createTranslations({ welcome, time })

// page2.ts
const { useTranslations } = createTranslations({ weather, score })
Enter fullscreen mode Exit fullscreen mode

Translations are colocated with the components that use them. Vite and webpack code-split them naturally — each page only loads what it needs. No upfront global bundle.

Language tags are typed

kotori uses BCP 47 tags validated at the type level. 'en', 'zh', 'ms-MY' are accepted. 'klingon' is a compile error.


The API in Full

import { createTranslations, dict } from './utils'

const intro = dict({
  en: 'My name is {{name}}, I am {{age}} years old.',
  zh: '我叫{{name}},我今年{{age}}岁了。',
  ja: '私の名前は{{name}}で、{{age}}歳です。',
  ms: 'Nama saya {{name}}, saya berumur {{age}} tahun.',
})

const { useTranslations } = createTranslations({ intro })

export const Page = () => {
  const { t, setLanguage, language } = useTranslations()

  return (
    <>
      <select 
        value={language}
        onChange={(e) => setLanguage(e.target.value as 'en')}
      >
        <option value="en">English</option>
        <option value="zh">Chinese</option>
        <option value="ja">Japanese</option>
        <option value="ms">Malay</option>
      </select>
      <p>{t('intro', { name: 'John', age: 30 })}</p>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Demo: https://stackblitz.com/edit/vitejs-vite-nyxwmhre?file=src%2FApp.tsx

Installation

npm i kotori
Enter fullscreen mode Exit fullscreen mode

The gzipped bundle is 0.38kb. There are no dependencies.

Source: github.com/tylim88/kotori

Top comments (0)