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
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
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
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
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
})
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
One instance per app
// utils.ts
export const { dict, createTranslations } = kotori({
primaryLanguageTag: 'en',
secondaryLanguageTags: ['zh', 'ja', 'ms'],
})
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 })
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>
</>
)
}
Demo: https://stackblitz.com/edit/vitejs-vite-nyxwmhre?file=src%2FApp.tsx
Installation
npm i kotori
The gzipped bundle is 0.38kb. There are no dependencies.
Source: github.com/tylim88/kotori

Top comments (0)