漸進的型付け言語の時代に必要なもの

最近では、Gradual Typing、漸進的型付けと呼ばれる型システムを備えた言語(拡張)が増えてきています。

次のようなもの

flow/pyre-checker/hack と facebook 製が多いですね。

この記事は、それらを使う動機と運用について書きます。この記事の出発点として、 おそらく TypeScript/Flow で発生した問題が後発の言語で発生すると思っており、それらを使う方や、設計する人への提言でもあります。

自分は昔 https://github.com/mizchi/TypedCoffeeScript というAltJS作ろうとして、実装のツラミはなんとなく知ってるつもりです。ホビーレベルで作るものではなかった…。

アノテーションの再評価

一昔前の Web プログラミング言語のトレンドは動的型付け一辺倒でしたが、その時代も終わり、静的な型宣言を再評価するフェーズが来ているように思います。

この背景には、おそらく Web プログラミングの規模が年々肥大化しており、動的検査のコストが増してきたのが理由にあるでしょう。

WAFの考え方だと、型をつけづらい外部IOであるところのHTTPリクエストを受け、HTML文字列を返す、という世界観では、型宣言は単に「おまじない」を多くするだけの邪魔者だったかもしれません。外部IOに型をつけづらいのは、外部IOの本質的な問題ではあります。

しかし今では、どのWAFも内部では分厚いビジネスロジックを持ち、実質的に静的なフィールドを持つORMを読み書きし、何らかの型を暗に想定する JSON を返す、という風にトレンドが変わってきました。型やIDEの支援なしにコードを育て続けるには、逆に高度なモジュール分割のノウハウや、状況に応じたストラテジーが必要になっています。

それらの肥大化の対応として、ドキュメントを書く文化や、テスト駆動も普及してきましたが、一周回って「検査可能なドキュメント」としての型アノテーションの価値が再評価されたように思います。

誰のための型か

注意してほしいのは、ここでいう型宣言の需要は、人間のために書く、ドキュメントとしての型アノテーションで、コンパイラに効率よくランタイムを生成してもうらための型ではありません。これを混同している人が多いですし、「高水準な、良く設計されたプログラミング言語」はそれらを区別せずにプログラマに書かせようとしてきます。(Rust などは低水準を目指しているので明示的に区別します)

言語としてその方針は間違ってないですが、使う側や、言語を選定する側は区別しないといけません。抽象メモリマシンである C や、型の表現が未発達だった時代の Java の冗長な宣言などに引っ張られて静的型付けを批判する人がいますが、それらは現代の静的型付け言語ではないです。最近の推論機は優秀で、見た目上は動的型付けと同じものを書けます。

また Language Server Protocol といったエディタのためのプロトコルによって、静的解析できるメタデータの重要性も上がっています。

何の機能をもってどう表現するかは、 私と型システムとポエム - The curse of λ という記事で、よく雰囲気が表現されています。

漸進的型付けの登場

What is Gradual Typing: 漸進的型付けとは何か - Qiita

基本的に、動的型付の言語に後付で型宣言を追加するものです。このとき、これらが新しい言語やコンパイラと言うのはちょっと微妙なところで、例えば TypeScript + Flow は拡張文法のパーサ + 拡張文法を取り除くだけのコンパイラ + 静的検査という組み合わせで、どちらというと Linter などに立ち位置が近いです。

自分が知る限り(主に TypeScript, Flow, Dart)、こういう特徴があります。

  • 未知の変数は any 型であると仮定する
  • あらゆる変数の型は any 型にアップキャストできる(Top)
  • any 型はあらゆる型にダウンキャストできる(Bottom)
  • あらゆる型は any 型と合わせて操作すると any にアップキャストされる
  • 自分で宣言した型を扱う限り、型エラーが発生する
  • アノテーションはランタイムに関与しない

(typescript の scrictAny や noImplicitAny オプションはこの挙動をより厳密にコントロールできます)

つまり、こういうコードが通ってしまいます。

const x: number = 1
const y: any = x
const z: string = x + y // pass

これが駄目だという話ではなく、型システムを後付している以上、柔軟性の方が重視されます。というかそうでないと、コンパイラを納得させられずに開発が「詰む」ことがあります。 any を書いた際に守るかどうかは自己責任です。

「型アノテーションはランタイムに関与しない」が特徴的で、型アノテーションを取り除くコンパイラとともに実装されるのも合わせて、なんというかそれ自身では控えめなコンパイラです。その結果として現れる挙動として、Flow/TypeScript は int や float の宣言はできず、必ず number として扱う必要があります。

また、既存のものに無理矢理型を当てはめるという特性上、Generics、Union Type, Subtyping といった高度な型表現を備えているのが事実上必須な特性となっています。

type X<T> = T | null | [T, T] | Array<T | 1> | 'yeah!' | 1 | 2

こんな風になったりしますね。

元は動的型付けからの発展だと思うと、この柔軟さや、any への妥協は必要なラインだと思います。

漸進型付けの利点

  • 既存の資産をそのまま利用できる
  • 「自分で書く範囲のコードは」厳密に運用することができる

一応言っておきますが、僕は動的型付の言語をすべて否定しているわけではなく、大規模なコードに適用するのはそぐわないという意見です。

一つのことをうまくやるUNIX哲学に照らし合わせても、文字が来て文字を返すぐらいに抽象化されたエコシステムでは、型宣言はそこまで重要視されないでしょう。たとえば npm のほとんどそういう世界だと思っています。

その上で、動的型付けのノウハウと資産を活かしつつ、その利用者になりつつ、巨大なアプリケーションを構築する手段として漸進型付けは有用だと思っています。

漸進型付けで発生した問題

ここからは自分が TypeScript と Flow の運用で発生した問題について。

TypeScript/Flow では、npm の資産をそのまま動かせる、というかランタイムに全く関与しないのですが、基本的に、扱うライブラリの型宣言はないです。

https://github.com/DefinitelyTyped/DefinitelyTypedhttps://github.com/flowtype/flow-typed/ という型定義の集積リポジトリがあります。ただしこれらが使い物になるかというと、おおよそ使えつつも様々な問題が起きます。

  • そもそも型が付けられるインターフェースではない(redux.combineReducers など)
  • 作者が型定義の運用に興味がない(PRを受け付けてくれない)
  • ↑の問題の結果、作者以外によって書かれた型アノテーションが間違っている
  • Generics がある以上、型定義が一意に定まらず、定義者の主張が強く出てしまう

そもそも型が付けられるインターフェースではない(redux.cobineReducers など)

たとえば、Redux の combineReducers は (State, Action) => State の reducer と呼ばれる関数を複数合成して、新しい (State', Action') => State' の新しい関数を生成する関数です。

combineReducers({
  counter, user, auth
})

これで、合成される State' は次のような型になります。

{
  counter: {...},
  user: {...},
  auth: {...}
}

これらをTypeScript/Flowの推論器で一般化するのはちょっと難しく、flow では専用の $compose という型で実装されようとしていますが、いまいち使いづらいので、結局推論器に任せずに自分でその型を宣言しなおすことになります。

これにかぎらず、JS の昔ながらのイディオムだと options: boolean | { isXXX: boolean, ... } みたいな引数が多く、型を付けられなくはないが、ダイナミックすぎて、ユニオンタイプを酷使するみたいな状況が起こりがちです。

作者が興味がない | 作者以外が書いた型が間違っている | 型の解釈の意見が合わない

そもそも言語拡張なので誰もが使うというものではなく、立場が弱いです。作者が興味が無いから PR が無視されたり、メンテ出来ないからごめんね、といってリジェクトされます。

また、Generics によって型の一意性はないです。

type Foo = { a: number, b: string }

という型があったとして、その定義は

type A<T> = { a: number, b: T }
type Foo = A<string>

なのか

type B<T> = { a: T, b: string }
type Foo = B<number>

なのか、使う人によって解釈が異なります。作者以外が書いてる場合は尚更です。ユーザーが使いたい時の値が Foo だとして、ライブラリとして提供される型の表現が A なのか B なのか、解釈に依存します。またどれぐらいの厳密なのかも方針次第で異なって、単に any で型名がわかるだけだったり、逆にオプショナルな値が解釈違いで必須になっていたりといった手続きが頻出します。

また、厳密な型定義ができるように Generics を大量に使ってしまった結果、高難易度な型パズルが頻出するライブラリが疎まれたりします。DefinitelyTyped/index.d.ts at master · DefinitelyTyped/DefinitelyTyped · GitHub

気に食わないなら自分で書き治すか、諦めて any にキャストする必要があります。

実際の運用について

興味がないのものを握りつぶす

実際に型がないと困るのは自分の扱うコードと接する範囲です。これらを厳密に扱う必要がないときは、単に無視します。 これに型を書いてもいいですが、現実に扱うすべてのコードに型をつけるのは現実的ではありません。よほど暇な時でしょう。

// declaration.d.ts
declare module "a";
declare module "b";
// flow-typed/lib.js.flow
declare module "a" { };
declare module "b" { };

握りつぶした上で、段階的に自分が興味がある範囲の型を徐々に追記していく、ということが多いです。

外部IOの出口を押さえる

とにかく大事なことは、自分が扱う範囲の型を守ることです。

function parseXXX(input: {args: string[]}): {data: string} {
  // input から文字列を取り出して加工する
  return { data }
}

const {data} = parseXXX(input as any)

input にはもっといろんなプロパティがありますが、ここでは args にしか興味がないとして、 最終的に data: string が帰ればOK、というやつです。

これは 最終的に string にダウンキャストして扱っています。ここは型がない危険な領域ですが、まあ頑張るとする。

express のようなサーバーを扱ってると、うまく型がつかず、こういう風にラップしていることが多いです。

後発の言語へ言いたいこと

Python 3.5 や、もしかすると Ruby 3 にも型が入るかもしれません。

TypeScript/Flow はそれ自身の立場が弱いので、ライブラリへの型定義ファイルのPRが受け入れられず、外部の型集積は結構な地獄になってしまいましたが、静的型付けを受け入れられない作者がおそらく一定数いる以上、どの環境でもないよりはマシです。

ただし、それらは握りつぶせる必要があります。原則的に型宣言や型推論ありきで運用するのは、別の言語として再スタートしない限りは不可能です。

また、既存のコードが型を想定していない以上、推論器の限界で表現できないものがたくさんあります。また、逆説的ですが、表現力がありすぎる型とその型推論は、使う側も結構辛いです。

たとえば、 flow の Redux connect の型定義はこうなっています

declare export function connect<Com: ComponentType<*>,
    A,
    S: Object,
    DP: Object,
    SP: Object,
    RSP: Object,
    RDP: Object,
    MDP: Object,
    MP: Object,
    RMP: Object,
    ST: $Subtype<{[_: $Keys<Com>]: any}>
    >(
    mapStateToProps: ?MapStateToProps<S, SP, RSP>,
    mapDispatchToProps: ?MapDispatchToProps<A, DP, RDP>,
    mergeProps: MDP,
    options: ConnectOptions<S, SP & DP & MP, RSP, RMP>
  ): (component: Com) => ComponentType<$Diff<ElementConfig<Com>, RMP> & SP & DP & MP> & $Shape<ST>;

パッと見、よくわかりません。推論機の癖を読み切ってジェネリクスを一つずつ解いて型パズルを完成させる必要があります。単に無視されてアップキャストされることのほうが多いです。そして割れ窓になります。

matz は Ruby に型宣言を入れずに推論機でどうにかしたいと言っていましたが、おそらく後方互換性を保ったままだと不可能だと思います。それはそれとしてドキュメントとしての型宣言は選択肢として提供するべきではないでしょうか。

Flow や mypy みてると思ったのは、むしろ言語として実装するのは型アノテーションのフィールドの構文予約だけでよくて、その実装はコミュニティに丸投げでいいのかもしれません。

最後に

ネガティブな点を多々挙げましたが、これらは 0 を 1 にする際の、型宣言がなかったものをあるようにするための一歩のための苦しみで、これ自体は間違いなく進歩だと思います。

最初から型がある言語でやればいいのでは?というツッコミはもっともなんですが、サーバーサイド以外はプラットフォームによって言語を選べないことが多く、JavaScriptはその最たるもので、ある種苦肉の策であります。

個人的には単一の推論ルールを持った処理系を、環境によってその厳しさを調整できると嬉しいような気がしていますが、そういう柔軟性がある言語はいまのところ無いですね。