TypeScriptのStructural Subtypingの話2

前回の記事ではNode.JSのCモジュールなどTypeScriptの世界だけで完結しない話について型システムがどう扱うか、ということを書いた。

今回は近いところでちょっと違う話。

前回の話を考えているとき、TypeScript自体のソースコードをちょっと調べていて気づいたパターンがある。それはたとえば https://github.com/Microsoft/TypeScript/blob/master/src/compiler/types.ts#L13 などに特有なのだが、 brand という特殊なフィールドを持つ型が何箇所かで定義されている。

この例では

export type Path = string & { __pathBrand: any };

という定義で、これはPathという型を定義しているのだけど、実質的にはPathはstring(文字列)だということを言っている。Node.JSではパスに対しては文字列が使われているという現状からそのようになっている。

そしてTypeScriptはインタフェースさえ合っていれば同じように引数に渡せるため、この型システムではPathとstringを区別することはできない(なぜなら同じ型だから!)。引数の順番を間違えたとか、そういうケアレスミスによって間違った文字列が渡されても間違いが検出できない。

そこで __pathBrand というフィールドを Path 型の定義に追加している。ただの文字列にはこのフィールドはない。またPath型を返す関数は文字列をType assertion (キャストのようなもの)によって変換しているため、実際のコードには __pathBrand は出現しない。

これによって全く同じ型の値をそのまま扱いつつ、些細なミスをコンパイラが検出してくれるようになる。

似たような仕組みは構文木にも現れていて、特定の構文要素をグルーピングするのにこのようなbrandのようなものを使い、間違ったものを渡すようなケアレスミスを防いでいる。

もちろんこのテクニックは完全ではなくて、やろうと思えば適当に __pathBrand のようなフィールドを追加してやることでチェックを迂回できる。でもいかにも意味のありげな名前だし、コメントにも明記されている(vscodeなどではこういうコメントも付随して定義されているし)。そして何よりこのテクニックは正確に検証するというよりはケアレスミスを減らすためのテクニックなので、意図的に回避したい人を止めるものではない、っていうところだろう。


こういうことをやりたければ、ふつうの関数型言語であれば新しい型を定義したり、 phantom type を使ったりするところだと思う。

Go言語であれば普通に新しい型を定義して元の型と互換性をなくすといったところかな。

こんな具合に「構造上は全く同じだが何らかの事情で処理系上は区別したい型」というのは、現実のプログラミングでは意外とほしくなることがあり、インタフェースの一致だけに限定したシステムだけではこういう区別はできない。そういう事情でTypeScriptにはこんなテクニックが使われている、という話でした。

Ruby 3ではこういう問題をどう扱うのかな、というのは気になっています。