最終更新:

【canopy-i18n】型安全に使えるi18nライブラリ【TypeScript】


こんにちは、フリーランスエンジニアの太田雅昭です。

Node.js/TypeScriptでのi18n、型ズレや初期設定の重さに悩まされがちです。この記事では、最小限のAPIで型安全に使える「canopy-i18n」を紹介します。

i18nライブラリへの不満点

現在数々のi18nライブラリがあります。用途によって使いやすさは変わってくるかと思いますが、最近のモノレポやTypeScriptでのコーディングスタイルに合っているものがないように思います。具体的には以下のようなことがあります。

  • 型安全性が弱い: 翻訳キーのタイポや引数不足が実行時まで発覚しない。
  • 初期設定が煩雑: 設定ファイルやプラグインが多く、導入に腰が重い。
  • テンプレート制約: {{placeholder}} のような独自構文で表現力が足りない。
  • 深いデータ適用が面倒: ネストしたオブジェクト/配列にロケールを一括適用しづらい。
  • ファイルが分散する: 言語ごとにJSONファイルを分けるため、管理が煩雑になりがち。

canopy-i18nで解決できること

canopy-i18nは、下記のような特徴があります。

  • 型安全: 許可ロケールやキーをコンパイル時にチェック。IntelliSenseも完全対応。
  • コロケーション: すべての翻訳を1ファイルにまとめられるので、ファイル間を飛び回る必要がない。
  • ゼロ依存・ゼロ設定: 外部依存なし。ローダーやプラグインの設定も不要。
  • 自由なテンプレート: ただのTypeScript関数なので、条件分岐や外部フォーマッタもそのまま使える。
  • ジェネリック返却型: 文字列だけでなく、Reactコンポーネントやオブジェクトなど任意の型を返せる。
  • AI対応: 型情報と単一ファイル構成により、AIアシスタントが正確にコード生成できる。

インストール

npm i canopy-i18n
# or
pnpm add canopy-i18n
# or
bun add canopy-i18n

基本的な使い方

createI18n でビルダーを作り、.add() でメッセージを定義、.build() でロケールを確定します。メッセージは関数として直接呼び出せます。

import { createI18n } from 'canopy-i18n';

const messages = createI18n(['ja', 'en'] as const)
  .add({
    title: { ja: 'タイトル', en: 'Title' },
    greeting: { ja: 'こんにちは', en: 'Hello' },
  })
  .build('en');

console.log(messages.title());    // "Title"
console.log(messages.greeting()); // "Hello"

同じビルダーから複数のロケールをビルドすることもできます。

const builder = createI18n(['ja', 'en'] as const)
  .add({
    title: { ja: 'タイトル', en: 'Title' },
  });

const ja = builder.build('ja');
const en = builder.build('en');

console.log(ja.title()); // "タイトル"
console.log(en.title()); // "Title"

テンプレート関数

動的な値を埋め込みたい場合は .addTemplates()() を使います。引数の型も推論されます。

const messages = createI18n(['ja', 'en'] as const)
  .addTemplates<{ name: string; age: number }>()({
    welcome: {
      ja: (ctx) => `こんにちは、${ctx.name}さん。${ctx.age}歳ですね。`,
      en: (ctx) => `Hello, ${ctx.name}. You are ${ctx.age} years old.`,
    },
  })
  .build('en');

console.log(messages.welcome({ name: 'Tanaka', age: 20 }));
// "Hello, Tanaka. You are 20 years old."

.add().addTemplates()() はメソッドチェーンで混在できます。

const messages = createI18n(['ja', 'en'] as const)
  .add({
    title: { ja: 'マイページ', en: 'My Page' },
  })
  .addTemplates<{ count: number }>()({
    items: {
      ja: (ctx) => `${ctx.count}個のアイテム`,
      en: (ctx) => `${ctx.count} items`,
    },
  })
  .build('ja');

console.log(messages.title());           // "マイページ"
console.log(messages.items({ count: 5 })); // "5個のアイテム"

カスタム返却型

文字列以外を返すこともできます。たとえばオブジェクトを返したい場合は型引数を指定します。

type MenuItem = { label: string; url: string };

const menu = createI18n(['ja', 'en'] as const)
  .add<MenuItem>({
    home: {
      ja: { label: 'ホーム', url: '/ja' },
      en: { label: 'Home', url: '/en' },
    },
  })
  .build('ja');

console.log(menu.home().label); // "ホーム"
console.log(menu.home().url);   // "/ja"

Namespaceパターン(ファイル分割)

規模が大きくなったらファイルを分割し、bindLocale でツリー全体にロケールを一括適用できます。

// i18n/common.ts
import { createI18n } from 'canopy-i18n';

export const common = createI18n(['ja', 'en'] as const).add({
  hello: { ja: 'こんにちは', en: 'Hello' },
  goodbye: { ja: 'さようなら', en: 'Goodbye' },
});

// i18n/user.ts
import { createI18n } from 'canopy-i18n';

export const user = createI18n(['ja', 'en'] as const)
  .addTemplates<{ name: string }>()({
    welcome: {
      ja: (ctx) => `ようこそ、${ctx.name}さん`,
      en: (ctx) => `Welcome, ${ctx.name}`,
    },
  });

// app.ts
import { bindLocale } from 'canopy-i18n';
import * as i18n from './i18n';

const messages = bindLocale(i18n, 'en');

console.log(messages.common.hello());              // "Hello"
console.log(messages.user.welcome({ name: 'John' })); // "Welcome, John"

bindLocale はネストしたオブジェクトや配列も再帰的に処理するため、どれだけ構造が深くなっても一発でロケールを切り替えられます。

リンク