この記事は「TypeScriptの型入門」の続編です。入門の続編ということなので初級というタイトルにしてみました。TypeScriptの型よくわからんという方は先に入門から読むことをおすすめします。入門レベルのTypeScriptくらい分かるよという方は読まなくても大丈夫です。
さて、前回の記事ではTypeScriptの型を一通り紹介しました。この記事ではその続編として、実用上必要になるTypeScriptの型の挙動を理解したり、標準ライブラリに存在する型の使い方を理解することを目標にします。前回に引き続き、あくまでTypeScriptの型に関する話ですから、JavaScriptの言語機能とか、TypeScriptの構文とかの話はしません。悪しからずご了承ください。
最終更新: 2019-03-16 (TypeScript 3.4に対応しました)
union型の復習
実は初級編のひとまずの主役はunion型です。ですので、union型について復習しておきましょう。
union型は、T1 | T2 | T3
のように複数の型を|
でつなげた型で、意味は「T1
, T2
, T3
のいずれかである値の型」となります。例えば、string | number
はstring
またはnumber
である値の型、すなわち「文字列または数値」という型です。
// 文字列はstring | number型に代入可能
const val1: string | number = 'foo';
// 数値もstring | number型に代入可能
const val2: string | number = 123;
// それ以外はだめ(エラー)
const val3: string | number = {foo: 'bar'};
union型のいいところは、if文やswitch文などで実行時に型を判定するコードを書くと、それに合わせて型が絞られる点です。
function func(arg: string | number) {
if ('string' === typeof arg) {
// 実行時にargが文字列であることを確認したので
// この中ではargはstring型
console.log(arg.length);
} else {
// argはstringではないので
// この中ではargはnumber型
console.log(arg * 10);
}
}
特に、代数的データ型っぽいパターンの型に対してもこの機能が有効に働きます。
type None = { type: 'None' };
type Some<T> = { type: 'Some'; value: T };
type Option<T> = None | Some<T>;
function map<T, U>(opt: Option<T>, func: (value: T) => U): Option<U> {
if (opt.type === 'Some') {
// この中ではoptは { type: 'Some'; value: T } 型
const newValue = func(opt.value);
return { type: 'Some', value: newValue };
} else {
// この中ではoptは { type: 'None' } 型
return { type: 'None' };
}
}
以上がunion型の復習でした。それでは、最初のテーマに入っていきます。
conditional typeにおけるunion distribution
TypeScriptの型入門を読んだ皆さんは、conditional typeの基本を既にご存知かと思います。余談ですが、conditional typeの訳語は条件型でいいのでしょうか。
実は、conditional typeには、TypeScriptの型入門の記事では紹介していなかった重要な性質があります。それがunion distributionです。日本語に直すとunion型の分配でしょうか。
とりあえず、conditional typeの例を見ましょう。
type None = { type: 'None' };
type Some<T> = { type: 'Some'; value: T };
type Option<T> = None | Some<T>;
/**
* ValueOfOption<V>: Option<T>を受け取って、渡されたのがSome型なら中身の値の型を返す。
* 渡されたのがNone型ならundefinedを返す。
*/
type ValueOfOption<V extends Option<unknown>> = V extends Some<infer R> ? R : undefined;
const opt1: Some<number> = { type: 'Some', value: 123 };
// typeof opt1はSome<number>なので
// ValueOfOption<typeof opt1>はnumber
const val1: ValueOfOption<typeof opt1> = 12345;
const opt2: None = { type: 'None' };
// typeof opt2はNoneなので
// ValueOfOption<typeof opt2>はundefined
const val2: ValueOfOption<typeof opt2> = undefined;
ValueOfOption<V>
は、V
に渡された型がSome<R>
型(の部分型)だった場合はそのR
を返し、そうでない場合はundefined
を返すような型となります。
ところで、V
としてOption<T>
型を渡したらどうなるでしょうか。まず、Option<T>
型はSome<T>
型の部分型ではありません。なぜならOption<T>
型の値はNone
型である可能性があり、それはSome<T>
ではないからです。では、conditional typeの定義に従ってundefined
となるのでしょうか。
しかし、実はそうではありません。ここからがこの記事の新しいところです。
実際にやってみると、実はValueOfOption<Option<T>>
はT | undefined
となります。
type None = { type: 'None' };
type Some<T> = { type: 'Some'; value: T };
type Option<T> = None | Some<T>;
type ValueOfOption<V extends Option<unknown>> = V extends Some<infer R> ? R : undefined;
// T1は number | undefined となる
type T1 = ValueOfOption<Option<number>>;
const val1: T1 = 123;
const val2: T1 = undefined;
条件型はどちらか片方を返すはずなのに、まさかの両方とは、これは反則もいいところです。この動作を説明するのがunion distributionなのです。
今回のポイントは、条件型の条件部分の型V
がOption<T>
というunion型になっている点です。Option<T>
はNone | Some<T>
というunion型でしたね。このように条件型の条件部分にunion型が来たときに、条件型は特殊な動作をします。一言で説明すると、「union型の条件型」が「条件型のunion型」に変換されます。数学等ではこのような挙動をdistribution(分配)といいますから、union distributionというのもそこから来ています。
今回の例で具体的に説明すると、V
にOption<T>
、すなわちNone | Some<T>
が入りますから、条件型のV
のところにNone
とSome<T>
がそれぞれ入った2つの条件型が生成され、それのunionになります。すなわち、V extends Option<infer R> ? R : undefined
は(None extends Some<infer R> ? R : undefined) | (Some<T> extends Some<infer R> ? R : undefined)
に変換されます。
これを計算すると確かにundefined | T
となりますね。これが条件型におけるunion distributionの基本です。この挙動には、2つほど注意しなければいけない点があります。
条件型の結果側における型変数の置き換え
下に示す別の条件型を考えてみましょう。
type NoneToNull<V extends Option<unknown>> = V extends Some<unknown> ? V : null;
このNoneToNull<V>
型は、V
がSome<T>
ならそのままで、None
ならnull
に変換するという型です。先ほどの条件型との大きな違いは、条件部分だけでなく結果部分にもV
が表れているということです。このV
に対してunion distributionが発生してV
が置換されるとき、結果部分のV
も同時に置換されます。
NoneToNull<Option<T>>
の場合は、これは(None extends Some<unknown> ? None : null) | (Some<T> extends Some<unknown> ? Some<T> : null)
に変換され、結果はnull | Some<T>
となります。ポイントは、分配後の条件型で、もともとV
だったところが左と右でそれぞれNone
とSome<T>
に置換されているところです。
分配されるのは型変数のみ
もう1つ注意しなければいけない点があり、これが条件型の大変ややこしいところでもあります。それは、今まで説明したようなunion distributionが発生するのは条件部分の型が型変数である場合のみであるという点です。
今までのサンプルでは、条件型のextends
の左が全部V
だったことを思い出してください。このV
はValueOfOption<V>
のように型の引数として導入された型変数です。このように、extends
の左が型変数ただひとつという形であるときしか、union distributionは発生しないのです。
例えば、ValueOfOption<V>
を使わずに、いきなりOption<number> extends Some<infer R> ? R : undefined
という型を書いてみたらどうなるでしょうか。これはextends
の左が型変数ではないので、union distributionが発生しません。よって、結果はundefined
です。
type None = { type: 'None' };
type Some<T> = { type: 'Some'; value: T };
type Option<T> = None | Some<T>;
// T1はundefined
type T1 = Option<number> extends Some<infer R> ? R : undefined;
const val1: T1 = undefined;
// ↓これはエラーになる
const val2: T1 = {type: 'Some', value: 123};
このように、型関数(型引数を持つような型をこう呼んでいます)をインライン化するだけで結果が変わるというのは非直感的ですね。また、union distributionを使いたいときは必ずそこを型変数にする必要があり、すなわち型関数を作る必要があります。自分で型を書くときだけでなく、人が書いたTypeScriptの型を読むときにも、条件型が出てきたらこれはunion distributionをさせることを意図しているのか、それともさせないことを意図しているのかを考えなければ読まなければいけません。
逆に型変数で条件分岐したくなったけどunion型が来ても分配してほしくない場合のテクニックとしては、何か適当な型で囲むというものがあります。配列型で囲むのが、記法が簡単なのでよく使われます。
type None = { type: 'None' };
type Some<T> = { type: 'Some'; value: T };
type Option<T> = None | Some<T>;
type ValueOfOption<V> = V[] extends Some<infer R>[] ? R : undefined;
// これはnumber型
const val1: ValueOfOption<Some<number>> = 123;
// これはundefined型
const val2: ValueOfOption<None> = undefined;
// これはnumber | undefinedではなくundefined型
const val3: ValueOfOption<Option<number>> = undefined;
// ↓なのでこれはエラー
const val4: ValueOfOption<Option<number>> = 123;
この例では、条件部分に来ているV[]
はただの型変数ではないのでunion distributionの発生条件に当てはまらず、分配が発生しません。
never型とunion distribution
never
型は属する値が無い型でしたが、union distributionに際して少し特殊な振る舞いをします。never
型は、0個のunion型であるように振る舞います。例として次のサンプルを見てみましょう。
type IsNever<T> = T extends never ? true : false;
// T1はneverになる
type T1 = IsNever<never>;
この例で、IsNever<T>
はT
がnever
ならばtrue
になりそうでなければfalse
になる型というつもりでしたが、never
を渡した結果はtrue
でもfalse
でもなくnever
です。これはnever
が0個のunionのように振る舞うことから説明できます。言い換えれば、T
が型変数でT extends never ? X : Y
という形の条件型に対してT
にnever
を代入すると常に結果はnever
になります。
これを避ける方法はついさっき説明したばかりなので省略します。
union distributionのまとめ
ここで説明したことを一言でまとめ直すと、「条件型の条件部分の型が型変数ならばunion型が分配される」ということです。
この動作は型で条件分岐するものとしての条件型の直観からは外れてしまいますが、この挙動によってunion型をたいへん便利に扱えるようになります(これについてはこの記事の後半で取り扱います)。また、最後に紹介したように、本当に型で条件分岐させたいときの方法(extends
の左が型変数だけになるのを避ける)も残されていますから、まあ良いのではないかと思います。
extends
の左が型変数になるのを特別扱いする理由としては、unionを分配したときに結果側も書き換えなければいけないことが挙げられます。下の例を思い出して欲しいのですが、この例ではV
に入ったunion型が分配されることになるので、結果側もV
を書き換えればいいことが明らかです。型変数に入っていないunion型を分配しようとすると右辺に出現する同じ型を適切に書き換える必要が出てきますが、それは困難です。
type NoneToNull<V extends Option<unknown>> = V extends Some<unknown> ? V : null;
ただ、この「型が型変数かどうかで挙動が変わる」という性質は非直感的でたいへん厄介ですから、しっかり覚えておきましょう。
mapped typeのunion distribution
実は、mapped typeもunion型を分配します。分配が発生する条件は条件型のときよりも複雑で、以下の形のmapped typeでT
が型変数のときに、T
にunion型が入ると分配されます。(X
は何らかの型で、型変数でなくても構いません。)
{[P in keyof T]: X}
実際にやってみましょう。下で定義するArrayify<T>
はT
の全てのプロパティを配列化するmapped typeです。
type Arrayify<T> = {[P in keyof T]: Array<T[P]>};
type Foo = { foo: string };
type Bar = { bar: number };
type FooBar = Foo | Bar;
// FooBarArrはunion型が分配されてArrayify<Foo> | Arrayify<Bar>になる
type FooBarArr = Arrayify<FooBar>;
const val1: FooBarArr = { foo: ['f', 'o', 'o'] };
const val2: FooBarArr = { bar: [0, 1, 2, 3, 4] };
// ↓これはArraify<Foo>でもArraify<Bar>でもないのでエラー
const val3: FooBarArr = {};
mapped typeが分配されていることが分かりました。
試しに、以下のようにArraify<T>
を介さずにmapped typeを使ってみると結果が変わります。これにより、今回もやはり型変数が条件になっていることが分かります。
type Foo = { foo: string };
type Bar = { bar: number };
type FooBar = Foo | Bar;
// FooBarArrは{}になる
type FooBarArr = {[P in keyof FooBar]: Array<FooBar[P]>};
// ↓これがエラーにならない!
const val1: FooBarArr = {};
上の例では、keyof FooBar
は"foo" & "bar"
という型になります。これは"foo"
でありかつ"bar"
であるという意味であり、そんな値は存在しないのでnever
になってほしいですが、どうもならないようです。とにかく、Foo
とBar
に共通する名前のプロパティは存在しないため、FooBar
に存在する(確実に存在すると言える)プロパティはありません。よってkeyof FooBar
に当てはまるようなP
が無いためFooBarArr
は{}
と計算されます。
このようなmapped typeによるunionの分配は、in
の右側がkeyof T
(しかもT
は型変数)でT
にunion型が入るという条件を満たすときにのみ発生します。狙ってやるなら問題ありあませんが、条件型のunion distributionよりこころなしか知名度が低いような気もしますので、ときには罠と化すことがあるかもしれません。
[P in keyof T]
という形そのものはT
のプロパティを全部マップするときに使う頻出の形です。この形が出てきたときはT
がunion型だったらどうなるかなということに思いを馳せる必要があるでしょう。
mapped typeと配列型
上と同じ[P in keyof T]
という形で、T
に配列の型(タプル型も含む)が入る場合も、やはり特別な挙動をします。
そもそも、配列の型にmapped typeを適用するとどうなるでしょうか。恐らく、とりあえず思い浮かべる挙動は、配列の要素の型がマップされるというものだと思います。まずは、敢えて特別な挙動を避けつつ配列の型をマップしてみましょう。StrArr
型はNumArr
のプロパティの型を文字列に変更した型を返します。
type NumArr = number[];
type StrArr = { [P in keyof NumArr]: string };
// StrArr型の変数aを宣言
declare const a: StrArr;
const _: string = a[0];
この例では、in
の右がkeyof NumArr
になっていますが、NumArr
は型変数ではなく具体的な型なので特別な挙動は発生しません。ちゃんと、StrArr
型の配列の要素は文字列になっていますね。
では、配列なのでforEachでループを回してみましょう。
type NumArr = number[];
type StrArr = { [P in keyof NumArr]: string };
// StrArr型の変数aを宣言
declare const a: StrArr;
// エラー: Cannot invoke an expression whose type lacks a call signature. Type 'String' has no compatible call signatures.
a.forEach(val => {
console.log(val);
});
おや……?
そう、mapped typeにより全てのプロパティが文字列にマップされたため、本来関数型のはずの、配列が持つプロパティforEach
の型もstring
にされてしまったのです。
確かにそういうコードを書いたとはいえ、こんなのはさすがに使いものになりませんね。
ということで、この事態を避ける機構がmapped typeには組み込まれています。具体的には、[P in keyof T]
で型変数T
の型が配列だった場合に、全てのプロパティをマップするのではなく要素の型のみをマップしてくれるのです。ではやってみましょう。
// すべてのプロパティをstringにする型関数
type Strify<T> = {[P in keyof T]: string};
type NumArr = number[];
// StrArrはstring[]型になる
type StrArr = Strify<NumArr>;
const arr: StrArr = ['foo', 'bar'];
arr.forEach(val => console.log(val));
今度はちゃんとStrArr
がstring[]
型となり、forEachなどのメソッドはそのまま使えるようになっています。
ポイントは、Strify<T>
はごくふつうのmapped typeで定義されており、オブジェクトの型に対してもそのまま使えるという点です。配列を特別扱いしなくても、いい感じにマップしてくれるというわけですね。
ちなみに、この機能がT
が型変数の場合に制限されているのは、そうしないとマップ後の配列の要素の型を正しく求められない場合があるからでしょう。{[P in keyof T]: X}
という型(X
は型変数とは限らない任意の型)で配列型U[]
をマップした場合、要素の型はX
内のT[P]
をU
で置換したものになります。この形のmapped typeならば、要素の型をマップする際にX
の内部にもともとの要素の型はシンタクティックにT[P]
という形で現れるためそれをU
に置換すればいいわけです(ほかにT[number]
とかもあるかもしれませんが、それは置換せずそのままでOKです)。T
が一般の型になってしまうとこのような変換が難しくなります。
また、配列型と述べましたが、タプル型も配列型の一種ですので同じルールが適用されます。
// すべてのプロパティを配列にする型関数
type PropArrify<T> = {[P in keyof T]: Array<T[P]>};
type MyTuple = [string, number, boolean];
// T1は [string[], number[], boolean[]]になる
type T1 = PropArrify<MyTuple>;
const t: T1 = [ ['f', 'o', 'o'], [], [true, false]];
// lengthなどの要素以外のプロパティはそのままになっている
console.log(t.length);
readonly
配列型との変換
Mapped typeでは、プロパティをreadonly
化したりすることもできました。配列に対して全てのプロパティをreadonly
化する操作を行った場合、ちゃんとreadonly
配列やタプルの型が発生します。もちろん、上記の場合と同じように[P in keyof T]
という形が必要です。
ちょっと話題を先取りしますが、TypeScriptには以下で定義されるReadonly<T>
型が標準で備わっています。これを使って試してみましょう。
type Readonly<T> = {readonly [P in keyof T]: T[P]};
下の例のように、Readonly<T>
の型変数T
に配列やタプルの型を入れると、readonly
配列やreadonly
タプルの型になります。
type T1 = number[];
// T2 は readonly number[] 型
type T2 = Readonly<T1>;
// T3 は readonly [string, number, string] 型
type T3 = Readonly<[string, number, string]>;
逆に{-readonly [P in keyof T]: T[P]}
によってプロパティからreadonly
を取り除くことができるのはご存知の通りですが、これをreadonly
配列型に適用するとやはり普通の配列型になります。
標準ライブラリの型
さて、ここまででconditional typesとmapped types、そしてunion型(あと配列)にまつわる細かい挙動を見てきました。特にunion型に対して例外的な挙動があったり、型変数を含んだ特定の形であるかどうかを気にしないといけなかったりと煩雑ですが、これによりTypeScriptの型システム(特にunion型)が便利なものになっているという側面もあります。
そのような便利さというのは、実は我々が直接conditional typesなどと弄れなくても標準ライブラリという形で提供されています。簡単な操作なら、標準ライブラリで提供されている型を用いて行うことができるでしょう。ここでは、標準ライブラリに存在する型を紹介します。標準ライブラリに存在するということは、特に何もせずともいきなり使ってよいということです。
なお、これらの型はlib.es5.d.tsで定義されており、以下で示す定義は同ソースコード(TypeScript v3.1.3)からの転載であり、Apache License 2.0に従います。
Record<K, T>
/**
* Construct a type with a set of properties K of type T
*/
type Record<K extends keyof any, T> = {
[P in K]: T;
};
Record<K, T>
型は、辞書として使いたいオブジェクトの型に適しています。例えばRecord<string, number>
は、任意の文字列型のキー(プロパティ名)に対してnumber
型の値を持ったオブジェクトです。
const dict: Record<string, number> = {};
dict.foo = 123;
dict.bar = 456;
console.log(dict.foo + dict.baz);
ただ、上の定義だと、存在しないキーがundefinedを返す可能性を無視していることに注意してください。これが気に入らない場合はRecord<string, number | undefined>
のように値がundefinedである可能性を明示するか、Map
を使うようにします。この型はそのような危険性よりも利便性を重視する場合に使われる型であるという印象です。
また、K
の制約にあるK extends keyof any
に注意してください。TypeScriptでは、オブジェクトのキー(プロパティ名)として使用できる型は決まっており、具体的にはstring | number | symbol
です。ですから、keyof 型
という型はかならずstring | number | symbol
の部分型になります。keyof any
はキーとしてありえる全ての型となり、やはりstring | number | symbol
になります。最初からstring | number | symbol
と書いてもよいですが、将来的にキーになり得る型が追加されたときのためかこれをベタ書きするのは避けてkeyof any
としているようです1。
Partial<T>
, Required<T>
, Readonly<T>
/**
* Make all properties in T optional
*/
type Partial<T> = {
[P in keyof T]?: T[P];
};
/**
* Make all properties in T required
*/
type Required<T> = {
[P in keyof T]-?: T[P];
};
/**
* Make all properties in T readonly
*/
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
普通にmapped typeを使ってプロパティの性質を操作するだけの型です。Partial<T>
は結構よく使います。
Pick<T, K>
/**
* From T pick a set of properties K
*/
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
この型定義を見て何をやっているのか分かればTypeScript入門レベルは問題なく理解できていると言えるかもしれません。
これは、与えられたオブジェクトの型T
のプロパティのうち、K
で与えられた名前のプロパティのみを残したような型を返す型関数です。
interface MyObj {
foo: number;
bar: string;
baz: boolean;
}
type T1 = Pick<MyObj, 'foo' | 'bar'>
/*
* T1の型は
* {foo: number; bar: string}
*/
残すべきプロパティ名をリテラル型のunion型で与えます。こういうところにTypeScriptにおけるunion型の重要性が見え隠れしています。
このPick<T, K>
型は既存の型をちょっといじった新しい型を作りたい場合によく使います。
Exclude<T, U>
, Extract<T, U>
/**
* Exclude from T those types that are assignable to U
*/
type Exclude<T, U> = T extends U ? never : T;
/**
* Extract from T those types that are assignable to U
*/
type Extract<T, U> = T extends U ? T : never;
これは何をやっているのか分かるでしょうか。これが分かれば、今回の話もだいぶ理解できています。
これは、conditional typeにおけるunion distributionを前提とした型です。T
に何らかのunion型が入ったとき、Extract<T, U>
は、その構成要素のうちU
の部分型であるもののみを残します。
具体例を見ましょう。
type T1 = 'foo' | 'bar' | 'baz' | 0 | 2 | 4 | false;
// T2は 'foo' | 'bar' | 'baz' 型になる
type T2 = Extract<T1, string>;
T1
はいろいろな型のunion型ですが、T2
はそれらのうち文字列であるもの、すなわちstring
の部分型であるものだけを残したunion型になっています。
こうなる理由は、この記事に書いてあることで説明できます。Extract<T1, string>
の計算においては、ExtractT, U>
の中のT extends U
のT
にunion distributionが適用され、
('foo' extends string ? 'foo' | never) | ('bar' extends string ? 'bar' | never) | ...
のように分配されます。その結果は、'foo' | 'bar' | 'baz' | never | never | never | never
となります。never
は属する値が無い型ですから、union型の中では消えます。よって'foo' | 'bar' | 'baz'
が残ったのです。
今回の説明はExtract<T, U>
を使いましたが、Exclude<T, U>
も同様の動作をします。違いは、Exclude<T, U>
は逆にU
の部分型であるものが除かれてそれ以外が残る点です。Exclude<T1, string>
は0 | 2 | 4 | false
型になるでしょう。
これらの型は、union型で代数的データ型っぽいことをやっている場合も役に立ちます。次のような型を考えます。
type MyData =
| {
type: 'foo';
fooValue: string;
}
| {
type: 'bar';
barValue: number;
}
| {
type: 'baz';
};
MyData
はtype
プロパティをタグとして、3種類の値からなる代数的データ型っぽい型です。
このうち、'foo'
はもう処理したので残りは'bar'
か'baz'
であるという状況を表現するために、MyData
からtype
が'foo'
であるものを除いた残り2種類のunion型を作りたいことがあります。このとき、union型の定義をもう一度書き直すのは無駄ですね。Exclude<T, U>
を使って次のように表現できます。
type T1 = Exclude<MyData, { type: 'foo' }>;
// T1は { type: 'bar'; barValue: number } | { type: 'baz' } 型
特に、U
にあたる部分が{type: 'foo'}
でいいのが特徴的で、{type: 'foo'; fooValue: string}
のようにフルに書き下さずに必要最低限の条件で済んでいます。これは条件型の条件判定がextends
、すなわち部分型関係を使っているからですね。
他の使い方としては、Pick<T, K>
と組み合わせて、T
から特定のキーだけ取り除くという使い方ができます。
interface MyObj {
foo: number;
bar: string;
baz: boolean;
}
// T1は {foo: number; bar: string} 型
type T1 = Pick<MyObj, Exclude<keyof MyObj, 'baz'>>;
これはなかなかよく使うので、これにOmit<T, K>
みたいな名前を付けることも結構あるようです。
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
NonNullable<T>
/**
* Exclude null and undefined from T
*/
type NonNullable<T> = T extends null | undefined ? never : T;
これはExclude<T, U>
のU
がnull | undefined
になった版です。
Parameters<T>
, ReturnType<T>
/**
* Obtain the parameters of a function type in a tuple
*/
type Parameters<T extends (...args: any[]) => any> = T extends (...args: infer P) => any ? P : never;
/**
* Obtain the return type of a function type
*/
type ReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : any;
この2つは関数に関わる型です。T extends (...args: any[]) => any
という条件はT
が関数の型でなければいけないということを言っています。
T
が関数のとき、Parameters<T>
はT
の引数の型一覧をタプル型の形で得ることができます。ReturnType<T>
はT
の返り値の型となります。
type F = (arg1: string, arg2: number) => string;
// T1は[string, number]型
type T1 = Parameters<F>;
// T2はstring型
type T2 = ReturnType<F>;
この2つの型は関数の型をごちゃごちゃといじりたい場合に便利ですね。
省略しますが、new
で呼び出す場合用に同じことができるConstructorParameters<T>
とInstanceType<T>
もあります。
以上が標準ライブラリで提供されている主な型です。特に、条件型のunion distributionを駆使したExtract<T, U>
やExclude<T, U>
の定義が特徴的ですね。これらとunion型を使いこなしてTypeScriptプログラムを書くことができてばTypeScriptの型初級は卒業でしょう。
その他のトピック
ここからは、TypeScriptの型入門からは漏れてしまったTypeScriptの型に関する雑多な話題を紹介します。
関数のオーバーロード
TypeScriptでは、関数のオーバーロードを定義することができます。とはいえ、変換先のJavaScriptにその機能はありませんから、オーバーロードが書けるのは型だけです。型でオーバーロードが定義されている関数func
を書いてみます。
function func(arg1: number): number;
function func(arg1: string, arg2: number): number;
function func(arg1: number | string, arg2?: number): number {
if ('number' === typeof arg1) {
return arg1;
} else {
return Number.parseInt(arg1) + arg2!;
}
}
func(1);
func("123", 321);
// ↓エラー
func("123");
これはfunction宣言が連続していてちょっと読みにくいですね。関数func
の宣言は、型の宣言と本体の宣言の2つの部分に分けることができます。よく見ると、最初の2行は普通の関数宣言ではなく、関数本体が無い形ですね。これはTypeScriptに特有の形で、この形を複数並べることにより関数のオーバーロードを宣言します。この例では2つのオーバーロードが宣言されており、1つ目は数値を受け取って数値を返す関数、もう1つは文字列と数値を受け取って数値を返す関数です。
そして、オーバーロードの宣言に伴って、関数本体の宣言を書く必要があります。TypeScriptにおける関数のオーバーロードの特徴としては、関数の本体を1つしか書くことができないということです2。なので、関数本体の宣言では、型はオーバーロードされた関数の全てに当てはまる包含的な型でなければいけません。
つまり、この関数func
においては、1つ目の引数の型はnumber
かもしれないしstring
かもしれないので、関数本体の宣言においてはその両方を受け入れるnumber | string
とします。第2引数はnumber
ですが、1つ目のオーバーロードが採用された場合は第2引数が無いので、無いかもしれないことを表すためにarg2?: number
とします。?
をつけたことでarg2
の型はnumber | undefined
となります。
関数内部では、自分で引数の型を調べてそれに合わせて適切な実装を選択する必要があります。else側のarg2!
が目に付きますが、この!
後置演算子はTypeScript独自のもので、型から強制的にnull
やundefined
を取り除くダウンキャストの演算子です。arg1
が文字列であると判明した時点でオーバーロードの2番目が選択されていることは明らかなので、arg2
がundefinedでないことは分かるのですが、残念ながらTypeScriptはそこまで賢くないのでarg2
がundefinedにならないことを見抜くことはできません。そのために、自分でアノテートしています。
このような使いにくさがあるので自分はあまりオーバーロードされた関数定義は好きではないのですが、在野のJavaScriptライブラリに型を付ける際によく登場します。
なお、オブジェクト型を用いて関数の型を宣言するときにも、同様に関数シグネチャを複数並べることでオーバーロードされた関数の型を表現できます。例えば、先ほど定義した関数の型を書くとこのようになります。
interface MyFunc {
(arg1: number): number;
(arg1: string, arg2: number): number;
}
this
JavaScriptはオブジェクト指向言語なので、メソッドの中ではthis
を使うことができます。このことをTypeScriptで表現するためのいろいろな工夫があります。
まず、実は関数定義や関数の型を書くときに、this
の型を明示することができます。ややこしいことに、this
の型は最初の引数に書いて明示しますが、これは本当の引数ではないため呼ぶときにはthis
の値を引数として渡すわけではありません。下の例で定義するfunc
の引数はarg
の1つだけです。
type MyObj = { foo: number };
function func(this: MyObj, arg: number): number {
return this.foo + arg;
}
const obj1 = {
foo: 12345,
func,
};
const obj2 = {
func,
};
// funcを普通に呼ぶとthisの型が違うのエラー
func(100);
// obj1.funcとして呼ぶとthisはobj1 (MyObj型)なのでOK
obj1.func(100);
// obj2.funcとして呼ぶとthisがobj2でMyObj型でないのでエラー
obj2.func(100);
ここで定義したfuncは、this
の型がMyObj
でないといけないような関数型を持ちます。ですから、普通に呼んだりthis
が違う状況で呼ぶとエラーになります。上の例では、obj1.func
として呼ぶ場合のみOKです。
ただ、こういう風にthis
を使っている場面は実際のところあまり見ません。this
はどちらかというとcontextualな型推論のために使われることが多いように思います。jQueryに代表されるような一部のライブラリでは、(今はどうなのかよく分かりませんが少なくとも昔は)積極的にコールバック関数内でのthis
の値を書き換えてきます。そのような関数の中においてthis
の型を正しく推論させるためにthis
の型指定を使うことができます。
type MyObj = { foo: number };
function callWithThis(func: (this: MyObj) => void): void {
func.call({ foo: 42 });
}
// ↓このコールバック関数の中ではthisがMyObj型を持つと推論される
callWithThis(function(){ console.log(this.foo); });
この例では、関数callWithThis
の引数が関数であり、その型においてthis
がMyObj
であると指定されています。callWithThis
の引数である無名関数の型はその型に当てはめられることになりますから、その中ではthis
はMyObj
型となるのです。このようにすることで、callWithThis
を使う側ではコールバック関数内でのthis
の型を自分で指定しなくてもカスタマイズされたthis
をうまく利用することができます3。
this
型
this
に関わるTypeScriptの型としては、そのままずばりのthis
型という型があります。これは、クラス(やインターフェース)のメソッド内で使うことができる特殊な型です。例として、自分をコピーするメソッドclone
を持ったクラスを考えます。
class MyClass {
constructor(public foo: number) { }
public clone(): MyClass {
return new MyClass(this.foo);
}
}
MyClass
のコンストラクタは1つの引数を持ちます。引数にpublic
とついていますが、このすると与えられた引数がそのままパブリックなプロパティfoo
に代入されます(これはTypeScriptの独自機能です)。
まあそこは本題ではなく、本題はclone()
メソッドです。これは自身と同じ新しいMyClassオブジェクトを返すメソッドです。そうなると、返り値の型は当然MyClass型です。
ここまでは問題ありませんが、このMyClassを継承した新しいクラスを作るときに少し困ります。
class MySubClass extends MyClass {
}
このままではMySubClass
インスタンスのclone()
を呼び出すとMySubClass
オブジェクトではなくMyClass
オブジェクトが返ることになります。MySubClass
の定義にオーバーライドされたclone()
の定義を書きなおしてもよいですが、それは何だかスマートではありませんね。JavaScript的な解決策はこうです。(もちろん、常にこんな適当な実装で事が済むわけではないと思いますが。)
class MyClass {
constructor(public foo: number) { }
public clone(): MyClass {
return new (this.constructor as any)(this.foo);
}
}
インスタンスはconstructor
プロパティに自身のコンストラクタを持っています。ですので、それを持ってきてnewすればいいのです(TypeScriptがあまりかしこくないのでanyでむりやり何とかしていますが、まあ仕方ないのでそういうものだと思いましょう)。MyClassのインスタンスの場合はthis.constructor
はMyClassになり、MySubClassインスタンスの場合はMySubClassになります。
これでJavaScript的にはOKですが、TypeScript的にはまだ問題があります。返り値の型がMyClass
で固定なのです。MySubClass
のclone()
を呼び出したときはちゃんと返り値がMySubClass
型になってほしいですね。
もうお分かりかと思いますが、返り値をthis
にすればこれができます。this
型は、文字通りthis
の型になります。今回は常にインスタンスの型と同じ型のオブジェクトが返されると考えてこのようにすればOKです。
class MyClass {
constructor(public foo: number) { }
public clone(): this {
return new (this.constructor as any)(this.foo);
}
}
完成形を見てもなんか無理やりだなと思った読者の方がいるかもしれませんが、this
型を使わざるを得ない時点でそもそもわりと無理やりであるという説もあります。
カスタム型ガード
if文とtypeof
等を組み合わせることによって、型の絞り込みを行うことができるのはご存知の通りです。実は、型の絞り込みを行う用の関数を自分で定義することができます。さっそくですが例を見てください。
type FooObj = { foo: number };
function isFooObj(arg: any): arg is FooObj {
return arg != null && 'number' === typeof arg.foo;
}
function useFoo(arg: unknown) {
if (isFooObj(arg)) {
// この中ではargはFooObj型
console.log(arg.foo);
}
}
useFoo(123);
useFoo({foo: 456});
まず注目すべきはisFooObj
の返り値の型です。arg is FooObj
という型なのか何なのかよくわからんものになっていますが、これがカスタム型ガードです。構文は引数名 is 型
で、その実態は真偽値です。返り値がtrueのときその引数はその型を持つことを保証するという意味になります。
FooObj
はnumber
型のプロパティfoo
を持つオブジェクトの型なので、isFooObj
ではarg
がその条件を満たすことを確かめています。
次にuseFoo
を見ましょう。この関数では、引数arg
の型をunknown
としています4。
arg
の型をFooObj
型として使いたいのですが、そのためにはまずarg
がFooObj
型かどうか確かめる必要があります。そこで、if文の中で先ほど定義したisFooObj
を呼び出します。この形でisFooObj
を使うことによって、その中ではarg
の型がFooObj
型に狭められるのです。
これも使いどころが沢山あるわけではないのですが、標準ライブラリ中のArray.isArray
の定義にも使われています。
interface ArrayConstructor {
new(arrayLength?: number): any[];
new <T>(arrayLength: number): T[];
new <T>(...items: T[]): T[];
(arrayLength?: number): any[];
<T>(arrayLength: number): T[];
<T>(...items: T[]): T[];
isArray(arg: any): arg is Array<any>;
readonly prototype: Array<any>;
}
まとめ
前回の記事TypeScriptの型入門は入門ということで型の説明に終始しましたが、この記事ではunion distributionを代表とするような、実用上理解しておく必要がある型の挙動を説明し、さらに標準ライブラリの有用な型もいくつか紹介しました。これらの知識を駆使して実用レベルでTypeScriptの力強い型システムを活かしていただきたいと思います。
特に、繰り返しになりますがTypeScriptの型システムにおいてはunion型が強力で、union型の利用を支援する機構も今回見たように整っています。もはやunion型が無いころのTypeScriptは何だったのかというレベルです。積極的にunion型を使っていきましょう。
また、入門レベルからのステップアップということで、記事の後半ではTypeScriptの型に関する少しマイナーな機能をいくつか紹介しました。マイナーというだけあって見る機会も使う機会も多くありませんが、ライブラリの型定義などを書くようになるとお世話になるかもしれません。
次のステップ
「TypeScriptの型中級」はまだ書いていませんが、その代わりに入門・初級をマスターしたみなさんが次に読むと良い記事をご案内します。
- TypeScriptの型演習 演習問題ができました。力試しに解いてみましょう。
- TypeScriptの型推論詳説 型推論を詳しく理解したい方はこちら。
-
TypeScriptのreadonlyプロパティを使いこなす
readonly
について深掘りする記事です。 - TypeScriptで超型安全なBuilderパターン TypeScriptの型の応用例です。
- TypeScriptで配列が指定した要素を全部持つか調べる方法 TypeScriptの型の応用例です。
- TypeScriptで最低一つは必須なオプションオブジェクトの型を作る これも応用例です。
- TypeScriptで最低n個の要素を持った配列の型を宣言する方法 これまた応用例です。
- TypeScriptの型レベル連結リスト活用術:型を変えられるコンテナを作る さらに応用例です。
-
実際、TypeScript 2.9より前は
keyof any
はstring
になっていました。 ↩ -
これは、TypeScriptからJavaScriptに変換するときに型に依存してはいけないという制限によるものだと考えられます。引数の型を調べてどの実装を使うか選択するみたいな実装をTypeScriptが出力するわけにはいかないので、1つの実装で全てやる必要があります。 ↩
-
ただ、この無名関数を別の名前の関数としてくくり出すような場合は、その引数が
callWithThis
の引数として使われるという情報がないため、this
の型を明示する必要があります。この辺はTypeScriptの型推論の弱い部分ですね。多分そういうデザインなのだと思いますが。 ↩ -
本当は
isFooObj
の引数の型もunknown
にしたかったのですが、型エラーになってしまうのでちょっとずるいですがany
にしています。まあ、型についての保証をする関数なのでその中身は自分で気をつけて書きましょうということです。 ↩