TypeScriptで関数の部分型を理解しよう
こんにちは、バックエンドエンジニアのほとけちゃんです。社内では他にテキストチャット弁慶なども担当しています。Slackでわめくのが仕事です。
さて、現在開発中の新規サービスではバックエンドにTypeScriptを採用しているのですが、チーム内にTSに不慣れなメンバーもいたためThe TypeScript Handbookを輪読しています。TSのわけわからん機能を知ることができてなかなか面白いです。
Handbookは丁寧に書かれている一方、実は型システムや数学の前提知識を要求しているくせに詳しい説明も無くさらっと書かれている箇所もいくつかあります。その中でも関数型同士の部分型について口頭で説明するのに骨が折れたので、どうせならブログにまとめようと思い筆を取りました。なるべく小難しい単語(structural subtypingとかco-variantとか)を使わない説明を心がけたのでお付き合い頂けると嬉しいです。ただし、ユニオンやインターセクションの理解は前提とします。
なお、本稿の内容は『型システム入門』の15章を読めば大体わかるので、あれを理解している人は読まなくても大丈夫です。粗探しために読むのは歓迎します。
部分型の基本
大雑把に言ってしまえば、部分型のモチベーションは「安全な範囲ならばある型の値を別の型の値として扱いたい」というものです。こう書いてしまうと「そんなことあるの?」と思う向きもおられるかもしれませんが、実のところ日常茶飯事です。
function hello(arg: {name: string}): string {
return 'Hello! ' + arg.name;
}
hello
の引数の型は{name: string}
なので、アサーション等の特殊な操作をしない限りhello
のボディの中ではarg.name
以外のプロパティにはアクセスできません。だったら、hello
の引数にはstring
型のname
プロパティを持つ値だったら何でもいいと思いませんか?だってname
さえあれば危険な操作は起こり得ないですからね!
部分型はそれを実現するための仕組みです。実際、次のコードはコンパイルできます。
const arg = {name: 'John', age: 1}
hello(arg)
name: string
なプロパティを持つ変数であれば{name: string}
型の部分型の値として扱われ、{name: string}
を期待する関数の実引数にすることができるのです。ただしhello({name: 'John', age: 1})
は型エラーになるので注意して下さい。部分型を与えていて安全であるにもかかわらず、オブジェクトリテラルの場合だと厳密な型の一致が求められます。理由はよく分からないというか調べていないのでエスパーすると、後からこの呼び出し部分のコードを読んだときに関数の型を誤解する可能性があるから、などでしょうか。
部分型の動きをよりシンプルに確認できるのが代入です。
const subUser: {name: string, age: number} = {name: 'John', age: 1};
const user: {name: string} = subUser;
もうお分かりでしょうが、2行目が怒られないのはsubUser
の型が{name: string}
の部分型だからです。
もう一つ具体例を見ましょう。人によってはこのユニオン型の例の方が理解しやすいかもしれません。
function stringify(arg: number | string): string {
if (typeof arg === 'number') {
return arg.toString()
}
return arg
}
stringify(1) // -> '1'
stringify('hoge') // -> 'hoge'
stringify(1)
が通るのは、number
型がnumber | string
型の部分型だからです。stringify
のシグネチャは引数にnumber
が来ることを想定しているので、実際にnumber
の値を適用しても安全です。ユニオン型については一般に、「任意の型T
, S
についてT
とS
はT | S
の部分型である」という命題が成り立つはずです1。
ついでに言うと、インターセクションについても部分型関係にかんして一般的な命題が成り立ちそうです。例えば{name: string} & {age: number}
という型は{name: string, age: number}
と等価です。オブジェクトのインターセクションを取るとプロパティがどんどん増えることになります。ということは……予想がついたと思いますが、「任意の型T
, S
について、T & S
はT
, S
それぞれの部分型になる」と言えるはずです2。
関数の部分型付け
それでは本題の関数型について見ていきましょう。関数型の間にも部分型関係が定義されています。これまでも「安全な範囲で」とか「危険な操作」という表現を使ってきましたが、どのように部分型関係を構築すれば安全なのでしょうか?
結論から言えば、関数型においては「U
がS
の部分型で、T
がV
の部分型ならば、S => T
はU => V
の部分型」になります。注意してもらいたいのは、引数の部分型関係が逆転するかたちになる点です。パッと理解できなかった人は2つ前の文をじっと睨みつけて下さい。S => T
と書いたときS
が引数の型、T
が戻り値の型です。
なんだかややこしいです。何故こんな小難しい決まりになっているのでしょうか。具体例で考えましょう。以下のコードをコンパイルできると仮定しましょう。
type F = (arg: {n: number, s: string}) => {s: string}
function funcSub(arg: {n: number, s: string, b: boolean}): {s: string} {
console.log(arg.b)
return {s: arg.s}
};
const f: F = funcSub;
f({n: 1, s: 'hoge'})
コンパイルが通るということはfuncSub
はF
の部分型です。funcSub
の第一引数の型はb
というプロパティを持っているので自由にarg.b
という式を呼び出せます。しかし、8行目で引数として与えられているオブジェクトにはb
というプロパティを持っていません。あーーー危ないですね。実際にはこのコードはコンパイルエラーになります。えらい!
では、どんな型を部分型とすると安全なのでしょうか。結論から言えば、「引数の型がn: number
、s: string
以外の余計なプロパティを持っていない型」です。それは別に{n: number}
でも構いません。プロパティs
は使われないで終わるだけです。なんなら{}
でも構いません。全てのプロパティが無視されるだけです。安全です。
これをより正確に言えば、期待される引数の型は「{n: number, s: string}
を部分型とする型」({n: number, s: string}
のスーパータイプとも言う)になります。
他方、戻り値の型の部分型関係は同じ向きをしています。
const f: F = someFunc;
const res: {s: string} = f({n: 1, s: 'hoge'})
res
は{s: string}
型なので、s
にしかアクセスできません。なのでsomeFunc
の返り値はs: string
さえ持っていればあとは任意です。つまり、{s: string}
の部分型であればよいのです。
したがって、例えば(arg: {n: number}) => {s: string, b: boolean}
はF
の部分型ですが、(arg: {n: number, s: string, b: boolean}) => {s: string}
は違います。こうやって上から矢継ぎ早に言われても理解が追いつかないかも知れないので、手元で色々と具体例を作って確認することをお勧めします。
inferの挙動を理解する
関数の部分型の説明はこれで終わりですがおまけです。そもそも輪読会で部分型が話題になったのは、HandbookのType inferece in conditional typesにおいてでした。関数の部分型を理解した我々ならこの節を読むのも楽勝です。若干駆け足になりますが、最後にここの解説をして終えたいと思います。理解できなければHandbookの原文をじっくり読んでみて下さい。
infer
キーワードはこんな使われ方をしています。
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
またなんか難しそうなのが出てきましたね。ここでは、「T
が何らかの関数型だったらその戻り値の型をR
に束縛してR
を返し、そうでないならany
にする」という操作が行われています。例えばReturnType<() => string>
ならstring
だし、ReturnType<string>
はany
です3。
次はより複雑です。
type Foo<T> = T extends { a: infer U; b: infer U } ? U : never;
自然言語で説明すると、「T
がa
とb
というプロパティを持つ型だったら、a
の型とb
の型の両方がその部分型になるような型を見つけてそれをU
とする」という操作がここでは行われます。具体的には、Foo<{ a: string; b: number }>
はstring | number
になります。string
もnumber
もstring | number
の部分型ですからね4。
最後です。やっと関数型が登場します。
type Bar<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void }
? U
: never;
基本的には上のFoo
と同じで、a
とb
の両方がU => void
の部分型になるようにU
を探します。ここでinfer
が引数の型の方についていることに注目して下さい。引数の型の部分型関係は逆転します。つまり、U
はa
とb
の引数の型の部分型になります。Foo
の場合とは逆であることに注意して下さい。
ではBar<{a: (x: {n: number}) => void, b: (x: {s: string}) => void}>
はどんな型になるでしょうか。引数の型はそれぞれ{n: number}
と{s: string}
です。こいつら両方の部分型になる型が欲しいわけです。どうしましょう。
答えは簡単、インターセクションを取ればいいのです。つまり{n: number} & {s: string}
です。インターセクションが部分型になるのは上で確認した通りなので、わからない場合は読み返してみて下さい。
おわりに
ブログの常として最後の方は息切れするのと飽きるのとで駆け足になってしまいましたが、本稿は以上になります。ブクマとブコメ待ってます。
- この記事は部分型の理論の知識で書いているので、実際のTSの型システムがそうなっているかは正直言って不明です。間違っていたらブコメとかに書いて下さい。 ↩
- 「string & numberはneverなのでプロパティ増えなくない?」と思った人は鋭い。でもneverはstringとnumberの部分型です。参考 ↩
- 組込みのReturnTypeとは定義が異なるようで、そちらは関数以外の型は与えられません。 ↩
-
ただ、条件を満たす型は無限にあります。
string | number | boolean
とかもそうで、ユニオンをどんどん足していっても条件を満たします。その中でも一番小さいやつを選ぶのでしょう。 ↩
その他の記事
Other Articles
関連職種
Recruit