TypeScriptは型がついたJavaScriptです。プログラミングにおいて型があることの恩恵は大きく、近頃AltJSの代表格として人気を集めています。TypeScriptはもともと型のないJavaScriptで書かれるコードに型を付けることを使命としていることもあり、たまに変な型が追加されます。TypeScript2.8で予定されているconditional typesもその一つです。これによってTypeScriptの型システムの表現力が広がることが期待されていますが、一方でTypeScriptを書いている人の中には、一部の人たちが長くてよく分からない型定義を書いて喜んでいるというような印象を持っている方もいるのではないでしょうか。健全にJavaScriptを書いていれば、自分でそのような変な型を書くことはあまり多くありません。
そこで、この記事ではTypeScriptの型について初歩から解説します。対象読者はTypeScriptを使っているけど型のことはよく分からない人やTypeScriptを使っていないけどTypeScriptの型システムに興味がある人などです。なお、型入門なのでTypeScriptの文法などは解説しません。具体的な文法などを知らなくても理解できるようにしていますが、文法を知りたいという方は先に他の入門記事を読むことをおすすめします。
プリミティブ型
TypeScriptにおいて一番基本となる型はプリミティブ型です。これはJavaScriptのプリミティブの型と対応しており、string, number, boolean, symbol, null, undefinedがあります。これらは互いに異なる型であり、相互に代入不可能です。
const a: number = 3;
const b: string = a; // エラー: Type 'number' is not assignable to type 'string'.
ただし、コンパイラオプションで--strictNullChecksをオンにしていない場合は、nullとundefinedは他の型の値として扱うことができます。
const a = null;
const b: string = a; // strictNullChecksがオンならエラー
逆に言うと、--strictNullChecksをオンにしないとundefinedやnullが紛れ込む可能性があり危険です。
リテラル型
プリミティブ型を細分化したものとしてリテラル型があります。リテラル型には文字列のリテラル型と数値のリテラル型と真偽値のリテラル型があります。それぞれ、'foo'や3やtrueというような名前の型です。
お分かりのように、'foo'というのは'foo'という文字列しか許されない型です。同様に、0というのは0という数値しか許されない型になります。
const a: 'foo' = 'foo';
const b: 'bar' = 'foo'; // エラー: Type '"foo"' is not assignable to type '"bar"'.
文字列のリテラル型はstringの部分型であり、文字列のリテラル型を持つ値はstring型として扱うことができます。他の型も同様です。
const a: 'foo' = 'foo';
const b: string = a;
リテラル型と型推論
上の例では変数に全て型注釈を付けていましたが、これを省略してもちゃんと推論されます。
const a = 'foo'; // aは'foo'型を持つ
const b: 'bar' = a; // エラー: Type '"foo"' is not assignable to type '"bar"'.
これは、'foo'というリテラルの型が'foo'型であると推論されることによります。変数aは'foo'が代入されているので、aの型も'foo'型となります。
ただし、これはconstを使って変数を宣言した場合です。constは変数に再代入されないことを保証するものですから、aにはずっと'foo'が入っていることが保証され、aの型を'foo'とできます。一方、letやvarを使って変数を宣言した場合は変数をのちのち書き換えることを意図していると考えられますから、最初に'foo'が入っていたからといって変数の型を'foo'型としてしまっては、他の文字列を代入することができなくて不便になってしまいます。そこで、letやvarで変数が宣言される場合、推論される型はリテラル型ではなく対応するプリミティブ型全体に広げられます。
let a = 'foo'; // aはstring型に推論される
const b: string = a;
const c: 'foo' = a; // エラー: Type 'string' is not assignable to type '"foo"'.
上の例では、aはletで宣言されているのでstring型と推論されます。そのため、'foo'型を持つcに代入することはできなくなります。
なお、letで宣言する場合も型注釈をつければリテラル型を持たせることができます。
let a: 'foo' = 'foo';
a = 'bar'; // エラー: Type '"bar"' is not assignable to type '"foo"'.
オブジェクト型
JavaScriptの基本的な概念としてオブジェクトがあります。オブジェクトは任意の数のプロパティがそれぞれ値を保持している構造です。もちろん、TypeScriptにはオブジェクトを表現するための型があります。これは、{ }の中にプロパティ名とその型を列挙するものです。
例えば{foo: string; bar: number}という型は、fooというプロパティがstring型の値を持ちbarというプロパティがnumber型の値を持つようなオブジェクトの型です。
interface MyObj {
foo: string;
bar: number;
}
const a: MyObj = {
foo: 'foo',
bar: 3,
};
なお、この例でinterfaceという構文が出てきましたが、これはTypeScript独自の構文であり、オブジェクト型に名前を付けることができます。この例では、{foo: string; bar: number}という型にMyObjという名前を付けています。また、分かりやすくするためにconstに型注釈を付けていますが、型注釈をしなくてもオブジェクト型を推論してくれます。
もちろん、型が合わないオブジェクトを変数に代入したりしようとすると型エラーとなります。下記の例では、aに代入しようとしているオブジェクトはbarプロパティの型が違うためエラーになり、bに代入しようとしているオブジェクトはbarプロパティが無いためエラーとなります。
interface MyObj {
foo: string;
bar: number;
}
// エラー:
// Type '{ foo: string; bar: string; }' is not assignable to type 'MyObj'.
// Types of property 'bar' are incompatible.
// Type 'string' is not assignable to type 'number'.
const a: MyObj = {
foo: 'foo',
bar: 'BARBARBAR',
};
// エラー:
// Type '{ foo: string; }' is not assignable to type 'MyObj'.
// Property 'bar' is missing in type '{ foo: string; }'.
const b: MyObj = {
foo: 'foo',
};
JavaScriptではオブジェクトは自由に書き換えることができます。プロパティの書き換えはもちろん、プロパティを作ったり消したりすることもできます。しかし、TypeScriptではそのような操作は型によって制限されます。そうでないと型を導入した意味がありませんね。
一方、TypeScriptでは構造的部分型を採用しているため、次のようなことが可能です。
interface MyObj {
foo: string;
bar: number;
}
interface MyObj2 {
foo: string;
}
const a: MyObj = {foo: 'foo', bar: 3};
const b: MyObj2 = a;
MyObj2というのはfooプロパティだけを持つオブジェクトの型ですが、MyObj2型変数にMyObj型の値aを代入することができています。MyObj型の値はstring型のプロパティfooを持っているためMyObj2型の値の要件を満たしていると考えられるからです。ちなみに、一般にこのような場合MyObjはMyObj2の部分型であると言います。
ただし、オブジェクトリテラルに対しては特殊な処理があります。次のような場合には注意してください。
interface MyObj {
foo: string;
bar: number;
}
interface MyObj2 {
foo: string;
}
// エラー:
// Type '{ foo: string; bar: number; }' is not assignable to type 'MyObj2'.
// Object literal may only specify known properties, and 'bar' does not exist in type 'MyObj2'.
const b: MyObj2 = {foo: 'foo', bar: 3};
変数bに代入しようとしている{foo: 'foo', bar: 3}はfooプロパティがstring型を持つため、先ほどの説明からすれば、barプロパティが余計であるもののMyObj2型の変数に代入できるはずです。しかし、オブジェクトリテラルの場合は余計なプロパティを持つオブジェクトは弾かれてしまうのです。
これは、多くの場合余計なプロパティを持つオブジェクトリテラルを意図的に用いることが少なく、ミスである可能性が高いからでしょう。実際、TypeScriptの型システムを順守している限り、このような余計なプロパティは存在しないものと扱われるためアクセスする手段が無く、無駄です。どうしてもこのような操作をしたい場合はひとつ前の例のように別の変数に入れることになります。一度値を変数に入れるだけで挙動が変わるというのは直感的ではありませんが、TypeScriptでは入口だけ見ていてやるからあとは自己責任でということなのでしょう。
なお、上の例では変数bがMyObj2型であることを明示していましたが、関数引数の場合でも同じ挙動となります。
interface MyObj2 {
foo: string;
}
// エラー:
// Argument of type '{ foo: string; bar: number; }' is not assignable to parameter of type 'MyObj2'.
// Object literal may only specify known properties, and 'bar' does not exist in type 'MyObj2'.
func({foo: 'foo', bar: 3});
function func(obj: MyObj2): void {
}
オブジェクト型についてはまだ紹介すべきことが色々とありますが、都合上いったん後回しにします。
関数型
JavaScriptの、というより大抵のプログラミング言語において重要な概念として関数があります。TypeScriptにも当然ながら関数の型、すなわち関数型があります。関数型は例えば(foo: string, bar: number)=> booleanのように表現されます。これは、第1引数としてstring型の、第2引数としてnumber型の引数をとり、返り値としてboolean型の値を返す関数の型です。型に引数の名前が書いてありますが、これは型の一致等の判定には関係ありません。よって、(foo: number)=> string型の値を(arg1: number)=> string型の変数に代入するようなことは問題なく行えます。
function宣言などによって作られた関数にもこのような関数型が付きます。
const f: (foo: string)=> number = func;
function func(arg: string): number {
return Number(arg);
}
関数型に対しても普通の部分型関係があります。
interface MyObj {
foo: string;
bar: number;
}
interface MyObj2 {
foo: string;
}
const a: (obj: MyObj2)=>void = ()=>{};
const b: (obj: MyObj)=>void = a;
この例に見えるように、(obj: MyObj2)=>void型の値を(obj: MyObj)=>void型の値として扱うことができます。これは、MyObjはMyObj2の部分型なので、MyObj2を受け取って処理できる関数はMyObjを受け取っても当然処理できるだろうということです。aとbの型を逆にすると当然エラーになります。1
また、関数の場合、引数の数に関しても部分型関係が発生します。
const f1: (foo: string)=>void = ()=>{};
const f2: (foo: string, bar: number)=>void = f1;
このように、(foo: string)=>void型の値を(foo: string, bar: number)=>void型の値として使うことができます。すなわち、引数を1つだけ受け取る関数は、引数を2つ受け取る関数として使うことが可能であるということです。これは、関数の側で余計な引数を無視すればいいので自然ですね。
ただし、関数を呼び出す側で余計な引数を付けて呼び出すことはできないので注意してください。これは先のオブジェクトリテラルの例と同じくミスを防止するためでしょう。
const f1: (foo: string)=>void = ()=>{};
// エラー: Expected 1 arguments, but got 2.
f1('foo', 3);
void型
先ほどから何気なくvoidという型が出てきていますので、これについて解説します。この型は主に関数の返り値の型として使われ、「何も返さない」ことを表します。
JavaScriptでは何も返さない関数(return文が無い、もしくは返り値の無いreturn文で返る)はundefinedを返すことになっていますので、void型というのはundefinedのみを値にとる型となります。実際、void型の変数にundefinedを入れることができます。ただし、その逆はできません。すなわち、void型の値をundefined型の変数に代入することはできません。
const a: void = undefined;
// エラー: Type 'void' is not assignable to type 'undefined'.
const b: undefined = a;
この挙動は、void型を返す関数というのはあくまで何も返さない関数なのだから、その値を利用することはできないという意図があると思われます。
void型の使いどころは、やはり関数の返り値としてです。何も返さない関数の返り値の型としてvoid型を使います。void型はある意味特殊な型であり、返り値がvoid型である関数は、値を返さなくてもよくなります。逆に、それ以外の型の場合(any型を除く)は必ず返り値を返さなければいけません。
function foo(): void {
console.log('hello');
}
// エラー: A function whose declared type is neither 'void' nor 'any' must return a value.
function bar(): undefined {
console.log('world');
}
なお、大して意味はありませんが、undefinedをvoid型の値として扱うことができるので、void型を返す関数にreturn undefined;と書くことができます。
any型
ここで、any型という言葉が出てきましたので、これについても解説します。any型は何でもありな型であり、プログラマの敗北です。
any型の値はどんな型とも相互変換可能であり、実質TypeScriptの型システムを無視することに相当します。
const a: any = 3;
const b: string = a;
この例では、変数aはany型の変数ですので、どんな値でも代入可能です。また、any型の値はどんな型の値としても利用可能ですので、any型の値をもつaをstring型の変数bに代入することができます。
上のプログラムを見ると最終的に数値がstring型の値に入ってしまっています。このように、any型を使うと型システムを欺くことが可能であり、せっかくTypeScriptを使っている意味が無くなってしまいます。ですので、any型はやむを得ない場面でのみ使用するのがよいでしょう。
配列型
配列はオブジェクトの一種ですが、配列の型を表すために特別な文法が用意されています。配列の型を表すためには[]を用います。例えば、number[]というのは数値の配列を表します。
const foo: number[] = [0, 1, 2, 3];
foo.push(4);
また、TypeScriptにジェネリクスが導入されて以降はArray<number>と書くことも可能です。ジェネリクスについてはあとで述べます。
タプル型
TypeScirptはタプル型という型も用意しています。ただし、JavaScriptにはタプルという概念はありません。そこで、TypeScriptでは配列をタプルの代わりとして用いることにしています。これは、関数から複数の値を返したい場合に配列に入れてまとめて返すみたいなユースケースを想定していると思われます。
タプル型は[string, number]のように書きます。これは実際のところ、長さが2の配列で、0番目に文字列が、1番目に数値が入ったようなものを表しています。
const foo: [string, number] = ['foo', 5];
const str: string = foo[0];
function makePair(x: string, y: number): [string, number] {
return [x, y];
}
ちなみに、タプル型と同じような意味の型を自分で作ることも可能です。
interface MyTuple extends Array<any> {
0: string;
1: number;
length: 2;
}
const a: MyTuple = ['foo', 5];
const b: [string, number] = a;
このように定義すると[string, number]型とMyTupleが(ほとんど)同じ意味になります。
ただし、タプル型の利用は注意する必要があります。TypeScriptがタプルと呼んでいるものはあくまで配列ですから、配列のメソッドで操作できます。
const tuple: [string, number] = ['foo', 3];
tuple.pop();
tuple.push('Hey!');
const num: number = tuple[1];
このコードはTypeScriptでエラー無くコンパイルできますが、実際に実行すると変数numに入るのは数値ではなく文字列です。このあたりはTypeScriptの型システムの限界なので、タプル型を使用するときは注意するか、あるいはそもそもタプル型を使うのは避けたほうがよいかもしれません。
クラスの型
最近のJavaScriptにはクラスを定義する構文があります。TypeScriptでは、クラスを定義すると同時に同名の型も定義されます。
class Foo {
method(): void {
console.log('Hello, world!');
}
}
const obj: Foo = new Foo();
この例では、クラスFooを定義したことで、Fooという型も同時に定義されました。Fooというのは、クラスFooのインスタンスの型です。上の例の最後の文はFooが2種類あって分かりにくいですが、obj: FooのFooは形名のFooであり、new Foo()のFooはクラス(コンストラクタ)の実体としてのFooです。
注意すべきは、TypeScriptはあくまで構造的型付けを採用しているということです。JavaScriptの実行時にはあるオブジェクトがあるクラスのインスタンスか否かということはプロトタイプチェーンによって特徴づけられますが、TypeScriptの型の世界においてはそうではありません2。具体的には、ここで定義された型Fooというのは次のようなオブジェクト型で代替可能です。
interface MyFoo {
method: ()=> void;
}
class Foo {
method(): void {
console.log('Hello, world!');
}
}
const obj: MyFoo = new Foo();
const obj2: Foo = obj;
ここでMyFooという型を定義しました。これはmethodという関数型のプロパティ(すなわちメソッド)を持つオブジェクトの型です。実はFoo型というのはこのMyFoo型と同じです。クラスFooの定義から分かるように、Fooのインスタンス、すなわちFoo型の値の特徴はmethodというプロパティを持つことです。よって、その特徴をオブジェクト型として表現したMyFoo型と同じと見なすことができるのです。
ジェネリクス
型がある言語にはいわゆるジェネリクスというものがよく存在します。いわゆる多相型に関連するものです。TypeScriptにもジェネリクスがあります。
型名をFoo<S, T>のようにする、すなわち名前のあとに< >で囲った名前の列を与えることで、型の定義の中でそれらの名前を型変数として使うことができます。
interface Foo<S, T> {
foo: S;
bar: T;
}
const obj: Foo<number, string> = {
foo: 3,
bar: 'hi',
};
この例ではFooは2つの型変数S, Tを持ちます。Fooを使う側ではFoo<number, string>のように、SとTに当てはまる型を指定します。
他に、クラス定義や関数定義でも型変数を導入できます。
class Foo<T> {
constructor(obj: T) {
}
}
const obj1 = new Foo<string>('foo');
function func<T>(obj: T): void {
}
func<number>(3);
ところで、上の例でfuncの型はどうなるでしょうか。実は、<T>(obj: T)=> voidという型になります。
function func<T>(obj: T): void {
}
const f: <T>(obj: T)=> void = func;
このように、関数の場合は呼び出すまでどのような型引数で呼ばれるか分からないため、型にも型変数が残った状態になります。
余談ですが、型引数(func<number>(3)の<number>部分)は省略できます。
function identity<T>(value: T): T {
return value;
}
const value = identity(3);
// エラー: Type '3' is not assignable to type 'string'.
const str: string = value;
この例ではidentityは型変数Tを持ちますが、identityを呼び出す側ではTの指定を省略しています。この場合引数の情報からTが推論されます。実際、今回引数に与えられている3は3型の値なので、Tが3に推論されます。identityの返り値の型はTすなわち3なので、変数valueの型は3となります。3型の値はstring型の変数に入れることができないので最終行ではエラーになっています。この例からTが正しく推論されていることが分かります。
ただし、複雑なことをする場合は型変数が推論できないこともあります。
union型(合併型)
さて、ここまで説明してきたジェネリクスなどは型のある言語なら多くの言語にありましたが、ここで紹介するunion型を持っている言語はそこまで多くないのではないかと思います。TypeScriptはこのunion型のサポートに力を入れています。
union型は値が複数の型のどれかに当てはまるような型を表しています。記法としては、複数の型を|でつなぎます。例えば、string | numberという型は「stringまたはnumberである値の型」、すなわち「文字列または数値の型」となります。
let value: string | number = 'foo';
value = 100;
value = 'bar';
// エラー: Type 'true' is not assignable to type 'string | number'.
value = true;
この例では変数valueがstring | number型の変数となっていますので、文字列や数値を代入することができますが、真偽値は代入することができません。
もちろん、プリミティブ型だけでなくオブジェクトの型でもunion型を作ることができます。
interface Hoge {
foo: string;
bar: number;
}
interface Piyo {
foo: number;
baz: boolean;
}
type HogePiyo = Hoge | Piyo;
const obj: HogePiyo = {
foo: 'hello',
bar: 0,
};
ここでtype文というのが登場していますが、これはTypeScript独自の文であり、新しい型を定義して名前を付けることができる文です。この例ではHogePiyoという型をHoge | Piyoとして定義しています。
union型の絞り込み
union型の値というのはそのままでは使いにくいものです。例えば、上で定義したHogePiyo型のオブジェクトは、barプロパティを参照することができません。なぜなら、HogePiyo型の値はHogeかもしれないしPiyoかもしてないところ、barプロパティはHogeにはありますがPiyoには無いからです。無い可能性があるプロパティを参照することはできません。同様に、bazプロパティも参照できません。fooプロパティは両方にあるので参照可能ですが、その型はstringかもしれないしnumberかもしれないということで、fooプロパティの型はstring | numberとなります。
そこで、Hoge | Piyoのような型の値が与えられる場合、まずその値が実際にはどちらかのかを実行時に判定する必要があります。そこで、TypeScriptではそのような判定を検出して適切に型を絞り込んでくれる機能があります。
interface Hoge {
foo: string;
bar: number;
}
interface Piyo {
foo: number;
baz: boolean;
}
function useHogePiyo(obj: Hoge | Piyo): void {
// ここではobjはHoge | Piyo型
if ('bar' in obj) {
// barプロパティがあるのはHoge型なのでここではobjはHoge型
console.log('Hoge', obj.bar);
} else {
// barプロパティがないのでここではobjはPiyo型
console.log('Piyo', obj.baz);
}
}
この例ではin演算子を使った例です。'bar' in objというのはbarというプロパティがobjに存在するならtrueを返し、そうでないならfalseを返す式です。
in演算子を使ったif文を書くことで、if文のthen部分とelse部分でobjの型がそれぞれHogeやPiyoとして扱われます。このようにして変数の型を絞り込むことができます。
ただし、この例は注意が必要です。なぜなら、次のようなコードを書くことができるからです。
const obj: Hoge | Piyo = {
foo: 123,
bar: 'bar',
baz: true,
};
useHogePiyo(obj);
objに代入されているのはPiyo型のオブジェクトに余計なbarプロパティが付いたものです。ということはこれはPiyo型のオブジェクトとみなせるので、Hoge | Piyo型の変数にも代入可能です。これをuseHogePiyoに渡すと良くないことが起こりますね。objは実際Piyo型なのに、これを実行すると'bar' in objが成立するのでobjがHoge型と見なされているところに入ってしまいます。obj.barを参照していますが、これはHoge型のプロパティなのでnumber型が期待されているところ、実際は文字列が入っています。
このようにin演算子を用いた型の絞り込みは比較的最近 (TypeScript 2.7)入った機能ですが、ちょっと怖いので自分はあまり使いたくありません。
typeofを用いた絞り込み
もっと単純な例として、string | number型を考えましょう。これに対する絞り込みはtypeof演算子でできます。
function func(value: string | number): number {
if ('string' === typeof value) {
// valueはstring型なのでlengthプロパティを見ることができる
return value.length;
} else {
// valueはnumber型
return value;
}
}
オブジェクトが絡まないこともあり、これなら安全ですね。
nullチェック
もうひとつunion型がよく使われる場面があります。それはnullableな値を扱いたい場合です。(JavaScriptなのでundefinedもありますが。)
例えば、文字列の値があるかもしれないしnullかもしれないという状況はstring | nullという型で表すことができます。string | null型の値はnullかもしれないので、文字列として扱ったりプロパティを参照したりすることができません。これに対し、nullでなければ処理したいという場面はよくあります。JavaScriptにおける典型的な方法はvalue != nullのようにif文でnullチェックを行う方法ですが、TypeScriptはこれを適切に解釈して型を絞り込んでくれます。
function func(value: string | null): number {
if (value != null) {
// valueはnullではないのでstring型に絞り込まれる
return value.length;
} else {
return 0;
}
}
また、&&や||が短絡実行するという挙動を用いたテクニックもJavaScriptではよく使われますが、これもTypeScriptは適切に型検査してくれます。上の関数funcは次のようにも書くことができます。
function func(value: string | null): number {
return value != null && value.length || 0;
}
代数的データ型っぽいパターン
これまで見たように、プリミティブ型ならunion型の絞り込みはけっこういい感じに動いてくれます。しかし、やはりオブジェクトに対してもいい感じにunion型を使いたいという需要はあります。そのような場合に推奨されているパターンとして、リテラル型とunion型を組み合わせることでいわゆる代数的データ型(タグ付きunion)を再現する方法があります。
interface Some<T> {
type: 'Some';
value: T;
}
interface None {
type: 'None';
}
type Option<T> = Some<T> | None;
function map<T, U>(obj: Option<T>, f: (obj: T)=> U): Option<U> {
if (obj.type === 'Some') {
// ここではobjはSome<T>型
return {
type: 'Some',
value: f(obj.value),
};
} else {
return {
type: 'None',
};
}
}
これは値があるかもしれないし無いかもしれないことを表すいわゆるoption型をTypeScriptで表現した例です。Option<T>型は、ある場合のオブジェクトの型であるSome<T>型と無い場合の型であるNone型のunionとして表現されています。ポイントは、これらに共通のプロパティであるtypeです。typeプロパティには、このオブジェクトの種類(SomeかNoneか)を表す文字列が入っています。ここでtypeプロパティの型としてリテラル型を使うことによって、Option<T>型の値objに対して、obj.typeが'Some'ならばSome<T>で'None'ならばNoneであるという状況を作っています。関数mapの中ではobj.typeの値によって分岐することにより型の絞り込みを行っています。この方法はTypeScriptで推奨されている方法らしく、コンパイラのサポートも厚いです。
次のようにswitch文でも同じことができます。大抵はどちらでも良いですが、こちらのほうが良い挙動を示す場合があります。
function map<T, U>(obj: Option<T>, f: (obj: T)=> U): Option<U> {
switch (obj.type) {
case 'Some':
return {
type: 'Some',
value: f(obj.value),
};
case 'None':
return {
type: 'None',
};
}
}
このmap関数の場合はこちらのほうが拡張に対して強くて安全です。例えばOption<T>に第3の種類の値を追加した場合、if文のバージョンではその値に対してもNoneが返るのに対し、switch文のバージョンではそのままではコンパイルエラーが出ます(第3の値に対する処理が定義されておらずmap関数が値を返さない可能性が生じてしまうため)。そのため、関数の変更の必要性にすぐ気づくことができます。
このパターンはTypeScriptプログラミングにおいて頻出です。オブジェクトで真似をしているため本物の代数的データ型に比べると記法が重いのが難点ですが仕方ありません。
never型
union型を触り始めるとたまに出てくるのがnever型です。never型は「属する値が存在しない型」です。どんな値もnever型の変数に入れることはできません。
// エラー: Type '0' is not assignable to type 'never'.
const n: never = 0;
一方、never型の値はどんな型にも入れることができます。
// never型の値を作る方法が無いのでdeclareで宣言だけする
declare const n: never;
const foo: string = n;
こう聞くとany型のように危険な型であるように思えるかもしれませんが、そんなことはありません。never型に当てはまる値は存在しないため、never型の値を実際に作ることはできません。よって、(TypeScriptの型システムを欺かない限りは)never型の値を持っているという状況があり得ないので、never型の値を他の型の変数に入れるということがソースコード上であったとしても、実際には起こりえないのです。
何を言っているのか分からない人もいるかもしれませんが、型システムを考える上ではこのような型はけっこう自然に出てきます。とりあえず具体例を見てみましょう。これは先ほどのOption<T>の例を少し変更したものです。
interface Some<T> {
type: 'Some';
value: T;
}
interface None {
type: 'None';
}
type Option<T> = Some<T> | None;
function map<T, U>(obj: Option<T>, f: (obj: T)=> U): Option<U> {
switch (obj.type) {
case 'Some':
return {
type: 'Some',
value: f(obj.value),
};
case 'None':
return {
type: 'None',
};
default:
// ここでobjはnever型になっている
return obj;
}
}
switch文にdefaultケースが追加されました。実はこの中でobjの型はneverになっています。なぜなら、それまでのcase文によってobjの可能性が全て調べ尽くされてしまったからです。これが意味することは、実際にはdefault節が実行される可能性は無いということです。つまり、この中ではobjの値の候補が全く無いということです。そのような状況を、objにnever型を与えることで表現しています。
また、もうひとつnever型が出てくる可能性があるのは、関数の返り値です。
function func(): never {
throw new Error('Hi');
}
const result: never = func();
関数の返り値の型がnever型となるのは、関数が値を返す可能性が無いときです。これは返り値が無いことを表すvoid型とは異なり、そもそも値が返ってくるということがあり得ない場合を表します。上の例では、関数funcは必ずthrowします。ということは、関数の実行は中断され、値を返すことなく関数を脱出します。特に、上の例でfuncの返り値を変数resultに代入していますが、実際にはresultに何かが代入されることはあり得ません。ゆえに、resultにはnever型を付けることができるのです。
なお、上の例ではfuncの返り値に型注釈でneverと書いていますが、TypeScriptでは関数の返り値の型は省略して推論させることができます。この場合はちゃんとneverに推論されます。
intersection型(交差型)
union型とある意味で対になるものとしてintersection型があります。2つの型T, Uに対してT & Uと書くと、TでもありUでもあるような型を表します。
interface Hoge {
foo: string;
bar: number;
}
interface Piyo {
foo: string;
baz: boolean;
}
const obj: Hoge & Piyo = {
foo: 'foooooooo',
bar: 3,
baz: boolean,
};
たとえばこの例では、Hoge & PiyoというのはHogeでもありPiyoでもある型を表します。ですから、この型の値はstring型のプロパティfooとnumber型のプロパティbarを持ち、さらにboolean型のプロパティbazを持つ必要があります。
ちなみに、union型とintersection型を組みあわせると楽しいです。次の例を見てください。
interface Hoge {
type: 'hoge';
foo: string;
}
interface Piyo {
type: 'piyo';
bar: number;
}
interface Fuga {
baz: boolean;
}
type Obj = (Hoge | Piyo) & Fuga;
function func(obj: Obj) {
// objはFugaなのでbazを参照可能
console.log(obj.baz);
if (obj.type === 'hoge') {
// ここではobjは Hoge & Fuga
console.log(obj.foo);
} else {
// ここではobjはPiyo & Fuga
console.log(obj.bar);
}
}
Obj型は(Hoge | Piyo) & Fugaですが、実はこれは(Hoge & Fuga) | (Piyo & Fuga)と同一視されます。よって、union型のときと同様にif文で型を絞り込むことができるのです。
オブジェクト型再訪
さて、union型を紹介したので、オブジェクト型にもうちょっと深入りして説明することができます。オブジェクト型はプロパティ名: 型;という定義の集まりでしたが、実はプロパティに対して修飾子を付けることができます。修飾子は?とreadonlyの2種類あります。
?: 省略可能なプロパティ
?を付けて宣言したプロパティは省略可能になります。
interface MyObj {
foo: string;
bar?: number;
}
let obj: MyObj = {
foo: 'string',
};
obj = {
foo: 'foo',
bar: 100,
};
この例ではbarが省略可能なプロパティです。barは省略可能なので、fooだけを持つオブジェクトと、fooとbarを両方持つオブジェクトはどちらもMyObj型の値として認められます。
ところで、実際のJavaScriptでは存在しないプロパティにアクセスするとundefinedが返ります。ということは、MyObj型の値に対してbarプロパティを取得しようとすると、undefinedの可能性があるということです。したがって、MyObjのbarプロパティの型はnumber | undefinedとなります。このように、?修飾子を付けられたプロパティは自動的にundefined型とのunion型になります。よって、それを使う側はこのようにundefinedチェックを行う必要があります。
function func(obj: MyObj): number {
return obj.bar != null ? obj.bar*100 : 0;
}
なお、?を使わずに自分でbarの型をnumber | undefinedとしても同じ意味にはなりません。
interface MyObj {
foo: string;
bar: number | undefined;
}
// エラー:
// Type '{ foo: string; }' is not assignable to type 'MyObj'.
// Property 'bar' is missing in type '{ foo: string; }'.
let obj: MyObj = {
foo: 'string',
};
?修飾子を使わない場合は、たとえundefinedが許されているプロパティでもきちんと宣言しないといけないのです。
readonly
もうひとつ可能な修飾子はreadonlyです。これを付けて宣言されたプロパティは再代入できなくなります。
interface MyObj {
readonly foo: string;
}
const obj: MyObj = {
foo: 'Hey!',
};
// エラー: Cannot assign to 'foo' because it is a constant or a read-only property.
obj.foo = 'Hi';
つまるところ、constのプロパティ版であると思えばよいでしょう。素のJavaScriptではプロパティのwritable属性が相当しますが、プロパティの属性を型システムに組み込むのは筋が悪くて厳しいためTypeScriptではこのような独自の方法をとっているのでしょう。
ただし、readonlyは過信してはいけません。次の例に示すように、readonlyでない型を経由して書き換えできるからです。
interface MyObj {
readonly: foo: string;
}
interface MyObj2 {
foo: string;
}
const obj: MyObj = { foo: 'Hey!'; }
const obj2: MyObj2 = obj;
obj2.foo = 'Hi';
console.log(obj.foo); // 'Hi'
インデックスシグネチャ
オブジェクト型には実は今まで紹介した他にも記法があります。その一つがインデックスシグネチャです。
interface MyObj {
[key: string] : number;
}
const obj: MyObj = {};
const num: number = obj.foo;
const num2: number = obj.bar;
[key: string]: number;という部分が新しいですね。このように書くと、string型であるような任意のプロパティ名に対してnumber型を持つという意味になります。objにそのような型を与えたので、obj.fooやobj.barなどはみんなnumber型を持っています。
これは便利ですが明らかに危ないですね。objは実際には{}なのでobj.fooなどはundefinedになるはずなのに、その可能性が無視されています。
そんな危ない型が平然と許されている理由は、オブジェクトを辞書として使うような場合に必要だとか、配列型の定義にも必要とかそんなところでしょう。実際、配列型の定義は概ね下のような感じです。
interface Array<T> {
[idx: number] : T;
length: number;
// メソッドの定義が続く
// ...
}
なお、この例のようにインデックスシグネチャの他にプロパティがあった場合、そちらが優先されます。
一応最近のJavaScriptならば、インデックスシグネチャの利用をできるだけ避けることはできます。オブジェクトを辞書として使う場合は、代わりにMapを使いましょう。配列の場合は、インデックスによるアクセスを避けてfor-of文を使うなどの方法で避けられます。
関数シグネチャ
実は、オブジェクト型の記法で関数型を表現する方法があります。
interface Func {
(arg: number): void;
}
const f: Func = (arg: number)=> { console.log(arg); };
(arg: number): void;の部分で、このオブジェクトはnumber型の引数をひとつ取る関数であることを表しています。
この記法は通常のプロパティの宣言と同時に使うことができるので、関数だけど同時に特定のプロパティを持っているようなオブジェクトを表すことができます。さらに、複数の関数シグネチャを書くことができ、それによってオーバーローディングを表現できます。
interface Func {
foo: string;
(arg: number): void;
(arg: string): string;
}
この型が表す値は、string型のfooプロパティを持つオブジェクトであり、かつnumber型を引数に関数として呼び出すことができその場合は何も返さず、string型を引数として呼び出すこともできてその場合はstring型の値を返すような関数、ということになります。
newシグネチャ
類似のものとして、コンストラクタであることを表すシグネチャもあります。
interface Ctor<T> {
new(): T;
}
class Foo {
public bar: number | undefined;
}
const f: Ctor<Foo> = Foo;
ここで作ったCtor<T>型は、0引数でnewするとT型の値が返るような関数を表しています。ここで定義したクラスFooはnewするとFooのインスタンス(すなわちFoo型の値)が返されるので、Ctor<Foo>に代入可能です。
asによるダウンキャスト
ここで型に関連する話題として、asによるダウンキャストを紹介します。これはTypeScript独自の構文で、式 as 型と書きます。ダウンキャストなので当然型安全ではありませんが、TypeScriptを書いているとたまに必要になる場面があります。なお、ダウンキャストというのは、派生型の値を部分型として扱うためのものです。
const value = rand();
const str = value as number;
console.log(str * 10);
function rand(): string | number {
if (Math.random() < 0.5) {
return 'hello';
} else {
return 123;
}
}
この例でvalueはstring | number型の値ですが、value as numberの構文によりnumber型として扱っています。よって変数strはnumber型となります。
これは安全ではありません。なぜなら、valueは実際にはstring型、すなわち文字列かもしれないので、変数strに文字列が入ってしまう可能性があるからです。
なお、asを使っても全く関係ない2つの値を変換することはできません。
const value = 'foo';
// エラー: Type 'string' cannot be converted to type 'number'.
const str = value as number;
しかしTypeScriptなのでany型を経由すれば変換できます(当然ながらとても危険ですが)。
const value = 'foo';
const str = value as any as number;
ちなみに、この例に見えるように、asはアップキャストもできます。例えば、const foo: string = 'foo'; とする代わりにconst foo = 'foo' as string;とすることができます。しかしasは危険なので、本当に必要な場面以外で使うのは避けたほうがよいでしょう。
object型と{}型
あまり目にすることがない型のひとつにobject型があります。これは「プリミティブ以外の値の型」です。JavaScriptにはオブジェクトのみを引数に受け取る関数があり、そのような関数を表現するための型です。例えば、Object.createは引数としてオブジェクトまたはnullのみを受け取る関数です。
// エラー: Argument of type '3' is not assignable to parameter of type 'object | null'.
Object.create(3);
ところで、{}という型について考えてみてください。これは、何もプロパティがないオブジェクト型です。プロパティが無いといっても、構造的部分型により、{foo: string}のような型を持つオブジェクトも{}型として扱うことができます。
const obj = { foo: 'foo' };
const obj2: {} = obj;
となると、任意のオブジェクトを表す型として{}ではだめなのでしょうか。
もちろん、答えはだめです。じつは、{}という型はオブジェクト以外も受け付けてしまうのです。ただし、undefinedとnullはだめです。
const o: {} = 3;
これは、JavaScriptの仕様上、プリミティブに対してもプロパティアクセスができることと関係しています。例えばプリミティブのひとつである文字列に対してlengthというプロパティを見るとその長さを取得できます。ということで、次のようなコードが可能になります。
interface Length {
length: number;
}
const o: Length = 'foobar';
このことから、{}というのはundefinedとnull以外は何でも受け入れてしまうようなとても弱い型であるということが分かります。
weak type
ところで、オプショナルなプロパティ(?修飾子つきで宣言されたプロパティ)しかない型にも同様の問題があることがお分かりでしょうか。そのような型は、関数に渡すためのオプションオブジェクトの型としてよく登場します。
そこで、そのような型はweak typeと呼ばれ3、特殊な処理が行われます。
interface Options {
foo?: string;
bar?: number;
}
const obj1 = { hoge: 3 };
// エラー: Type '{ hoge: number; }' has no properties in common with type 'Options'
const obj2: Options = obj1;
// エラー: Type '5' has no properties in common with type 'Options'.
const obj3: Options = 5;
最後の2行に対するエラーはweak typeに特有のものです。obj2の行は、{ hoge: number; }型の値をOptions型の値として扱おうとしていますがエラーとなっています。構造的部分型の考えに従えば、{ hoge: number; }型のオブジェクトはfooとbarが省略されており、余計なプロパティhogeを持ったOptions型のオブジェクトと見なせそうですが、weak type特有のルールによりこれは認められません。具体的には、weak typeの値として認められるためにはエラーメッセージにある通りweak typeが持つプロパティを1つ以上持った型である必要があります。実際、そうでない値をOptions型のオブジェクトとして扱いたい場面はほとんど無いためこういうのはエラーとして弾きたいところ、weak typeは値に対する制限が弱すぎるためこのような追加のルールが導入されているのです。ただし例外として、{}はOptions型の値として扱えるようです。
また、weak typeはオブジェクトではないものも同様に弾いてくれます。
keyof
ここからがいよいよTypeScriptのよく分からないところです。ここから先はそれぞれが単独でQiitaの記事になるほどのポテンシャルを秘めているので、探せば記事があると思います。
あるTを型とすると、keyof Tという型の構文があります。keyof Tは、「Tのプロパティ名全ての型」です。
interface MyObj {
foo: string;
bar: number;
}
let key: keyof MyObj;
key = 'foo';
key = 'bar';
// エラー: Type '"baz"' is not assignable to type '"foo" | "bar"'.
key = 'baz';
この例では、MyObj型のオブジェクトはプロパティfooとbarを持ちます4。なので、プロパティ名として可能な文字列は'foo'と'bar'のみであり、keyof MyObjはそれらの文字列のみを受け付ける型、すなわち'foo' | 'bar'になります。よって、keyof MyObj型の変数であるkeyに'baz'を代入しようとするとエラーとなります。
プロパティアクセス型 T[K]
keyofとセットで使われることが多いのがプロパティアクセス型です。これは型TとKに対してT[K]という構文で書きます。Kがプロパティ名の型であるとき、T[K]はTのそのプロパティの型となります。
言葉で書くと分かりにくいので例を見ましょう。
interface MyObj {
foo: string;
bar: number;
}
// strの型はstringとなる
const str: MyObj['foo'] = '123';
この例ではMyObj['foo']という型が登場しています。上で見たT[K]という構文と比べると、TがMyObj型でKが'foo'型となります。
よって、MyObj['foo']はMyObj型のオブジェクトのfooというプロパティの型であるstringとなります。
同様に、MyObj['bar']はnumberとなります。MyObj['baz']のようにプロパティ名ではない型を与えるとエラーとなります。厳密かつ大雑把に言えば、Kはkeyof Tの部分型である必要があります。
逆に言えば、MyObj[keyof MyObj]という型は可能です。これはMyObj['foo' | 'bar']という意味になりますが、プロパティ名がfooまたはbarということは、その値はstringまたはnumberになるということなので、MyObj['foo' | 'bar']はstring | numberになります。
keyofとプロパティアクセス型を使うと例えばこんな関数を書けます。
function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const obj = {
foo: 'string',
bar: 123,
};
const str: string = pick(obj, 'foo');
const num: number = pick(obj, 'bar');
// エラー: Argument of type '"baz"' is not assignable to parameter of type '"foo" | "bar"'.
pick(obj, 'baz');
この関数pickは、pick(obj, 'foo')とするとobj.fooを返してくれるような関数です。注目すべき点は、この関数にちゃんと型を付けることができているという点です。pick(obj, 'foo')の返り値の型はobj.fooの型であるstring型になっています。同様にpick(obj, 'bar')の型はnumber型になっています。
pickは型変数を2つ持ち、2つ目はK extends keyof Tと書かれています。これは初出の文法ですが、ここで宣言する型変数Kはkeyof Tの部分型でなければならないという意味です。この条件が無いと、返り値の型T[K]が妥当でない可能性が生じるためエラーとなります。
pick(obj, 'foo')という呼び出しでは、Tが{ foo: striing; bar: number; }型、Kが'foo'型となるため、返り値の型は({ foo: string; bar: number; })['foo']型、すなわちstring型となります。
Mapped Types
さて、以上の2つと同時に導入されたのがmapped typeと呼ばれる型です。日本語でどう呼べばいいのかはよく分かりません。mapped typeは{[P in K]: T}という構文を持つ型です。ここでPは型変数、KとTは何らかの型です。ただし、Kはstringの部分型である必要があります。例えば、{[P in 'foo' | 'bar']: number}という型が可能です。
{[P in K]: T}という型の意味は、「K型の値として可能な各文字列Pに対して、型Tを持つプロパティPが存在するようなオブジェクトの型」です。上の例ではKは'foo' | 'bar'なので、Pとしては'foo'と'bar'が可能です。よってこの型が表わしているのはnumber型を持つプロパティfooとbarが存在するようなオブジェクトです。
すなわち、{[P in 'foo' | 'bar']: number} というのは{ foo: number; bar: number; }と同じ意味です。
type Obj1 = {[P in 'foo' | 'bar']: number};
interface Obj2 {
foo: number;
bar: number;
}
const obj1: Obj1 = {foo: 3, bar: 5};
const obj2: Obj2 = obj1;
const obj3: Obj1 = obj2;
これだけでは何が面白いのか分かりませんね。実は、{[P in K]: T}という構文において、型Tの中でPを使うことができるのです。例えば次の型を見てください。
type PropNullable<T> = {[P in keyof T]: T[P] | null};
interface Foo {
foo: string;
bar: number;
}
const obj: PropNullable<Foo> = {
foo: 'foobar',
bar: null,
};
ここでは型変数Tを持つ型PropNullable<T>を定義しました。この型は、T型のオブジェクトの各プロパティPの型が、T[P] | null、すなわち元の型であるかnullであるかのいずれかであるようなオブジェクトの型です。具体的には、PropNullable<Foo>というのは{foo: string | null; bar: number | null; }という型になります。
また、mapped typeでは[P in K]の部分に前回紹介した修飾子(?とreadonly)を付けることができます。例えば、次の型Partial<T>はTのプロパティを全てオプショナルにした型です。この方は便利なのでTypeScriptの標準ライブラリに定義されており、自分で定義しなくても使うことができます。全てのプロパティをreadonlyにするReadonly<T>もあります。
type Partial<T> = {[P in keyof T]?: T[P]};
実際にmapped typeを使う関数を定義する例も見せておきます。
function propStringify<T>(obj: T): {[P in keyof T]: string} {
const result = {} as {[P in keyof T]: string};
for (const key in obj) {
result[key] = String(obj[key]);
}
return result;
}
この例ではasを使ってresultの型を{[P in keyof T]: string}にしてから実際にひとつずつプロパティを追加していっています。asやanyなどを使わずにこの関数を書くのは難しい気がします。そのため、mapped typeはどちらかというと関数が使われる側の利便性のために使われるのが主でしょう。ライブラリの型定義ファイルを書く場合などは使うかもしれません。
ちなみに、mapped typeを引数の位置に書くこともできます。
function pickFirst<T>(obj: {[P in keyof T]: Array<T[P]>}): {[P in keyof T]: T[P] | undefined} {
const result: any = {};
for (const key in obj) {
result[key] = obj[key][0];
}
return result;
}
const obj = {
foo: [0, 1, 2],
bar: ['foo', 'bar'],
baz: [],
};
const picked = pickFirst(obj);
picked.foo; // number | undefined型
picked.bar; // string | undefined型
picked.baz; // undefined型
この例のすごいところは、pickFirstの型引数Tが推論できているところです。objは{ foo: number[]; bar: string[]; baz: never[]; }という型を持っており、それが{[P in keyof T]: Array<T[P]>}の部分型であることを用いて、Tを{ foo: number; bar: string; baz: never; }とできることを推論できています。これをさらにmapped typeで移して、返り値の型は{ foo: number | undefined; bar: string | undefined; baz: undefined; }となります。なお、bazの型はnever | undefinedですが、neverはunion型の中では消えるのでこれはundefinedとなります。
mapped typeは他にも色々な応用が出来るようです。実践的な例としては、Diff型をmapped typeなどを用いて実現することができます。ここまでの内容を理解していればこの記事も理解できると思います。
Conditional Types
上記のmapped typesが導入されたのがTypeScript 2.1のことで、そこから先しばらくは細々とした改良はあっても訳の分からないやばい型が導入されるようなことはありませんでした。その状況を打ち破り、TypeScript 2.8で久々に登場予定のやばい型、それがconditional typeです。これは型レベルの条件分岐が可能な型です。Qiitaに既にいい記事がありますのでそちらを参照してもらうのも良いですが、今回の一連の記事は一通り読めばTypeScriptの型が分かることを目指していますので、ここにも解説を書いておきます。
Conditional type(日本語でなんと言えばいいのかはやっぱり分かりません)は4つの型を用いてT extends U ? X : Yという構文で表現される型です。いわゆる条件演算子を彷彿とさせる記法で、意味もその直感に従います。すなわち、この型はTがUの部分型ならばXに、そうでなければYになります。そんなもの何に使うんだと言いたくなるかもしれませんが、実はこの型の表現力は凄まじく、当該のPull Requestで指摘されているようにさまざまな問題を解決できます。まずそれについて少し述べます。
mapped typesの限界
mapped typeが導入された当初から指摘されていた問題として、deepなマッピングができないという問題がありました。先ほど組み込みのReadonly<T>を紹介しましたが、これはプロパティをshallowにreadonly化します。例えば、
interface Obj{
foo: string;
bar: {
hoge: number;
};
}
という型に対してReadonly<Obj>は{ readonly foo: string; readonly bar: { hoge: number; }; }となります。つまり、barの中のhogeはreadonlyになりません。これはこれで役に立つかもしれませんが、ネストしているオブジェクトも含めて全部readonlyにしてくれるようなもの、すなわちDeepReadonly<T>のほうが需要がありました。少し考えると、これは再帰的な定義にしなければならないことが分かります。しかし、次のような素朴な定義はうまくいきません。
type DeepReadonly<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>;
}
次に示すように、これは一見うまくいくように見えます。
type DeepReadonly<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>;
}
interface Obj{
foo: string;
bar: {
hoge: number;
};
}
type ReadonlyObj = DeepReadonly<Obj>;
const obj: ReadonlyObj = {
foo: 'foo',
bar: {
hoge: 3,
},
};
// エラー: Cannot assign to 'hoge' because it is a constant or a read-only property.
obj.bar.hoge = 3;
しかし、これはDeepReadonly<T>のTの型が何か判明しているからであり、次のような状況ではうまくいかなくなります。
type DeepReadonly<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>;
}
function readonlyify<T>(obj: T): DeepReadonly<T> {
// エラー: Excessive stack depth comparing types 'T' and 'DeepReadonly<T>'.
return obj as DeepReadonly<T>;
}
つまり、あのような単純な再帰では一般のTに対してどこまでもmapped typeを展開してしまうことになり、それを防ぐためにconditional typeが必要となるわけです。
conditional typeによるDeepReadonly<T>
では、conditional typeを用いたDeepReadonly<T>を
https://github.com/Microsoft/TypeScript/pull/21316 から引用します。
type DeepReadonly<T> =
T extends any[] ? DeepReadonlyArray<T[number]> :
T extends object ? DeepReadonlyObject<T> :
T;
interface DeepReadonlyArray<T> extends ReadonlyArray<DeepReadonly<T>> {}
type DeepReadonlyObject<T> = {
readonly [P in NonFunctionPropertyNames<T>]: DeepReadonly<T[P]>;
};
type NonFunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T];
DeepReadonly<T>がconditional typeになっており、Tが配列の場合、配列以外のオブジェクトの場合、それ以外の場合(すなわちプリミティブの場合)に分岐しています。配列の場合はDeepReadonlyArray<T>で処理し、それ以外のオブジェクトはDeepReadonlyObject<T>で処理しています。プリミティブの場合はそのプロパティを考える必要はないため単にTを返しています。
DeepReadonlyArray<T>は、要素の型であるTをDeepReadonly<T>で再帰的に処理し、配列自体の型はReadonlyArray<T>により表現しています。ReadonlyArray<T>というのは標準ライブラリにある型で、各要素がreadonlyになっている配列です。T[number]というのは配列であるTに対してnumber型のプロパティ名でアクセスできるプロパティの型ですから、すなわち配列Tの要素の型ですね。
DeepReadonlyObject<T>は上の素朴な場合と同様にmapped typeを用いて各プロパティを処理しています。ただし、NonFunctionPropertyNames<T>というのはTのプロパティ名のうち関数でないものです。よく見るとこれもconditional typeで実装されています。さっき記事を紹介したDiffとアイデアは同じですが、conditional typeにより簡単に書けています。
つまり、このDeepReadonlyObject<T>は実はTからメソッド(関数であるようなプロパティ)を除去します。これにより、メソッドが自己を書き換える可能性を排除しているのでしょう。
実のところ、DeepReadonly<T>の本質は、conditional typeが遅延評価されるところにあります。DeepReadonly<T>の分岐条件はTが何なのかわからないと判定できないので必然的にそうなりますが。これにより、評価時に無限に再帰することを防いでいます。
試してみたところ、
type List<T> = {
value: T;
next: List<T>;
} | undefined;
のような再帰的な型にもDeepReadonly<T>を適用することができました。
conditional typeにおける型マッチング
実はconditional typeにはさらに強力な機能があります。それは、conditional typeの条件部で新たな型変数を導入できるという機能です。 https://github.com/Microsoft/TypeScript/pull/21496 から例を引用します。
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : T;
ReturnType<T>は、Tが関数の型のとき、その返り値の型となります。ポイントは、関数の型の返り値部分にあるinfer Rです。このようにinferキーワードを用いることでconditional typeの条件部分で型変数を導入することができます。導入された型変数は分岐のthen側で利用可能になります。
つまり、このReturnType<T>は、Tが(...args: any[]) => R(の部分型)であるときRに評価されるということです。then側でしか型変数が使えないのは、else側ではTが(... args: any[]) => Rの形をしていないかもしれないことを考えると当然ですね。このことから分かるように、この機能は型に対するパターンマッチと見ることができます。
実は同じ型変数に対するinferが複数箇所に現れることも可能です。その場合、推論される型変数にunion型やintersection型が入ることもあります。人為的な例ですが、次の例で確かめられます。
type Foo<T> =
T extends {
foo: infer U;
bar: infer U;
hoge: (arg: infer V)=> void;
piyo: (arg: infer V)=> void;
} ? [U, V] : never;
interface Obj {
foo: string;
bar: number;
hoge: (arg: string)=> void;
piyo: (arg: number)=> void;
}
declare let t: Foo<Obj>; // tの型は[string | number, string & number]
部分型関係を考えれば、Uがunion型で表現されてVがintersection型で表現される理由が分かります。Uはcovariantな位置に、Vはcontravariantな位置に出現しているからです。ちなみに、試しに両方の位置に出現させてみたところ、Foo<Obj>が解決されなくなりました。
ちなみに、ReturnType<T>など、conditional typesを使った型がいくつか標準ライブラリに組み込まれるようです。自分でconditional typesと戦わなくても恩恵を得られる場面が多いと思います。
まとめ
TypeScriptの型をひととおり紹介しました。今回は入門記事ということで、はしょったものもあります(this型とか。普段使わないので忘れているとも言いますが)。とはいえ、面白そうな部分はおおよそ紹介したつもりなので、TypeScriptの型で面白いことをやっているコードがあってもけっこう読めるのではないかと思います。
TypeScriptを使っている方は、mapped typeやconditional typeなどを機会があれば使ってみましょう(後者はTypeScript 2.8の正式版が出ないとなかなか使えないと思いますが)。mapped typeくらいなら意外と使える場面があります。
TypeScriptを使っていない方にとっては、特に後半は他の言語ではなかなか見ないような型が登場しており面白かったのではないでしょうか。型システムが強い言語は多々ありますが、TypeScriptの型システムはJavaScriptに型を付けるという難題に対する答えであり、それらとは一線を画したところがあります。