【学習メモ】 TypeScript の型入門 その2
はじめに
@uhyo_さんが書いた以下の記事を理解するために、原文を自分でも理解できる表現にリライトしたものになります。
この記事はどんな記事?
TypeScript(v3.7) の型について初歩から解説した記事。
- ジェネリクス
- タプル型
- union 型(合併型)
- never 型
- intersection 型(交差型)
ジェネリクス
以下のように、型名のあとに<>
で囲った名前を追加することで、型の定義の中でそれらの名前を型変数として利用できる。
// `Foo`は2つの型変数`S`、`T`を持つ interface Foo<S, T> { foo: S; bar: T; } // `Foo<number, string>`と記述しているので、 // `S`型は`number`型になり、`T`型は`string`型になる。 const obj: Foo<number, string> = { foo: 3, bar: "hi" };
他にも、以下のようにクラス定義や関数定義でも型変数を導入できる。
class Foo<T> { constructor(arg: T) {} } const obj1 = new Foo<string>("foo"); function func<T>(obj: T): void {} func<number>(3);
また、関数の引数は型推論されるため、以下のように関数の型引数は省略できる。
function identity<T>(value: T): T { return value; } // identity に渡した引数 value から T 型が推論される。 // 3 を渡しているので、`T`型は`number`型になる。 const value = identity(3); // エラー: Type '3' is not assignable to type 'string'. const str: string = value;
ただし、複雑なことをする場合は型変数が推論できないこともある。
タプル型
以下のような[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]; }
TypeScript がタプルと呼んでいるものはあくまで配列のため、配列のメソッドで操作できる。
以下のコードはエラーが発生しないが、number 型の変数num
には文字列が入ってしまう。
const tuple: [string, number] = ["foo", 3]; tuple.pop(); // tuple[1] に string 型が入ってしまうが、エラー発生しない。 tuple.push("Hey!"); // tuple[1] に string 型が入っているが、エラー発生しない。 const num: number = tuple[1];
このあたりは TypeScript の型システムの限界なので、タプル型を使用するときは注意するか、そもそもこのようにタプル型を使うのは避けたほうが良いかもしれない。
以下のように要素が 0 個のタプル型なども作ることができる。
const unit: [] = [];
また、TypeScript のタプル型は、以下のように可変長のタプル型の宣言が可能。
type NumAndStrings = [number, ...string[]]; const a1: NumAndStrings = [3, "foo", "bar"]; const a2: NumAndStrings = [5]; // エラー: Type 'string' is not assignable to type 'number'. const a3: NumAndStrings = ["foo", "bar"];
しかし、...
を使えるのはタプル型の最後に 1 回だけであり、以下のような型は宣言できない。
type NumAndStrings = [...number[], ...string[]];
また、以下のように?
がついた、オプショナルな要素を持つタプル型も定義できる。
type T = [string, number?]; const t1: T = ["foo"]; const t2: T = ["foo", 3];
オプショナルな要素は複数あっても問題ないが、オプショナルではない要素より後に来なければいけない。
そのため、[string?, number]
のような型は NG。
タプル型と可変長引数
TypeScript 3.0 から、関数の可変長引数の型にタプル型を利用できるようになった。
type Args = [string, number, boolean]; // func の型は (args_0: string, args_1: number, args_2: boolean) => number になる const func = (...args: Args) => args[1]; const v = func("foo", 3, true);
上記のようにタプル型を用いることによって、複数の引数の型をまとめて指定できる。
以下のように、可変長タプルを用いた場合、引数の可変長性が保たれる。
type Args = [string, ...number[]]; // func の型は (f: string, args_0: string, ...args_1: number[]) => string const func = (f: string, ...args: Args) => args[0]; const v1 = func("foo", "bar"); const v2 = func("foo", "bar", 1, 2, 3);
また、以下のようにオプショナルな要素を持つタプルの型を用いた場合、オプショナルな引数を持つ関数の型ができる。
type Args = [string, number?]; // func の型は (f: string, args_0: string, args_1?: number | undefined) => string const func = (f: string, ...args: Args) => args[0];
タプル型と可変長引数とジェネリクス
タプル型をとるような型変数を用いることで、関数の引数列をジェネリクスで扱うことができる。
例として、関数の最初の引数があらかじめ決まっている関数を作る、関数bind
を書いてみる。
// U は any[] 型であることを制約する function bind<T, U extends any[], R>( // bind の第1引数に渡された関数 func の第2引数以降の型が U 型になり、関数の戻り値の型が R 型になる。 // 例 // const add = (x: number, y: number) => x + y; // const add1 = bind(add, 1); // bind に渡している add の第2引数 y の型は number なので、U の型は [number] 型になる。 // また、add の戻り値の型は number 型なので、R 型は number になる。 func: (arg1: T, ...rest: U) => R, // bind の第2引数に渡された型が T型になる。 // 例 // bind(add, 1) の場合、T型が 1 になる。 value: T ): (...args: U) => R { return (...args: U) => func(value, ...args); } const add = (x: number, y: number) => x + y; // function bind<1, [number], number>(func: (arg1: 1, y: number) => number, value: 1): (y: number) => number const add1 = bind(add, 1); console.log(add1(5)); // 6 // Argument of type '"foo"' is not assignable to parameter of type 'number'. add1("foo");
union 型(合併型)
以下のように、複数の型のどれかに当てはまるような型を union 型と言う。
let value: string | number = "foo"; value = 100; value = "bar"; // エラー: Type 'true' is not assignable to type 'string | number'. value = true;
プリミティブ型だけでなくオブジェクトの型でも union 型を作ることができる。
interface Hoge { foo: string; bar: number; } interface Piyo { foo: number; baz: boolean; } type HogePiyo = Hoge | Piyo; const obj: HogePiyo = { foo: "hello", bar: 0 }; const obj: HogePiyo = { foo: 1, baz: false }; // エラー const obj3: HogePiyo = { hoge: "hello" };
union 型の絞り込み
上記のように、Hoge | Piyo
のような型の値が与えられる場合、実行時に値がどちらの型か判定する必要がある。
判定をしないと、以下のようにエラーが出る。
interface Hoge { foo: string; bar: number; } interface Piyo { foo: number; baz: boolean; } function useHogePiyo(obj: Hoge | Piyo): void { // この時点では obj が Hoge型 か Piyo型かわからないので、 // obj には bar プロパティが存在しない可能性があり、エラーが出る。 console.log(obj.bar); }
以下のように、in
演算子を使った if 文を書くことで、適切に型を絞り込んでくれる。
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); } }
ただし、この機能を利用すると、以下のようなコードを書けてしまうので注意が必要。
// 余計な bar プロパティがついたオブジェクト // 本来これは Piyo 型として扱いたいが、useHogePiyo に渡すと、 // "bar" in obj で true になるので、Hoge 型として扱われてしまう。 const obj: Hoge | Piyo = { foo: 123, bar: "bar", baz: true }; 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); } } useHogePiyo(obj);
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 チェック
それは nullable な値を扱いたい場合も、union 型を用いる。
例えば、文字列の値があるかもしれないし null かもしれないという状況はstring | null
という型で表現できる。
このような型を絞り込みたい場合は、以下のように if 文を利用する。
function func(value: string | null): number { if (value != null) { // valueはnullではないのでstring型に絞り込まれる return value.length; } else { return 0; } }
また、&&
や||
を利用した絞り込みもできる。
そのため、上記のfunc
は以下のようにも書ける。
function func(value: string | null): number { return (value != null && value.length) || 0; }
タグ付き union 型
以下のように、リテラル型で区別できる union 型をタグ付き union 型と言う。
interface Some<T> { type: "Some"; value: T; } interface Any { type: "Any"; } interface None { type: "None"; } // Option<T> 型は Some<T> 型と None 型の union 型。 // Some<T> 型と None 型を共通プロパティである type を保持している。 // type の型は 'Some'型 と 'None'型であり、それぞれリテラル型である。 // そのため、リテラル型で Option<T> 型が、Some<T> 型なのか None 型なのかを区別できる。 // このようにリテラル型で区別できる型をタグ付き union 型と言う。 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" }; } }
次のように 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>
にAny
型を追加しても if 文の場合はエラーが発生しない。
interface Some<T> { type: "Some"; value: T; } interface Any { type: "Any"; } interface None { type: "None"; } type Option<T> = Some<T> | Any | None; function map<T, U>(obj: Option<T>, f: (obj: T) => U): Option<U> { if (obj.type === "Some") { return { type: "Some", value: f(obj.value) }; } else { return { type: "None" }; } }
一方で、switch 文の場合はコンパイルエラーが出る(Any
型に対する処理が定義されておらずmap
関数が値を返さない可能性が生じてしまうため)。
interface Some<T> { type: "Some"; value: T; } interface Any { type: "Any"; } interface None { type: "None"; } type Option<T> = Some<T> | Any | None; // エラー // Function lacks ending return statement and return type does not include 'undefined'.ts(2366) 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" }; } }
union 型オブジェクトのプロパティ
オブジェクト同士の union 型を作った場合、そのプロパティアクセスの挙動は概ね期待通りの結果となる。
interface Hoge { foo: string; bar: number; } interface Piyo { foo: number; baz: boolean; } type HogePiyo = Hoge | Piyo; function getFoo(obj: HogePiyo): string | number { // obj.foo は string | number型 return obj.foo; }
配列の要素もオブジェクトのプロパティの一種なので、同じ挙動となる。
const arr: string[] | number[] = []; // string[] | number[] 型の配列の要素は string | number 型 const elm = arr[0];
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;
never 型が出現するタイミング
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: // 上記の case 文 によって obj の推論はすべて調べつくしている。 // この switch 文の中では、obj の値の候補がまったくない。それを表すために obj が never 型になる。 return obj; } }
以下のように関数の返り値でも never 型が出てくることもある。
// 関数が値を返す可能性が無い時は nerver 型になる。 // 返り値がないことを表す void 型とは異なり、関数が正常に終了して、値が返ってくるということがあり得ない場合を表す。 // 戻り値を型注釈をしないと void 型が推論されるので、nerver 型を明示する必要がある。 function func(): never { throw new Error("Hi"); } // result は never 型(result に何かが代入されることはあり得ない) const result = func();
intersection 型(交差型)
intersection 型は、以下のT & U
のような、T
でもありU
でもあるような型のこと。
interface Hoge { foo: string; bar: number; } interface Piyo { foo: string; baz: boolean; } // obj の型は以下の型になる // { // foo: string; // bar: number; // baz: boolean; // } const obj: Hoge & Piyo = { foo: "foooooooo", bar: 3, baz: true };
以下のように union 型と intersection 型を組みあわせることも可能。
interface Hoge { type: "hoge"; foo: string; } interface Piyo { type: "piyo"; bar: number; } interface Fuga { baz: boolean; } // Obj は以下のいずれかの型になる // { // type: "hoge"; // foo: string; // baz: boolean; // } // { // type: "piyo"; // bar: number; // baz: boolean; // } type Obj = (Hoge | Piyo) & Fuga; // ↑は type Obj = (Hoge & Fuga) | (Piyo & Fuga) とも書ける function func(obj: Obj) { // obj は必ず Fuga 型なので、baz を参照可能 console.log(obj.baz); if (obj.type === "hoge") { // Hoge & Fuga 型が推論される console.log(obj.foo); } else { // Piyo & Fuga 型が推論される console.log(obj.bar); } }
union 型を持つ関数との関係
前述の通り、関数型とそれ以外の型の union 型を作った場合、それを関数として呼ぶことはできない。
type Func = (arg: number) => number; interface MyObj { prop: string; } const obj: Func | MyObj = { prop: "" }; // obj は MyObj(object)型の可能性があるため、エラーが発生する // エラー: Cannot invoke an expression whose type lacks a call signature. // Type 'MyObj' has no compatible call signatures. obj(123);
また、以下のように union 型の構成要素がすべて関数型でも、引数が intersection 型になり関数を呼べないこともある。
// 文字列を受け取って文字列を返す関数型 type StrFunc = (arg: string) => string; // 数値を受け取って文字列を返す関数型 type NumFunc = (arg: number) => string; declare const func: StrFunc | NumFunc; // func の引数は string & number 型になっているため、数値を渡すとエラーが発生する。 // そもそも string & number 型をwたすことは不可能なため、この関数を呼ぶことはできない。 // エラー: Argument of type '123' is not assignable to parameter of type 'string & number'. // Type '123' is not assignable to type 'string'. func(123);
上記の挙動は TypeScript 3.3 からの新しい挙動である。
それ以前は、異なる型の関数同士の union は関数として呼ぶことができなかった。
引数の型が intersection 型の場合、以下のような引数を渡せば関数を呼べる。
interface Hoge { foo: string; bar: number; } interface Piyo { foo: string; baz: boolean; } type HogeFunc = (arg: Hoge) => number; type PiyoFunc = (arg: Piyo) => boolean; // func の型は (arg: Hoge & Piyo) => number | boolean 型になる。 declare const func: HogeFunc | PiyoFunc; // `Hoge & Piyo`型のオブジェクトを渡せば関数を呼べる。 const res = func({ foo: "foo", bar: 123, baz: false });