30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

TypeScript の構造的部分型とプリミティブ型について

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}.`);
};

しかしgreetPersonではなく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 三体.

これは、BookPersonと互換性があると見做されるためである。
具体的には、name: stringというプロパティさえ持っていれば、その型はPersonと互換性があると判断される。

Booknameの他に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

なぜこのような紛らわしい挙動になっているのか分からなかったが、以下の記事を読んで納得できた。

typescript-jp.gitbook.io

オブジェクトリテラルを渡す際にわざわざ余分なプロパティを書いていればそれは、勘違いやタイプミスである可能性が高い。そのため、エラーを出すようにしている。

また、可読性を高める効果もある。
以下のようなコードを許可してしまうと、わざわざcategoryを渡しているのだから、greetcategoryを渡すことに意味があるように読めてしまう。だが実際には、この値はgreet内で全く使われない。

greet({name: '三体', category: 'Science Fiction'});

{} には Freshness が適用されない

ややこしいのだが、{}には Freshness が適用されない。
なぜこのような挙動をしているのかは、分からなかった。

// y という余分なプロパティをオブジェクトリテラルで渡しているため、エラーになる
const onlyX: {x: number} = {x: 1, y: 2};

// x という余分なプロパティをオブジェクトリテラルで渡しているが、エラーにならない
const empty: {} = {x: 1};

プリミティブ型と構造的部分型

{}型はどんなオブジェクトも許容するだけでなく、nullundefined以外のプリミティブ型も許容する。

// 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'

ここまでの内容をまとめると、プリミティブ型(nullundefinedを除く)は、型のチェックを行う際、プリミティブ型そのものとしてだけでなく、インスタンスオブジェクトとしても、扱われる。どちらに対しても互換性を持った型になる。

以下の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

プリミティブ型(nullundefinedを除く)は構造体としても扱われる、そして、{}はどのような内容の構造体でも受け入れる。
そのため冒頭で示したように、{}はプリミティブ型(nullundefinedを除く)と互換性を持つのである。

// 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';

参考資料