最初に、infer について説明されている良サイト(公式ドキュメントを含む)はこちらです。
- TypeScript 2.8 Conditional Types | TypeScript Documentation
- TypeScript 2.8 の Conditional Types について | Qiita
- TypeScript2.8 Conditional Types 活用事例 | Qiita
- TypeScriptのinferとは何か
これらを読んでもなお理解不能だった私...。
手元でコードを書いてようやく理解できたので備忘録として記事を残します。
サンプルコードは TypeScript 3.5.1 で試しています。
前提となる知識
Generics と Conditional Types を理解していないと infer に苦戦します。ちょっと長いですがお付き合いください。
Generics
Genericsは型をプレースホルダのように扱う仕組みです。
Genericsでは <T>
のように型エイリアスを指定します。この時点ではまだ T
が何であるか決定していません。
interface Something<T> {
id: number;
flag: T;
}
上記の Something 型を利用する時点で T
に具体的な型を付与します。
const something1: Something<boolean> = { id: 1, flag: true };
flag プロパティは T
型で宣言されているため T
に boolean
が付与されると自動的に boolean
型が決定します。同じように T
に number
や string
が付与されると自動的に flag
プロパティの型が決定します。
const something2: Something<number> = { id: 2, flag: 1 };
const something3: Something<string> = { id: 2, flag: 'yes' };
決定した型と実装コードが一致しない場合はコンパイルエラーになります。
// Error: Type 'string' is not assignable to type 'boolean'.
const something4: Something<boolean> = { id: 2, flag: 'yes' };
// Error: Property 'toNumber' does not exist on type 'number'.
const something5: Something<number> = { id: 2, flag: 1 };
something5.flag.toNumber();
Genericsを使うと、いろいろな型を付与して利用できる柔軟性と、コンパイルエラーによる型安全を得る事ができます。
Conditional Types
Conditional types は T extends U ? X : Y
という構文で表されます。
ところでこの構文、見たことありませんか?
そうです「三項演算子」です。
// flag が true と評価されれば 1、そうでなければ 2 が得られる
const flag ? 1 : 2;
Conditional types における T
U
X
Y
は型を示しています。
// T,U 型の互換性があれば X 型、そうでなければ Y 型
T extends U ? X : Y
これだけでは使い所がよく分からないので、サンプルを見てみましょう。
下記のコードは Animal インターフェースと、その実装の Dog, Cat クラスです。
interface Animal {
name: string;
color: string;
}
class Dog implements Animal {
constructor(
public name: string,
public color: string,
) {}
}
class Cat implements Animal {
constructor(
public name: string,
public color: string,
) {}
}
次に Virus インターフェースと、その実装の Influenza クラスを追加します。
interface Virus {
name: string,
type: string;
}
class Influenza implements Virus {
constructor(
public name: string,
public type: string,
) {}
}
Dog, Cat, Influenza をひとまとめにした Creature という型を宣言します。
filterByName() は Creature の配列から name プロパティが一致する値を取り出す関数です。
type Creature = Dog | Cat | Influenza;
function filterByName(creatures: Creature[], name: string) {
return creatures.filter(creature => creature.name === name);
}
filterByName(
[
new Dog('Bob', 'white'),
new Cat('Steve', 'white'),
new Influenza('H1N1', 'A'),
new Influenza('H3N2', 'A'),
new Influenza('H7N9', 'A'),
],
'H1N1',
)
// [ {name: "H1N1", type: "A"} ]
今度は color プロパティが一致する値を取り出す filterByColor() 関数を実装してみましょう。
IsAnimal は与えられた型 T
のうち Animal と互換性のある型かどうかを判定します。AnimalType は Creature のうち Animal と互換性のある型だけを抽出します。
// Dog -> T, Cat -> T, Influenza -> never
type IsAnimal<T> = T extends Animal ? T : never;
// Dog | Cat
type AnimalType = IsAnimal<Creature>;
function filterByColor(animals: AnimalType[], color: string): AnimalType[] {
return animals.filter(v => v.color === color);
}
filterByColor() の第一引数には Animal 型の配列が入る事が保証されています。
互換性のない型を渡す事はできません。
filterByColor(
[
new Dog('Bob', 'white'),
new Dog('Steve', 'brown'),
new Dog('Mel', 'brown'),
], 'white'
);
// OK: [ {name: 'Bob', color: 'white'} ]
filterByColor([
// Error: Type 'Influenza' is not assignable to type 'Dog | Cat'.
new Influenza('H1N1', 'A'),
...
Conditional Types はこのように、型を条件に当てはめて別の型を導出する事ができる機能です。
本題の infer に行こう
前提が長くて忘れてしまったかもしれませんが、この記事の本題は infer でした。
infer は Type inference in conditional types というもので、Conditional Types の構文の中で型をキャプチャする機能です。
1)異なる型のプロパティをひとつに束ねる
ショッピングサイトのアイテムをサンプルにしてみましょう。
書籍を表す Book クラスと、イベントチケットを表す Ticket クラスがあるとします。それぞれアイテムを識別する code プロパティが存在していますが、型が異なっています。
class Book {
constructor(
public code: number,
public name: string,
) {}
}
const orderedBook = [
new Book(1, 'ゴミでも分かるTypeScript'),
new Book(2, 'TypeScriptマガジン 2019春号'),
new Book(2, 'TypeScriptマガジン 2019冬号'),
];
class Ticket {
constructor(
public code: string,
public name: string,
) {}
}
const orderedTicket = [
new Ticket('23100-mokumoku', 'TS Nagoyaもくもく会 第1回'),
new Ticket('23100-mokumoku', 'TS Nagoyaもくもく会 第2回'),
new Ticket('13101-tsconf', 'TS Conf Tokyo'),
];
注文履歴を検索するような処理を追加する場合 code の型のばらつきが厄介になりそうですね。いくつもの関数で Book,Ticket クラスを判定する処理が出てきそうです。
ここで Conditional types を使うと code プロパティを U
型としてキャプチャする事ができます。
type Code<T> = T extends { code: infer U } ? U : never;
// <T> に Book,Ticket を付与した時に Code<T> が決定する
// Code<Book> = T extends { code: infer number } ? number : never --> number
// Code<Ticket> = T extends { code: infer string } ? string : never --> string
// ---> Code<T> は number | string と同じ
関数宣言に便利なので Book,Ticket を抽象的に扱う型も宣言しておきましょう。
type Item<T> = { code: Code<T> };
// <T> に Book,Ticket を付与した時に Item<T> が決定する
// Item<Book> = { code: number }
// Item<Ticket> = { code: string }
// ---> Item<T> は { code: number | string } と同じ
code プロパティを Code 型として抽象的に扱う事ができるようになったため、Book,Ticket の注文履歴どちらでも利用可能な検索ができるようになりました。
function filter<U>(list: Item<U>[], code: Code<U>): Item<U>[] {
return list
.filter(item => item.code === code)
;
}
console.info(filter<Book>(orderedBook, 2));
console.info(filter<Ticket>(orderedTicket, '23100-mokumoku'));
infer による型推論を利用すると、もし型を間違えた場合にコンパイルエラーで気づく事ができます。
これは嬉しいですね。
// Error: Argument of type '"2"' is not assignable to parameter of type 'number'.
console.info(filter<Book>(orderedBook, '2'));
もうひとつ嬉しいのは、クラスの型変更による影響が最小限にとどまる事です。
filter() 関数は code プロパティの型を型推論で得ているため、Ticket クラスの code プロパティを string から number に変更しても、この関数自体は影響を受けません。
2)Promiseの値の型チェック
もうひとつ、サンプル行ってみましょう。
下記のコードは複数の非同期処理を直列に実行するものです。
async function series(...fetches) {
let responses = [];
for (let fetch of fetches) {
responses.push(await fetch());
}
return responses;
}
Promise の値は明示的に宣言しなければ any
型の値を持つものとして扱われます。
結構あるあるだと思いますが、このままだとエディタのコード補完も効かず、タイポしてもコンパイルエラーが出ないので不便ですよね。
series(
() => Promise.resolve({ ddata: 'first' }), // プロパティ名をタイポしてもエラーにならない
() => Promise.resolve({ data: 'second' }),
() => Promise.resolve({ data: 'third' }),
).then(
responses => responses.forEach(response => {
console.log(response.dataa); // プロパティ名をタイポしてもエラーにならない
})
);
ここで infer による型推論を利用してみます。
今度は Promise の持つ値を U
型としてキャプチャします。
type Value<T> = T extends () => Promise<infer U> ? U : never;
type Fetch<T> = () => Promise<Value<T>>;
async function series<T>(...fetches: Fetch<T>[]) {
let responses: Value<T>[] = [];
for (let fetch of fetches) {
responses.push(await fetch());
}
return responses;
}
<T>
に付与される型の情報から Promise の持つ値が推論されるようになりました。
これでタイポした時にコンパイルエラーで気づく事ができます。
series<() => Promise<{ data: string; }>>(
() => Promise.resolve({ ddata: 'first' }), // コンパイルエラー
() => Promise.resolve({ data: 'second' }),
() => Promise.resolve({ data: 'third' }),
).then(
responses => responses.forEach(response => {
console.log(response.dataa); // コンパイルエラー
})
);
Promise を使うたびに毎回 infer を使った型推論を書くのはとても面倒でやってられませんが、少し複雑な処理が絡んだりする場面では、コンパイルによる型チェックが大いに活躍してくれるかもしれません。
おわりに
infer って何か便利そうですよね。抽象化した型を扱う Sinon.js のソースコードでもよく見かける手法なので、身につけておけばコードリーディングにも役立ちそうです。
この記事が infer を前にして私と同じように「......🤔」となった誰かの一助になれば幸いです。