コード中の定数の greppability と TypeScript の進化

TL; DR

コード中の定数の greppability

以下のように翻訳の ID を引数にとって, 各言語に翻訳されたメッセージを返す関数というのはよくあるのではないかと思います.

declare function translate(id: string): string;

そして (形式は何でも良いのですが) 各言語に対応した翻訳が書かれているファイルを, (方法は何でも良いのですが) translate が読み込んで使うとしましょう.

{
  "direction": "方向",
  "direction.left": "",
  "direction.right": ""
}

こうして画面上に各言語に対応したメッセージが表示できました. よかったですね.

const Direction: FC<{ dir: "left" | "right" }> = ({ dir }) => (
  <p>{translate("direction")}: {translate(`direction.${dir}`)}</p>
);

ところでソフトウェアは生き物なので, 生きている限りは画面上の表示も変更されたりして, 使わなくなった翻訳というのが往々にして発生します. こういったものが積もっていくと資源の無駄なので, 必要がなくなったら削除していきたいはずです.

ということで, 例えば direction.left という ID の翻訳が現在使われているか調べてみましょう. 最も素朴な方法はこの ID でソースコードを検索 (grep) することですが, 検索してみたところソースコード中には direction.left という文字列は見つかりませんでした. どうやら使われていないようなので削除しましょう!

...

となっては困りますね. とはいえ単純な grep で見つけられないとなると自動化できませんし, 人間が探すにしても空気を読む必要があって大変です.

ここで問題となっているのは translate(`direction.${dir}`) のように動的に ID 文字列を生成している部分です. もし greppability (grep 可能性) を確保したいのであれば, ここを translate(dir === "left" ? "direction.left" : "direction.right") のようにリテラルで与えるようにする必要があります.

このような greppability を保証するために translate の型定義を以下のように変更してみます.

declare function translate<T extends string>(id: StringLiteral<T>): string;

type StringLiteral<T extends string> = string extends T ? never : T;

ここで StringLiteral<T>Tstring の場合のみ never となり, string literal type やその union type であれば T 自体となる型です. こうして作られた translate は, 引数に漠然とした string は受け付けず, 必ず string literal type またはその union type といった, より具体的なものを受け取るようになります.

なぜこれで greppability を保証できるかというと, 動的に ID を生成すると型が曖昧になる = string 型になるということを利用しています.

// OK
translate("direction.left"); 

declare const dir: "left" | "right";
// `direction.${dir}` の型は string なのでエラー
translate(`direction.${dir}`);

よかったですね.

ここまでが一年前に考えていた話.

TypeScript の進化

さて TypeScript の進化はここ一年もめざましく, 先日 beta 版がリリースされた TypeScript 4.2 からは template literal expression に対して template literal type が付与されるようです.

どういうことかというと, こういうことです.

declare const dir: "left" | "right";
// `direction.${dir}` の型は `"direction.left" | "direction.right"` なので OK
translate(`direction.${dir}`); 

終了. 正直 TypeScript 4.1 の template literal types の導入の時点でこうなる予感はしてました.

ということで TypeScript の型を使った方法はもう未来がないので, せっかく紹介したところですが使わないでください. 他の方法, たとえば ESLint で no-restricted-syntax を使って雑に検査するのであれば以下のような感じになるかと思います.

{
  "rules": {
    "no-restricted-syntax": [
      "error",
      {
        "selector": "CallExpression[callee.type='Identifier'][callee.name='translate'][arguments.length>0][arguments.0.type!='Literal']",
        "message": "Translation ID must be given as a literal expression."
      }
    ]
  }
}

良いニュースと悪いニュースがある, みたいな話でした. こちらからは以上です.

2020-02-13 追記

TypeScript 4.2 RC で上記の変更は一部 revert されました. とはいえ as const をつけることで beta と同様のより詳細な型の推論は行われるので, やはり型推論が不可能であるという前提に立った方法は不適当であることは変わらなそうです.