TypeScript の構造的部分型という概念は、直感的で分かりやすい。
だが一方で、Freshness やプリミティブ型の挙動など、よく理解しておかないと戸惑ってしまうような挙動も多い。
例えば、なぜ以下のコードがエラーにならないのか、最初は分からなかった。
const number: {} = 1; const string: {} = 'a'; const boolean: {} = true; const symbol: {} = Symbol('a');
また、下記のコードでbar
だけがエラーになるのも分かりづらい。
const point = {x: 1, y: 1}; const foo: {x: number} = point; // OK const bar: {x: number} = {x: 1, y: 1}; // Error const empty: {} = {x: 1, y: 1}; // OK
この記事では、これらの挙動を理解できるようになることをゴールとし、その過程を通して、構造的部分型について学んでいく。
動作確認は TypeScript のv3.7.5
で行っている。
構造的部分型
TypeScript では構造的部分型という考え方を採用している。
具体的には、2 つの構造体のプロパティをチェックし、それが一致すれば両者には互換性があると見做す。
下記のコードでは、Person
という型とBook
という型を定義している。
それぞれは独立した存在であり、継承関係などはない。
そしてgreet
という関数は、Person
型を引数として受け取る。
interface Person { name: string; } interface Book { name: string; category: string; } const greet = (person: Person): void => { console.log(`Hello! I'm ${person.name}.`); };
しかしgreet
にPerson
ではなくBook
を渡しても、エラーにはならない。
const alice: Person = {name: 'Alice'}; greet(alice); // Hello! I'm Alice. // greet に Book を渡してもエラーにならない const santai: Book = {name: '三体', category: 'Science Fiction'}; greet(santai); // Hello! I'm 三体.
これは、Book
はPerson
と互換性があると見做されるためである。
具体的には、name: string
というプロパティさえ持っていれば、その型はPerson
と互換性があると判断される。
Book
はname
の他にcategory
というプロパティを持っているが、これは問題にはならない。
TypeScript の構造的部分型では、必要なプロパティ(Person
の場合はname
)さえ存在すれば、それ以外のプロパティの有無は問わないためである。
そして「余分なプロパティは問題にならない」という性質上、空のオブジェクトは、どんなオブジェクトに対しても互換性を持つ。
const foo = {x: 1, y: 2, z: 3}; // OK const onlyX: {x: number} = foo; const onlyY: {y: number} = foo; const onlyZ: {z: number} = foo; const empty1: {} = foo; const empty2: {} = {bar: 'a'}; const empty3: {} = {array: [1]}; // Error const onlyA: {a: number} = foo;
プロパティの過剰が問題にならない一方、プロパティの不足は問題になる。
例えばBook
の場合、name
だけでなくcategory
もなければ、条件を満たさない。
interface Person { name: string; } interface Book { name: string; category: string; } const alice: Person = {name: 'Alice'}; const santai: Book = {name: '三体', category: 'Science Fiction'}; // OK const foo: Person = santai; // Error const bar: Book = alice; // Property 'category' is missing in type 'Person' but required in type 'Book'.
Freshness
TypeScript の構造的部分型では余分なプロパティは問題にならない、と書いてきたが、例外もある。
それが Freshness と呼ばれる挙動で、オブジェクトリテラルに対しては型のチェックが厳しくなり、余分なプロパティが許容されなくなる。
下記のコードでは全く同じ値をgreet
に渡しているが、一度変数に代入しておいた場合はエラーにならず、オブジェクトリテラルをgreet
に渡した場合のみエラーになっている。
interface Person { name: string; } interface Book { name: string; category: string; } const greet = (person: Person): void => { console.log(`Hello! I'm ${person.name}.`); }; const alice: Person = {name: 'Alice'}; const santai: Book = {name: '三体', category: 'Science Fiction'}; greet(santai); // OK greet({name: '三体', category: 'Science Fiction'}); // Error
なぜこのような紛らわしい挙動になっているのか分からなかったが、以下の記事を読んで納得できた。
オブジェクトリテラルを渡す際にわざわざ余分なプロパティを書いていればそれは、勘違いやタイプミスである可能性が高い。そのため、エラーを出すようにしている。
また、可読性を高める効果もある。
以下のようなコードを許可してしまうと、わざわざcategory
を渡しているのだから、greet
にcategory
を渡すことに意味があるように読めてしまう。だが実際には、この値はgreet
内で全く使われない。
greet({name: '三体', category: 'Science Fiction'});
{} には Freshness が適用されない
ややこしいのだが、{}
には Freshness が適用されない。
なぜこのような挙動をしているのかは、分からなかった。
// y という余分なプロパティをオブジェクトリテラルで渡しているため、エラーになる const onlyX: {x: number} = {x: 1, y: 2}; // x という余分なプロパティをオブジェクトリテラルで渡しているが、エラーにならない const empty: {} = {x: 1};
プリミティブ型と構造的部分型
{}
型はどんなオブジェクトも許容するだけでなく、null
とundefined
以外のプリミティブ型も許容する。
// OK const number: {} = 1; const string: {} = 'a'; const boolean: {} = true; const symbol: {} = Symbol('a'); // Error const foo: {} = null; const bar: {} = undefined;
この挙動を理解するためにまず、型としてのプリミティブ型について調べていく。
keyof
演算子は、プロパティ名の Union Types を返す。
これを利用することで、対象となる型がどんな名前のプロパティを持っているのか調べることができる。
interface Point { x: number; y: number; } // 以下の結果から、Point には x と y の 2 つのプロパティがあることを確認できる // type keyOfPoint = "x" | "y" type keyOfPoint = keyof Point;
これをプリミティブ型に対して使ってみる。
// type keyOfNumber = "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString" type keyOfNumber = keyof number; // type keyOfString = number | "toString" | "charAt" | "charCodeAt" | "concat" | 以下略 type keyOfString = keyof string; // type keyOfBoolean = "valueOf" type keyOfBoolean = keyof boolean; // type keyOfSymbol = "toString" | "valueOf" type keyOfSymbol = keyof symbol;
この結果を見ると、プリミティブ型はプロパティを持った構造体でもある、と言える。
そしてこのプロパティは、各プリミティブ型に対応するインスタンスオブジェクトのプロトタイプメソッドと、ほぼ一致する。
例えば数値型の場合、constructor
以外は一致している。
const one = new Number(1); const porototype = Object.getPrototypeOf(one); // 'constructor', 'toString', 'toFixed', 'toExponential', 'toPrecision', 'valueOf', 'toLocaleString' console.log(Object.getOwnPropertyNames(porototype)); // type keyOfNumber = "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString" type keyOfNumber = keyof number;
若干の差異はあるが、他のプリミティブ型も同様の結果になる。
余談だが、keyof string
の結果にnumber
が含まれるのは、文字列に対してはブラケット記法で数値を渡せるためである。
'abc'[0] // 'a'
ここまでの内容をまとめると、プリミティブ型(null
とundefined
を除く)は、型のチェックを行う際、プリミティブ型そのものとしてだけでなく、インスタンスオブジェクトとしても、扱われる。どちらに対しても互換性を持った型になる。
以下のone
は、number
型であると同時に、toFixed
というメソッドを持った構造体でもある。
数値型はtoFixed
メソッド以外にもtoExponential
などのメソッドを持つが、構造的部分型では余分なプロパティは問題にならないため、HasToFixed
型と互換性があると判断される。
そして、オブジェクトリテラル以外は Freshness の対象外なので、値を直接代入しても問題ない。
const one = 1; interface HasToFixed { toFixed: Function; } const foo: number = one; // OK const bar: HasToFixed = one; // OK const baz: HasToFixed = 1; // OK const qux: HasToFixed = new Number(1); // OK
なお、インスタンスオブジェクトはプリミティブ型に対して互換性を持たないので、注意する。
const bar: Number = 1; // OK const foo: Number = new Number(1); // OK const baz: number = new Number(1); // Error
プリミティブ型(null
とundefined
を除く)は構造体としても扱われる、そして、{}
はどのような内容の構造体でも受け入れる。
そのため冒頭で示したように、{}
はプリミティブ型(null
とundefined
を除く)と互換性を持つのである。
// OK const number: {} = 1; const string: {} = 'a'; const boolean: {} = true; const symbol: {} = Symbol('a'); // Error const foo: {} = null; const bar: {} = undefined;
ちなみに、「プリミティブな値は受け入れず、オブジェクトしか受け入れない型」は、object
型で表現できる。
// OK const foo: object = {}; const bar: object = []; const baz: object = new Number(1); // Error const qux: object = 1; const quux: object = 'a';