LoginSignup
775
632

More than 1 year has passed since last update.

TypeScriptの型初級

Last updated at Posted at 2018-10-24

この記事は「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 | numberstringまたは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なのです。

今回のポイントは、条件型の条件部分の型VOption<T>というunion型になっている点です。Option<T>None | Some<T>というunion型でしたね。このように条件型の条件部分にunion型が来たときに、条件型は特殊な動作をします。一言で説明すると、「union型の条件型」が「条件型のunion型」に変換されます。数学等ではこのような挙動をdistribution(分配)といいますから、union distributionというのもそこから来ています。

今回の例で具体的に説明すると、VOption<T>、すなわちNone | Some<T>が入りますから、条件型のVのところにNoneSome<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>型は、VSome<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だったところが左と右でそれぞれNoneSome<T>に置換されているところです。

分配されるのは型変数のみ

もう1つ注意しなければいけない点があり、これが条件型の大変ややこしいところでもあります。それは、今まで説明したようなunion distributionが発生するのは条件部分の型が型変数である場合のみであるという点です。

今までのサンプルでは、条件型のextendsの左が全部Vだったことを思い出してください。このVValueOfOption<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>Tneverならばtrueになりそうでなければfalseになる型というつもりでしたが、neverを渡した結果はtrueでもfalseでもなくneverです。これはneverが0個のunionのように振る舞うことから説明できます。言い換えれば、Tが型変数でT extends never ? X : Yという形の条件型に対してTneverを代入すると常に結果は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になってほしいですが、どうもならないようです。とにかく、FooBarに共通する名前のプロパティは存在しないため、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));

今度はちゃんとStrArrstring[]型となり、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型の値を持ったオブジェクトです。

Recordの使用例
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で与えられた名前のプロパティのみを残したような型を返す型関数です。

Pickの使用例
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 UTに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';
    };

MyDatatypeプロパティをタグとして、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>Unull | 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の返り値の型となります。

ParameterとReturnTypeの使用例
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独自のもので、型から強制的にnullundefinedを取り除くダウンキャストの演算子です。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の引数が関数であり、その型においてthisMyObjであると指定されています。callWithThisの引数である無名関数の型はその型に当てはめられることになりますから、その中ではthisMyObj型となるのです。このようにすることで、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で固定なのです。MySubClassclone()を呼び出したときはちゃんと返り値が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のときその引数はその型を持つことを保証するという意味になります。

FooObjnumber型のプロパティfooを持つオブジェクトの型なので、isFooObjではargがその条件を満たすことを確かめています。

次にuseFooを見ましょう。この関数では、引数argの型をunknownとしています4

argの型をFooObj型として使いたいのですが、そのためにはまずargFooObj型かどうか確かめる必要があります。そこで、if文の中で先ほど定義したisFooObjを呼び出します。この形でisFooObjを使うことによって、その中ではargの型がFooObj型に狭められるのです。

これも使いどころが沢山あるわけではないのですが、標準ライブラリ中のArray.isArrayの定義にも使われています。

標準ライブラリのArrayConstructorの定義
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の型中級」はまだ書いていませんが、その代わりに入門・初級をマスターしたみなさんが次に読むと良い記事をご案内します。


  1. 実際、TypeScript 2.9より前はkeyof anystringになっていました。 

  2. これは、TypeScriptからJavaScriptに変換するときに型に依存してはいけないという制限によるものだと考えられます。引数の型を調べてどの実装を使うか選択するみたいな実装をTypeScriptが出力するわけにはいかないので、1つの実装で全てやる必要があります。 

  3. ただ、この無名関数を別の名前の関数としてくくり出すような場合は、その引数がcallWithThisの引数として使われるという情報がないため、thisの型を明示する必要があります。この辺はTypeScriptの型推論の弱い部分ですね。多分そういうデザインなのだと思いますが。 

  4. 本当はisFooObjの引数の型もunknownにしたかったのですが、型エラーになってしまうのでちょっとずるいですがanyにしています。まあ、型についての保証をする関数なのでその中身は自分で気をつけて書きましょうということです。 

775
632
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
775
632