2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptAdvent Calendar 2024

Day 2

TypeScriptでローカライズ

Last updated at Posted at 2024-12-01

TypeScriptのカレンダーの2日が空いていたので参加しました

ツールを作っていると多言語に対応する必要が出てくることがあります。

複数の言語に対応するため、文言のマッピングを行うことも多々あるのですが、その場合言語による語順の変化に対応するため、文言の生成時にパラメータを渡してやることになります。

なので以下のようなマッピングと関数を用意して

const localize_map = {
  ja: {
    abcdef: 'いろはにほへと',
    'ghi${jkl}mno': 'ちりぬるを${jkl}わかよたれそ',
    'pqr$${stu}vwx': 'つねならむ$${うゐのおくやま}けふこえて',
    'y${A}z${B}': 'あさきゆめみし${B}ゑひもせす${A}',
  },
};

function localize(message: string, params?: Record<string, string>): string;

英語のときは

console.log(
  localize('abcdef'),
  localize('ghi${jkl}mno', { jkl: 'JKL' }),
  localize('pqr$${stu}vwx'),
  localize('y${A}z${B}', { A: 'aaa', B: 'bbb' }),
);
// -> abcdef ghiJKLmno pqr${stu}vwx yaaazbbb

日本語のときは

console.log(
  localize('abcdef'),
  localize('ghi${jkl}mno', { jkl: 'JKL' }),
  localize('pqr$${stu}vwx'),
  localize('y${A}z${B}', { A: 'aaa', B: 'bbb' }),
);
// -> いろはにほへと ちりぬるをJKLわかよたれそ つねならむ${うゐのおくやま}けふこえて あさきゆめみしbbbゑひもせすaaa

のような出力にしたいわけです。

しかし、こういう状態だと得てして

    'ghi${jkl}mno': 'ちりぬるを${jk1}わかよたれそ',

パラメーター名がキーと値で違っていたり、

   'y${A}z${B}': 'あさきゆめみし${B}ゑひもせす',

パラメーターに過不足があったり、

  localize('abcdefg'),

メッセージが微妙に違っていたり

  localize('ghi${jkl}mno'),

パラメータを渡すのを忘れていたり

と人為的ミスの発生するポイントがかなりあります。

この人為的ミスの発生をTypeScriptを使って抑えていこうと思います。

仕様

文言のマッピングは、文言と文言のマッピングをロケールや言語ごとに用意しようと思います。

各文言は指定されたパラメーターを置き換えられるようにしておきたいです。なのでテンプレートリテラルを真似して、${ABC}の形で表記されている箇所を、文言と一緒に渡されたパラメーターのプロパティABCの値へ置き換えるようにします。

このときの${ABC}をプレースホルダー、ABCをプレースホルダーのキーと呼ぶことにします。

ただこうしただけでは${ABC}を含むメッセージが表現できなくなるので、$$$に変換する、というルールも追加します。

$ABCのように{}が無い場合、{ABC}のように$が無い場合のように不完全なプレースホルダーは面倒なのでプレースホルダーとは見なさず、固定文字列とします。

プレースホルダーのキーは1文字以上の文字列とし、${}を含めることはできないものとします。面倒なので。

ソースコード上 実際の文言 プレースホルダー
abc abc なし
abc${def} abcDEF def='DEF'
abc$${def} abc${def} なし
abc${def abc${def なし
abc${d${ef}} abc${dEF} ef='EF'
abc${d$ef} abc${d$ef} なし

解析

上記の仕様で文言を解析していきます。

  • まず$で分割してその後に
    • $が続けば、前の文字列に$を追加して次の検索へ
    • {~}が続けば
      • ~の部分が空文字列であれば、前の文字列に${}を追加して次の検索へ
      • ~の部分が${を含む場合は、前の文字列に${を追加して次の検索へ
      • それ以外であれば、プレースホルダーとして追加して次の検索へ
    • $でも{~}でもなければ前の文字列に$を追加して次の検索へ
  • $がなければ固定文字列
/** テンプレート文字列を固定文字列とプレースホルダーの配列に分割 */
type Tokenize<S extends string> =
  // 次の`$`を検索
  S extends `${infer PRE}$${infer POST}`
    ? // `$`の次の文字も`$`だったら`$$`を固定文字列`$`として次を検索
      POST extends `$${infer REST}`
      ? [`${PRE}$`, ...Tokenize<REST>]
      : // `$`の次が`{~}`だったらプレースホルダーと見なしてKEYを取得
      POST extends `{${infer KEY}}${infer REST}`
      ? // KEYが空文字列の場合は`${}`までを固定文字列として次を検索
        KEY extends ''
        ? [`${PRE}\${}`, ...Tokenize<REST>]
        : // KEYが`$`、`{`を含んでいる場合は不完全なプレースホルダーなので`${`までを固定文字列として次を検索
        KEY extends `${string}${'$' | '{'}${string}`
        ? [`${PRE}\${`, ...Tokenize<`${KEY}}${REST}`>]
        : // プレースホルダーはそのキーを配列にして次を検索
          [PRE, [KEY], ...Tokenize<REST>]
      : // `$`の次が`$`でも`{~}`でもなければ`$`までを固定文字列として次を検索
        [`${PRE}$`, ...Tokenize<POST>]
    : // `$`が見つからなければ固定文字列とする
      [S];

プレースホルダーの部分は['ABC']のようにキーの文字列リテラル型を1つ持つ配列として表しています。

場合によっては固定文字列が配列の中で連続することになりますが、この解析結果自体が表に出ることはないのでそのままとします。

この解析結果から必要なパラメーターやローカライズ後の文字列型などを生成していきます。

必要なパラメーター

解析の結果、文字列リテラル型もしくはプレースホルダー(文字列リテラル型の配列)、の配列が得られます。

この中からプレースホルダーのキーをUnion型として抽出します。

/** プレースホルダーのキーを抽出する */
type PlaceHolderKey<TOKEN> = TOKEN extends [infer KEY]
  ? KEY extends string
    ? KEY
    : never
  : never;

/** テンプレート文字列からプレースホルダーのキーを抽出する */
type TemplateParameters<S extends string> = PlaceHolderKey<
  Exclude<Tokenize<S>[number], string>
>;

localize関数の第2引数の型

これをキーに指定して値がstrtingRecord型をlocalize関数の第2引数の型に指定します。

ただし、メッセージがプレースホルダーを持たない場合、つまりTemplateParameters型がneverの場合は第2引数を省略できるようにします。

/** テンプレート文字列に必要なパラメーターを生成する。 */
type LocalizeParameter<KEY extends string> = [TemplateParameters<KEY>] extends [
  never,
]
  ? // KEYがプレースホルダーを持たなければ引数追加無し
    []
  : // プレースホルダーを持つなら必須引数として追加する
    [params: Record<TemplateParameters<KEY>, string>];

localize関数の型

localize関数の第1引数にはMAPS型の値のキーを指定します。LocalizeParameterの型引数は文字列型である必要があるのでstringで絞り込んで

/** メッセージをロケールに応じた文言に変換する関数の型 */
type LocalizeFunction<MAPS extends Record<string, Record<string, string>>> = {
  <KEY extends string & keyof MAPS[keyof MAPS]>(
    key: KEY,
    ...args: LocalizeParameter<KEY>
  ): string;
};

こうすることによりlocalize関数でマッピングにない文字列を指定したり、パラメーターに過不足があればエラーとして指摘されるようになります。

image.png

文言のパラメーターチェック

ローカライズの前と後の文言に存在するプレースホルダーの過不足をチェックするために、ローカライズの前と後の文言に対してTemplateParametersを取り比較します。

一致すれば過不足がないので、後の文言の型を返し、不一致であればエラーを指摘するために存在しない型を返します。

ここでいう存在しない型には過不足のパラメーターを含めることで、エラーメッセージを見るだけでどのパラメーターを修正すればよいかが分かるようにします。

/** 言語ごとの文言マッピングにプレースホルダーの過不足がないかチェックする */
type ValidationLocaleMap<MAP extends Record<string, string>> = {
  readonly [KEY in keyof MAP & string]: Equal<
    TemplateParameters<KEY>,
    TemplateParameters<MAP[KEY]>
  > extends true
    ? // ローカライズ前後でプレースホルダーのキーが一致すればそのまま
      MAP[KEY]
    : // 一致しなければエラーメッセージと差分を型に載せる
      string &
        [
          'parameter(s) not match:',
          (
            | Exclude<TemplateParameters<KEY>, TemplateParameters<MAP[KEY]>>
            | Exclude<TemplateParameters<MAP[KEY]>, TemplateParameters<KEY>>
          ),
        ];
};

/** すべての言語の文言マッピングにプレースホルダーの過不足がないかチェックする */
type ValidationLocaleMaps<MAPS extends Record<string, Record<string, string>>> =
  {
    [LOCALE in keyof MAPS]: ValidationLocaleMap<MAPS[LOCALE]>;
  };

これだけでは型チェックに使えないので、関数にすることでその関数に指定したマッピング上でエラーが表示されるようにします。

function localizer<MAPS extends Record<string, Record<string, string>>>(
  maps: ValidationLocaleMaps<MAPS>,
): LocalizeFunction<MAPS> {
  // ...
}

image.png

パラメーターの過不足があれば、その文言を指摘してくれますし、過不足のあるパラメーターもエラーメッセージを見れば分かるので、修正が簡単にできるようになりました。

欠点

  • TypeScriptのコード上に書かないとエラーを指摘してくれない

特に外注とかしてると納品物はJSONだったり、エクセルシートだったりなのでエラーの修正には実は向いていないのかも知れないです。

とりあえず納品物からTypeScriptのコードを自動生成し、コード上のコメントとして納品物のどこから持って来たものかを書き込むことで、TypeScriptが指摘してくれた箇所が納品物のどの位置に当たるかが分かるようにしています。

まとめ

全体のコードはPlaygroundにあります。

テンプレートリテラル型のおかげである程度の文字列解析を行うことができるようになりました。

ただ、まだある程度でしかないのでちょっと割り切った仕様にする必要がありますね。

マッピングのチェックについては最近実装されたsatisfiesが使えると思ったんですが、

image.png

のようにエラーになってできませんでした。トホホ…

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?