1
/
5

hi18n (i18nライブラリ) の紹介 (2) メッセージパーサーと型レベルパーサー

Photo by John Barkiple on Unsplash

hi18nとは

hi18n は現在Wantedlyで開発中の、TypeScript/JavaScript向け翻訳テキスト管理ライブラリ (i18nライブラリの一種) です。


GitHub - wantedly/hi18n: message internationalization meets immutability and type-safety
Installation: npm install @hi18n/core @hi18n/react-context @hi18n/react npm install -D @hi18n/cli # Or: yarn add @hi18n/core @hi18n/react-context @hi18n/react yarn add -D @hi18n/cli Put the following file named like src/locale/index.ts: And you can use th
https://github.com/wantedly/hi18n

導入方法については hi18nの使い方 で、全体の設計思想については (1) 設計思想と基本方針 で紹介しています。

本記事ではメッセージのパーサーまわりの実装上の工夫について説明します。

ICU MessageFormatについて

(1) 設計思想と基本方針 でも説明しましたが、hi18nではICU MessageFormatを踏襲したメッセージ形式を採用しています。これは以下のような機能を備えています。

  • 引数の単純埋め込み `Hello, {name}!`
  • フォーマット指定 `{count, number} messages`
  • 複数形などの分岐 `{count, plural, one {You have # message.} other {You have # messages.}}`

これに加えて、LinguiJSが採用しているICU MessageFormatへの拡張構文として以下の機能も採用しています。

  • マークアップ呼び出し `Click <link>here</link>`

パースをいつ行うか

メッセージの翻訳はソフトウェアに同梱されるため、その構文解析を行うタイミングは大きく2つあります。

  • ビルド時にパースする。
  • 実行時にパースする。

LinguiJSではビルド時にパースを行っているようですが、hi18nでは実行時にパースしています。これには以下の理由があります。

  • オリジナルのICUでは実行時にパースする形のAPIとして提供されている。そのため、おそらくパフォーマンス上の懸念は薄いと考えられる。
  • 構文もそこまで複雑ではないため、パーサーがバンドルサイズに与える影響は小さいと考えられる。
  • 逆に、構文解析後のASTをJSON等でバンドルすると、パース前の文字列よりも肥大化する可能性が高い。
  • ICU MessageFormatは十分に安定した形式であるのに対して、ライブラリ独自のJSONフォーマットは仕様が安定していないため、ビルドツールとランタイムの間でJSONフォーマットを橋渡しに使うのは互換性上のリスクがある。
    • MessageFormat 2.0では正規のJSONフォーマットを定義しようとする動きがあるが、これは策定・普及までどれくらいかかるかも不明なので今回は考慮しない。

パフォーマンス上の工夫

パフォーマンス問題が起きないようにするための小手先の工夫として、以下のような対応をしています。

  • ほとんどのメッセージは特殊文字を含まない単純メッセージのため、正規表現でマッチさせてこの形に該当したときはパース処理をスキップする。
  • 同じメッセージに対する構文解析の結果はキャッシュする。

ただし、いずれも実際に効果を測定しているわけではないため、将来的に必要ないことがわかったら無くすことも考えられます。

バンドルサイズ削減の工夫

バンドラーによるコードの解析を容易にするため、インスタンスメソッドは使わないようにしています。

// このように書かずに
class Parser {
  parseSomething() {
    this.nextToken()
  }
}

// このように書く
function parseSomething(this: ParserState) {
  nextToken.call(this)
  // this~>nextToken() // call-this expressionが入るとこう書ける
}

こちらも実際に効果を計測しながら実施しているわけではないので、実効性については不明です。

パーサーの設計

パーサーは簡単な再帰下降パーサーです。特別珍しいところはありませんが、以下のような選択をとっています。

  • テキスト部では空白が解釈されるのに対し、コマンド内では空白が無視されます。この例をはじめとして文脈によって字句構造が異なるため、このパーサーの字句解析器はオンデマンドで動作するようになっています。別の言い方をすると、最初にトークンの入った配列を生成せず、都度トークンを切り出しながらパースします。
  • また、このような設計の場合、取り出したトークンをキャッシュする仕組みが必要になることがありますが、MessageFormatの構文ではいずれの非終端記号も先読み分を消費し切るため、今回は特にキャッシュ用の領域は持たずにその場で使い切っています。
  • 空白の読み飛ばしはトークンを読み取る前に行っています。つまり、トークンを読み取った後の空白は次の処理まで読まずに放置されます。これには以下の利点があります:
    • コマンドの終端 } の直後の空白はテキスト部にあたるため読み飛ばしたくないが、そのために特別な分岐が必要なくなる。
    • トークンとしては別として認識したいが、空白を禁止したいケースがある (タグ名の直前など) 。このケースで空白の有無を判定するには最初のトークンを読み取った直後に次の文字を調べればいいだけで簡単になる。

型レベルパーサー

hi18nではMessageFormatのパーサーをもう1つ実装しています。それが型レベルパーサーです。

型レベルパーサーは以下の目的のために実装しています。

  • メッセージの構文エラーを早期に発見するため。
  • メッセージ引数の不一致を早期に発見するため。

たとえば、以下のように誤ったメッセージを書くと型エラーになります。

export default new Catalog<Vocabulary>({
  "example/greeting": msg("Hello, {name!"),
});

また、宣言されていない引数を使っても型エラーになります。

// type Vocabulary = { "example/greeting": Message<{ name: string }> };
export default new Catalog<Vocabulary>({
  "example/greeting": msg("Hello, {namae}!"),
});

翻訳メッセージから推論された引数をそのまま使うようにはなっていません。メッセージの引数は複数の言語にまたがる翻訳データと、利用側 (これも複数箇所存在する可能性がある) の間のインターフェースになっていて、明示する形にしたほうが混乱が少なくなると考えているためです。

なぜ型レベルパーサーなのか

実行時にできることはなるべく実行時に行い、型でできることはなるべく型で実現する、というのがhi18nの基本方針のひとつだからです。

外部ツール、特にライブラリ固有のツールを作って使うと、ユーザーインターフェースの作り込みが不十分になったり、他のツールやエコシステムとの相互運用性が不十分になるなどの懸念があります。なるべくそのような不便を減らすためには、より基本的な道具で目的を実現するのがよいだろうという判断からこのようにしています。

特にTypeScriptは現代のフロントエンド環境では広く使われており、あらかじめエディタとの連携の設定が整っている可能性も高いだろうと考えられます。その既存の仕組みに乗ることは本ライブラリのユーザーにとっても利点になるはずです。

ただし、あとで述べるように型レベルパーサーには保守性の懸念もあります。もし保守できなくなった場合には「型でできることはなるべく型で実現する」の前提条件が満たせなくなったとみなし、同等の機能を外部ツールによって提供するという対応も考えられます。

Message型とmsg関数

型レベルパーサーに基づいてメッセージの型を管理するために使われているのがMessage型とmsg関数です。Message型は以下のように定義されています。

declare const messageBrandSymbol: unique symbol;
export type Message<Args = {}> = string & {
  [messageBrandSymbol]: (args: Args) => void;
};

これはSymbolが公称的な型同一性を持つことを利用したBranded Typeの一種です。ポイントとして、部分型関係が意図した形で適用されるように、Args引数が反変位置に来るように定義しています。(TypeScript 4.7でvariance annotationsが入りましたが、variance annotationの有無にかかわらず適切な位置にあったほうがよいでしょう)

また、bivariance ruleに引っかからないようにmethod signatureを避け、関数型をもつproperty signatureとして定義しています。

Message型はstringのbranded subtypeとして定義されていて、実態は特定の構文に従った文字列データです。この型を返すための関数としてmsg関数を定義しています。

// InferredMessageType<S> は s の内容に基づいて適切な Message<...> 型になる
export function msg<S extends string>(s: S): InferredMessageType<S> {
  return s as InferredMessageType<S>;
}

msg関数は実行時は引数をそのまま返すだけの処理として振舞います。そのため、以下のコード

export default new Catalog<Vocabulary>({
  "example/greeting": msg("Hello, {name}!"),
});

は、実行時の動作としては以下と同じです:

export default new Catalog<Vocabulary>({
  "example/greeting": "Hello, {name}!",
});

内部で構文エラーがあるときは、Message型のかわりにParseError型が返されます。これはMessage型と互換性がないため、プロパティ位置で型エラーが発生します。型エラーにはParseError型が含まれるため、パースエラーの理由も説明されます。

構文エラーがない場合、たとえば Message<{ namae: string }>Message<{ name: string }> に代入しようとすると、互換性がないためエラーとなります。MessageはArgsに関して反変であるように定義しているため、引数を全く使っていないメッセージは引数を必要とするメッセージと同様に扱うことができます。

型レベルパーサーの入出力

型レベルパーサーの役割は「構文チェック」と「引数型のチェック」の2つに限られるため、ランタイムパーサーと異なりASTの出力は行いません。これによりいくつかのケースでは処理が簡略化されます。

たとえば # という文字はplural/selectordinalの分岐中では意味を持つのに対し、それ以外の場所ではテキストとして扱われますが、型レベルパーサーにとってはこの2つを区別する意味はないため特別扱いせずにテキストとして読むことになります。

// # の役割は異なるが、 # の有無は引数型に影響しないので無視できる
"{count, plural, other {# messages}}"
"# messages"

型レベルパーサーの実装

型レベルパーサーは以下の2つの機能により実現可能になりました。

TypeScriptのTemplate Literal Typesを使ったinferを行うとき、長さの自由度が2以上あるときは文字列の始点側が1文字になるように切り出されます。

// infer Ch と string はどちらも長さに自由度がある
// → Chが1文字になるように切り出される
type Head<S> = S extends `${infer Ch}${string}` ? Ch : never;
type T = Head<"Foo">; // => "F"

これを使って入力から文字を1文字ずつ読み取り、末尾再帰によって繰り返し処理をすることでパースを進めるのが型レベルパーサーの基本構成です。

型レベル構造体

型レベル計算で多値を扱うには、タプル型かオブジェクト型に複数の型を詰め込めばよいですが、このような多値は毎回typeとして定義を書き起こすのがよいです。

// 3つの型レベル値からなる型レベル構造体
type Token<Kind extends string, Value extends string, S extends string> = [
  Kind,
  Value,
  S
];

特に、このようにしておくと各要素のextends制約が明示されるというメリットがあります。

特にTypeScript 4.7でinfer-extendsが入る以前は、型レベル構造体に書き起こさないと二重にinferする必要がある場面が発生していました。

type T = /* ... */
  NextToken<Current> extends [infer Kind, infer Value, infer Next] ?
  NextToken<Next> // Nextのbase constraintにstringが含まれていないのでエラー
  : never;

// 型レベル構造体を使わない方法
type T = /* ... */
  NextToken<Current> extends [infer Kind, infer Value, infer Next] ?
  NextToken<Next & string> // & で強制する
  : never;

これが、型レベル構造体を使えば自然な結果になります。

// 型レベル構造体を使う方法
type T = /* ... */
  NextToken<Current> extends Token<infer Kind, infer Value, infer Next> ?
  NextToken<Next> // Token由来の制約でNext extends stringがわかるのでOK
  : never;

// infer-extendsを使う方法
type T = /* ... */
  NextToken<Current> extends [infer Kind, infer Value, infer Next extends string] ?
  NextToken<Next> // OK
  : never;

型レベルADT

次に問題になるのが直和の表現です。といっても表現方法自体は簡単で、複数の (互いに交わらないような) 型レベル構造体を作り合併をとるだけでよいです。問題はそれをどう扱うかです。

基本的にはconditional typeをif-elseチェーンとして繋ぐことでパターンマッチの代わりとすることになります。

type T = /* ... */
  Tok extends Token<"identifier", infer Value, infer Next> ?
    /* Tokが識別子だった場合 */ :
  Tok extends Token<"}", any, infer Next> ?
    /* Tokが "}" だった場合 */ :
  never;

ところがこのような分岐を書こうとすると、パターンマッチ対象の式を何度も書くことになります。パターンマッチ対象の式が複雑になるのはできれば避けたいところです。

普通の言語であれば、いわゆるlet-inに相当する構文を使って一旦ローカル変数に束縛してしまえばいいわけですが、TypeScriptでは型の途中で型レベル束縛を作成できる構文は限られています。筆者の理解が正しければ、generic call signature / generic construct signatureの型引数とconditional typesのinferくらいしかないはずです。

generic call signatureに登場する型引数はlet-inよりもlambdaに近いので、今回は使えません。つまり、conditional typesをlet-inの代用として使う必要があります。

// conditional typesをlet-inの代用として使う
type T = /* ... */
  NextToken<...> extends infer Tok ?
    /* Tok を使った処理 */ :
    never;

しかし、conditional typesはTypeScriptの型プログラミングにとって最も重要なリソース制限のひとつです。このように本質的ではない目的でconditional typesを濫用するのは望ましくありません。

このジレンマは現時点では綺麗に解消することはできませんが、実はパターンマッチ対象の式を繰り返し書くという選択肢はTypeScriptの型レベルプログラミングにおいてはそれほど悪い選択肢ではありません。これはTypeScriptが型参照の展開結果をメモ化するためです。

たとえば、以下はフィボナッチ数列の項を計算する例です。

type Fib<S extends string> =
  S extends `xx${infer S}` ? Add<Fib<`x${S}`>, Fib<S>> :
  S extends "x" ? "1" :
  S extends "" ? "0" :
  string;

type T = Fib<"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx">;
// => "190392490709135"

Addの実装を含むフルのコードは以下の通りです。

type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
type ArrayOfLength = [[], [0], [0,0], [0,0,0], [0,0,0,0], [0,0,0,0,0], [0,0,0,0,0,0], [0,0,0,0,0,0,0], [0,0,0,0,0,0,0,0], [0,0,0,0,0,0,0,0,0]] & Record<string, 0[]>;
type Lo = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
type Hi = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1];
type Add1<A extends string, B extends string, C extends 0 | 1> =
  [...ArrayOfLength[A], ...ArrayOfLength[B], ...ArrayOfLength[C]]["length"];

type Add2<AU extends string, AL extends string, BU extends string, BL extends string, C extends 0 | 1> =
  `${AddN<AU, BU, Hi[Add1<AL, BL, C> & number]>}${Lo[Add1<AL, BL, C> & number]}`;

type AddN<A extends string, B extends string, C extends 0 | 1 = 0> =
  A extends `${infer AU}${Digit}` ?
    A extends `${AU}${infer AL}` ?
      B extends `${infer BU}${Digit}` ?
        B extends `${BU}${infer BL}` ? Add2<AU, AL, BU, BL, C>
        : string
      : B extends "" ? Add2<AU, AL, "", "0", C>
      : string
    : string
  : A extends "" ?
    B extends `${infer BU}${Digit}` ?
      B extends `${BU}${infer BL}` ? Add2<"", "0", BU, BL, C>
      : string
    : B extends "" ? C extends 0 ? "" : `${C}`
    : string
  : string;

type Add<A extends string, B extends string> =
  A extends "0" ? B :
  B extends "0" ? A :
  AddN<A, B>;

type Fib<S extends string> =
  S extends `xx${infer S}` ? Add<Fib<`x${S}`>, Fib<S>> :
  S extends "x" ? "1" :
  S extends "" ? "0" :
  string;

type T = Fib<"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx">;
// => "190392490709135"

このfibは再帰による非効率的な実装として知られているもので、通常は指数的な時間がかかります。しかし、TypeScriptは型参照の展開結果をメモ化しているため、このような計算も一瞬で終わるようになります。

このように、TypeScriptの型レベルプログラミングでは同じ式を何回も書くことによるパフォーマンスペナルティーはない一方、式の結果を保存することのコストは高いという奇妙な構造のため、同じ式を何度も書くほうが (やや保守性は下がるものの) 好ましいとも考えられます。

type T = /* ... */
  /* NextTokenを2回計算しているように見えるが、実際にはメモ化されている */
  NextToken<...> extends Token<"identifier", infer Value, infer Next> ?
    /* NextToken<...>が識別子だった場合 */ :
  NextToken<...> extends Token<"}", any, infer Next> ?
    /* NextToken<...>が "}" だった場合 */ :
  never;

その他の工夫

  • TypeScriptの型レベルプログラミングでは大域脱出用の仕組みがないため、エラーの伝搬を自力で行う必要があります。これは高コストのため、なるべく処理をフラットに書くようにします。
    • これは手続き型言語でいうところのcallとjumpの違いに相当します。なるべくjumpを使うという主張です。
    • 末尾再帰な繰り返しプログラムにおけるcall相当とjump相当の違いは、復帰方法の違いです。callでは、被呼び出し側の関数は末尾呼び出しをせずに呼び出し元に復帰します。一方、jumpでは被呼び出し側の関数は末尾呼び出しによって呼び出し元と同等の処理に遷移します。
  • エラーメッセージを含めた主な挙動はランタイムパーサーと一致させています。全く同じテストデータに対してテストすることで、同じ挙動を保証しています。

型レベルパーサーの保守性問題

これは多くの人が懸念していることかと思います。筆者も型レベルプログラミングによって実装された部分の保守性は問題になる可能性があると思っています。

ただ、今回の事例では以下のような理由からリスクは十分に低いと考えています。

  • 対象となる構文 (MessageFormat) の複雑性には上限が決まっていて、拡張が必要になる回数は限られている。
  • もし型レベルパーサーの保守が不可能になった場合、プランBがある。
    • 「型でできることは型で行う」のポリシーに従っているため、型でできなくなったら外部ツールで再現することを試みることになる
    • さいわい、この要件はESLintのルールとしても実装する余地があるため、いざとなったらそちらに移行するという手立てがある。

まとめ

  • 翻訳データには引数の埋め込みなどのためにフォーマット構文が存在するため、これらのパーサーが必要である。
  • hi18nでは翻訳データのフォーマット指定をビルド時ではなく実行時にパースしている。
  • 実行時に使われるパーサーとは別に、型レベルパーサーが実装されている。これにより、翻訳データの形式的な誤りはTypeScriptの型検査器によって発見できる。型レベルプログラミングにはいくつもの特殊な制約があるため、それにあわせて実装上の工夫が行われている。
  • 型レベルパーサーには保守性に関する懸念がある。保守できなくなった場合は、外部ツールによる同等機能への置き換えを行う。
Invitation from Wantedly, Inc.
If this story triggered your interest, have a chat with the team?
Wantedly, Inc.'s job postings
6 Likes
6 Likes

Weekly ranking

Show other rankings
Like Masaki Hara's Story
Let Masaki Hara's company know you're interested in their content