TypeScriptでローカライズ

※ [TypeScriptのカレンダー](https://qiita.com/advent-calendar/2024/typescript)の2日が空いていたので参加しました


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

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

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

```ts
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;
```

英語のときは

```ts
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
```

日本語のときは

```ts
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
```

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

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

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

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

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

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

```ts
  localize('abcdefg'),
```

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

```ts
  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}`|なし|

## 解析

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

- まず`$`で分割してその後に
  - `$`が続けば、前の文字列に`$`を追加して次の検索へ
  - `{～}`が続けば
    - ～の部分が空文字列であれば、前の文字列に`${}`を追加して次の検索へ
    - ～の部分が`$`や`{`を含む場合は、前の文字列に`${`を追加して次の検索へ
    - それ以外であれば、プレースホルダーとして追加して次の検索へ
  - `$`でも`{～}`でもなければ前の文字列に`$`を追加して次の検索へ
- `$`がなければ固定文字列

```ts
/** テンプレート文字列を固定文字列とプレースホルダーの配列に分割 */
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型として抽出します。

```ts
/** プレースホルダーのキーを抽出する */
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引数の型

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

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

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

## `localize`関数の型

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

```ts
/** メッセージをロケールに応じた文言に変換する関数の型 */
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122792/9cb66dd3-c001-a51d-7c11-c2ec1a3d51b5.png)

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

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

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

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

```ts
/** 言語ごとの文言マッピングにプレースホルダーの過不足がないかチェックする */
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]>;
  };
```

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122792/f0485634-5528-3aed-6704-441fea1caa61.png)

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

## 欠点

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

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

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

## まとめ

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

https://www.typescriptlang.org/ja/play/?target=99&jsx=0&ts=5.7.2#code/C4TwDgpgBAogjgVwIYBsA8BBANFAQgPigF4oAKNAFX1IEpjCKoIAPYCAOwBMBnKDKAPxQADFABcUAIx0WbLr0rU6RBk1YceeALAAoKIJG79EyUYPAATgghmJAM1TcIAbl26A9ACpPUQGMMgZ4ZAdYZAG4ZAH4ZACYZAcNNAdW1AdCVAJIZAL7VALO0Y2MALBhDQwE6GQG2GQGuGQAGGUMA7BkBZRNjAawZAMCVAJyUoT3ddUEgoCgB7AGsOAEsALwg0AGU1OU1uSx72AHNCIjN3dyhAQmsSgAMAEjX4wBxLQCJfMxHZDV5NgG8puwgLKAAFACUYAF8NjYv2K5vbgHkhiie1mZ9EJFlBNmsSqsYoAghnBgAMGQDGDIB9BkAkQybLZJVJxcHpQDqDIAzBmWuwOen0ZJ+f1GJzBr3enygjz+AKBZKEAG1zg9nlscAA6fkdbrsfqDRlUAC6LOMUFB4MhgBkGNZnQB6-wDEaisnkiqF0oBoyMAVgwEgDSMAAmvFAGvKgHT9KV3X6MY7yMFnN6Xa5QE2mp4vOnusXM0lkgygz3ywBeXmkSoAWDUAECqAewZzgDAH4MgHMGTFpPGE4m2-SeqlOgDkBZzBg5by5TwAOm8AXyBV1egM0GL8JLA0HxDKlqHwYBABiV20A1CqAZIYU4ARBkA0QyxuOAWDlADHagAtFfWagrFfUlFPnNap9NxTNE-YlvOOzTnCYWKbTH0FjYFqAAHygBbOBZ958vAY7rKgZbOFerZxrHWvKCo2gznJ63pvP6aytiWEigiu2pxoAugwlIAtQyhPEFSVASB4kl+QZslyOBsp64rAaBwpNi2bZfghSxyssipbCmsIqgCbH6oAigyADEMgAODOCO4pBmeHZu2RGco8LxAVA-IgQ21GDBSEosgxNJrPKuqACYMgDSDCiPECSJe6AJoME62myQziq4OgeN4UBIcUGFYYAvUaAF+KZkNE0OgtNAtwoEgADGEAABLtCgnDXEaEAgJQ3wmgAcnMbTxTACX5pobJujc5FmEIx7qE674zCy+VmmpUDsBAABu1y2JVNXXDZdk+AEWRRHE+mOaUmHxO5nmNM04DQBQEAALZgAFbC3EgFhIGNEBsBY3DDBlvDFbMxB3AFwVhRFUUxWgZgwMwgUoAgkWUIpIrDPgbLsAgY0AEbXBRUAbfguj4M1OheD43XxGekwzAClSAHb+gCyDIA28aABIMSJtWE4SAFcMviAJcM+SANHq8SAPiugAIRmZgBADF5Q2tAAwu07CBUgwBUStFCpQlRyFZopAbQ+P4beKNBsuKyX04lTNjLwbJmNlUAAGJYGY8li-cUs6HRBj3GtZBs4+bKc9zivAjSZziyrbNCPrEgbT65OU9TtPNvgn5khIVW1RY9VFj9f1+EEiORt1gAXCdUgNvKbazg9DcMIxEKPo1jeOE8TPnDXcs3zYt1wipwo0TVNgyC9SH1bebVM04pdPXU2Qz4N9bi-fZgAAUYAeVGACoM6QlJE1eAHsMgDDDIAKwz+IADQyVN1JSAHIJM6AM2x8r6mOumAIMMgDlDO3gD1DAN3m+VAABqqA9Jw1M9BTAAy7RUygEAALJIGAaDHxgtwq-cECBe0FicGgG04B9yVnGYFgQEgnAUygIA-jzFMKA3QQDtDsFAS+18ABk71gbTHFBIeAyB0AsnTpNamEAZpzQWktFanp8DyyDOgzO2Ck54IvlfMiZpeZmEICeXglhrClS7FAQAtwyhEANUMqNAAlDIALoZACySoAGP0UwD0wvKQAAHKABfAkyAk0JJiTLaKB1DTTa07KCaRuJDL8UABUMqNQiAEOGdugBuhlCIADoZ0iADvdP2GNKiAEh4wA2gzmQknAi8MwoDQJLCLFxQYCxgETrg64rM6DsHaMAKAY1qaBQABZiALEQwi+hSAliDI+E6Z0LqDBIZgshgTlpoAITgbJ00AnJ3yco8i5cUlkjSadc6l1ilYNKRQipNDCFtHGhgkpOCyn4LNFUnxZIaAJK-NZXQTxXb2RMoATwZ8QlDrs3NuXde79zCFqJyw8x4T2nnPReE5Y4r3XigTe2894H1QCfM+K0oHZydLfe+j9n7wJwPch+T8X6uMvOXOYZgP7tjZLvb4pMMC7xgFAYBoDwGQKvlZCQRyTnAB3uwfeh9LnnxuQCoFIKYC8xsvoCZlc3Zhw6lUQAo-qAEDI-UgBFhlRgY0IgB+hiwtHCcRNBpx1aCijeAxcllIKWaA28DkpskaTyihBDxQq28foB21x5aKxBN2M08oAaAEBjJE2jACo+oAB1NAC-8YAAqVACEjriFkPN6qITWauLCKrtL6hRKSwAEBnaszPqsyJr-E4O4BIV5jyRXNOuH0007SPpjNslXHwRjTFmPiGwwAjQyhHyJUQAO-qAA0GJELdKiAEhNKGZlABFqVqkoGMDnx05ccgY4sECU0RRTShtxbmaG9e855DI75vKeW46Yr9BXly2n8-QfLTQCvbR4kBMUoUYshRAm5vNkntlARIT0Iy5L8lmtMT1UAS0ilFdcftn1SQ0AkFui8Aw06dMzju9mrN4HDrOE8GgeKZT2QAAKRTAF-AuEBODQtrZUQAGtqAAp1LRY4I3mJTCZVl3k+3HxgEMIYGAADiYKGGfJmNQFkC1uDcCQNMCAEgoMwfgzARd8kV1ro3dyv1FgL7Qdgwh3d+h92QOowR+9KBzlHwECbeBNkCWhrsBWwKVb2BQFY4fEUlGbk3xbY8j5Da22Xk7e275M79CRLAGu+FW9BMlrRdcmFhCzAiYuRx5DHbdAMbIxActlakU1rLlAXtwm2Mil5IZo+W1XMuE-otBAFghOuZFD9fQfHrMU0c6JpsBUhYjrAZOmFbIJ1fqssOj6ymyTocw9h+dZoiP8jZO6+a3BEHrqc+RnpS0d1mDoA5-Q992ATDCxc9zJWIAubY9AAQQgXYslq-VgKMxkDYaa6i3kkTgAxNIO4AAeqQSsAB3AA1DQUgAgiAAH0aDuBoAIXkbJhASo60+As96asU16210+YAtqqe4GyDzIagw9fCX16YA20VXaubdpA-WsMQHu2SR7DXS0fqGxci7222TpZ+-toQz3Xtg525D7D0OIkQAwz947UAv7AB8355rnBeRfwwcFVLQZ3DVmW2Ias95KxnFIGySbNZxSeBoJWW97hTMuNIKNmJOA50dIzjkijAb8DKDo4RUBBh8tjW4OD0ByOiydm59E4gRASA3lvAYG8d4JBK8XXeswTxxmVwB-5gYIOgcWFIA5gAVkgCQ1WoBICeoFSKdgJAFjHIAWIY4yVEAN4MgAPBnSPElkBZpjRJ6G8a3nQUBPDGqEgs7vACGDIAKIZAA2DBOeIkfo9PEAPEMulAARDEiXiKFg-tj8XACwtIJgICeNVWbzAE9Pm0oAWwZbWAACGWkgAxBkAAkMJRABSDIAeQZAAhDEmJ43FACuDIAZQYYb4lL74kAbwMBPD6G8XATxG8FkAEIMgBVBghoAMIZACBDIAfwZcSr6eIARIZABODNCBxJlF-r4SU8eWt6fo9fCi11j0xUum4gKQAsTuXcIA7ACxhkDNms-8w8I8zgo8Y8492h4l7MoAYD3cAApI0XeO8W9BJH-P-MACvKvbHWvevEA7A8AgsBfM4JfFfM4NfBAs4Pgd3JAJghA3Ad3J6dgzA0AnQfXX6JYAAWkIAANdygEgLQN3jgKgDwMrzOGryIOYCgBACYKQD6HYKekrh-1a1RS2gLFt1WxQNuCO2N1O3f1ay-zAPC1-3-2d1dxIPMK5UsMgKz1gPjxwHoOQKfDEM4NIIsNwPwIDkILrwby4P0BwPIPv2oNoNcIYKfCUJYLYI4KgCwLMxslBAEKgC9x9wD3SCgBT3T3iDELz0L2LygBb3bzeB7372H1Hwn2n3xCgB3332P1xFUKvxvxMiUN0CAA

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122792/cff59dc9-e325-8bc4-65a5-a083bc7cbb15.png)

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