オブジェクト型の重箱の隅

TypeScript のオブジェクト型について比較的触れられる機会が少ないこと (重箱の隅) をただ集めただけの記事です. オブジェクト型やその周辺に明るくなりたい人, または任意の重箱の隅が好きな人向け.

挙動の確認は 2022-11-06 時点の最新版である 4.8.4 で行っています.

オブジェクト型

TypeScript におけるオブジェクト型 (object types) は, その名の通りオブジェクトに対応する型です. 例えば以下のようなものがオブジェクト型に含まれます.

// 空のオブジェクト
type Empty = {};

// プロパティやメソッドなどメンバーを持つことができる
type Human = {
  name: string;
  greet(): void;
};

プリミティブ値との関係

JavaScript では undefinednull を除く全てのプリミティブ値に対して, あたかもオブジェクトであるかのようにプロパティやメソッドの参照ができます. TypeScript のオブジェクト型もこのことを反映して, undefinednull を除くプリミティブ値に対応する型 (number, string など) がオブジェクト型の部分型となっています. したがって, 以下のようなコードも型検査を通過します1.

// 数値型 (number) は {} の部分型なので OK
const x: {} = 42;

また TypeScript 4.8 以降は unknown (任意の型の上位型) と {} | undefined | null がほぼ同じ意味になりましたが, ここからも {}undefinednull を除くプリミティブ値も全て含んでいることがわかると思います.

別の「オブジェクト」型

紛らわしいことに, TypeScript には object という名前の型があります. これはこの記事で紹介するオブジェクト型とは異なり, プリミティブ値を含まない真のオブジェクトのみを表す型で, 非プリミティブ型 (non-primitive type) と呼び分けるのが一般的です.

const x: object = 42;
//    ~
// Type 'number' is not assignable to type 'object'. (2322)

さらに紛らわしいことに Object という名前の型もあります. こちらは Object.prototype という値のための型で, ふつうに生活していれば一生使うことはないでしょう.

空のオブジェクト型

空のオブジェクト型 {} はまったくメンバーを持たないように見えますが, 実は Object.prototype から継承したプロパティやメソッドについてはアクセスすることができます.

例えば以下のようなコードは型検査を通過します.

declare const x: {};

x.toString();
x.hasOwnProperty("foo");

一方で, 本当に Object.prototype から継承したメソッドを呼び出すべき場面は少ないですし, さらにはメソッドが上書きされている可能性もあるため, こういったコードを積極的に書くことは推奨しません.

const x: {} = {
  hasOwnProperty: 42,
};

x.toString();
// = "[object Object]"
// この文字列が得られても基本的には使い道がない

x.hasOwnProperty("foo");
// → 実行時エラー (TypeError: x.hasOwnProperty is not a function)

このような見えないメンバーについては後述の keyof などでも列挙されません.

interface を使ったオブジェクト型の定義

オブジェクト型は interface を使って定義することもできます. 例えば上の Humaninterface を使って定義すると以下のようになります.

interface Human {
  name: string;
  greet(): void;
}

type Human = ... のように型エイリアスを使ってオブジェクト型に名前を付けた場合との違いはいくつかありますが,

くらいを覚えておけば良いでしょう.

class を使ったオブジェクト型の定義

class 宣言によってもオブジェクト型が定義されます. 定義される型は interface とほぼ同じですが,

  • 定義のマージは行われない
  • privateprotected といったアクセス修飾子を持ったメンバーを含むことができる

といった違いがあります.

オブジェクト型のメンバー

オブジェクト型にメンバーとして含められるものには以下の種類があります.

  • プロパティ
  • Index signatures
  • メソッド
  • Call signatures
  • Construct signatures

プロパティ

オブジェクト型のプロパティは, その名の通りオブジェクトにプロパティが存在することを表します.

type T = {
  foo: number;
  bar: string;
  greet: () => void;
};

declare const x: T;
const foo: number = x.foo;
x.greet();

プロパティや後述のメソッドのキーは computed property keys の形で記述することもできます. 特に Symbol をキーに使ったプロパティやメソッドについてはこの方法でしか定義できません.

type T = {
  ["foo"]: number;
  [Symbol.iterator]: () => Iterator<string>;
};

よく見ると実は [...] に入るのは型レベルではなく値レベルの式 (の一部) というところが地味に注意すべきポイント.

Index Signatures

辞書のように任意のキーを持つオブジェクトのプロパティは index signatures を使って書くことができます.

type T = {
  [key: string]: number | undefined;
  [key: symbol]: string | undefined;
};

declare const x: T;
const foo: number | undefined = x.foo;
const itr: string | undefined = x[Symbol.iterator];

[key: T] のうち key の部分は単なるラベルで, 機能的な意味はありません. また T には string | number | symbol の部分型のみ指定が可能です.

キーが stringnumber の index signature を両方定義することも可能ですが, JavaScript のオブジェクトにおいて数値によるアクセス x[0] が文字列によるアクセス x["0"] と等価なことを反映して, キーが number の index signature の型は, キーが string の index signature の型の部分型である必要があります.

type T = {
  [key: string]: number | undefined;
  [key: number]: string | undefined;
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// 'number' index type 'string | undefined' is not assignable to 'string' index type 'number | undefined'.(2413)
};

プロパティとそれを包含する index signature の両方が存在する場合も, プロパティの型が index signature の部分型である必要があります.

type T = {
  [key: string]: number | undefined;
  foo: string;
// ~~
// Property 'foo' of type 'string' is not assignable to 'string' index type 'number | undefined'.(2411)
};

なお交差型を使った場合は index signature と互換性のないプロパティの共存もできて, この場合はアクセスするとプロパティの方が優先されます.

type T = {
  [key: string]: number | undefined;
} & {
  foo: string;
};

declare const x: T;
const foo: string = x.foo;
const bar: number | undefined = x.bar;

Object.prototype から継承したメンバーと index signature のどちらが優先されるかについては空気読みが発生して, アクセスの方法に依存します. . によるアクセスでは前者, [] によるアクセスでは後者です.

type T = {
  [key: string]: number | undefined;
};

declare const x: T;
// . によるアクセスでは Object.prototype が優先
const a: () => string = x.toString;
// [] によるアクセスでは index signature が優先
const b: number | undefined = x["toString"];

ところで index signature にはいくつか型安全性の問題があり, かつ現代においては辞書や集合を表すのにより適した MapSet もあるので, あえて使うのは JSON をそのまま表現する場合などに限られるでしょう.

メソッド

オブジェクト型にはプロパティとは別にメソッドを含めることができます.

type T = {
  greet(): void;
};

プロパティとして関数を定義した場合とメソッドの違いはそれぞれの引数についての変性 (variance) の違いで, プロパティの場合は反変 (contravariant) ですが, メソッドの場合は双変 (bivariant) となります.

例えばプロパティの場合, 以下のように numbernumber | string の部分型であるため ComparableP<number | string>ComparableP<number> の部分型ですが, 逆の関係は成り立ちません.

type ComparableP<T> = {
  compare: (arg: T) => number;
};

declare const p1: ComparableP<number>;
const p2: ComparableP<number | string> = p1;
//    ~~
// Type 'ComparableP<number>' is not assignable to type 'ComparableP<string | number>'.
//   Type 'string | number' is not assignable to type 'number'.
//     Type 'string' is not assignable to type 'number'.(2322)

// 確かに p1 は引数に string が与えられることを想定していないので,
// p2: ComparableP<number | string> に代入ができると困る
p2.compare("fish");

メソッドの場合は同じように ComparableM<number | string>ComparableM<number> の部分型ですが, 逆に ComparableM<number>ComparableM<number | string> の部分型でもあります.

type ComparableM<T> = {
  compare(arg: T): number;
};

declare const m1: ComparableM<number>;
const m2: ComparableM<number | string> = m1; // エラーなし

// m1 も引数に string が与えられることを想定していないので困るが...?
m2.compare("fish");

なぜこんなことになっているかというと, readonly T[] などは型引数 T について共変 (covariant) なものとして扱いたいことが普通なのに, indexOf のようなメソッドの引数に T が登場していて2, 上のようにメソッドの引数を反変ではなく双変としないと readonly T[] 全体としては不変 (invariant) になってしまい, これだとあまりに不便なためです (実装時の PR も参考).

この双変性というのはメソッドの型を単体で取り出した場合にも残っていて, 一見すると普通の関数に見えて引数の変性が異なるということがあります.

type ComparableM<T> = {
  compare(arg: T): number;
};
declare const c1: ComparableM<number>["compare"];
const c2: ComparableM<number | string>["compare"] = c1; // エラーなし

ここまでの例からもわかるように双変性というのは型安全ではないため, それを理解した上でのハック目的3や, typescript-eslint の unbound-method ルールを使いたいといったことがなければ, あえてプロパティではなくメソッドを使う必要はないでしょう.

Call Signatures

オブジェクトが関数として呼び出せることを表すために, 他のメンバーに加えて call signatures を含めることもできます.

type T = {
  (arg: number): string;
};

declare const f: T;
const r: string = f(42);

上の例の型 T の定義は, 実は以下の関数型による定義と等価です.

type T = (arg: number) => string;

一つのオブジェクト型に複数の call signature を含めることもできて, この場合は関数がオーバーロードされていることを表します.

type T = {
  (arg: number): string;
  (arg: string): number;
};

この定義は以下と等価です.

declare function f(arg: number): string;
declare function f(arg: string): number;

type T = typeof f;

オブジェクト型が call signature または次に紹介するの construct signature を含む場合, 通常のオブジェクト型が Object.prototype から継承したプロパティやメソッドを暗黙的に持つのと同様に, Function.prototype から継承したプロパティやメソッドを暗黙に持つことになります. 以下は Function.prototype から継承されたメソッド call を呼び出している例です.

type T = {
  (arg: number): string;
};

declare const f: T;
f.call(undefined, 42);

Construct Signatures

Call signatures の亜種で, オブジェクトがコンストラクタ関数として new 演算子を使って呼び出せることを表すための construct signatures というものもあります.

type T = {
  new (arg: number): string;
};

declare const f: T;
const r: string = new f(42);

複数個書いた場合にオーバーロードの意味になるといった特徴は call signatures と同様です.

メンバーに対する修飾子

オブジェクト型のプロパティと index signatures については修飾子 (modifier) を付与することができます.

readonly 修飾子

プロパティと index signatures にはそれぞれ readonly 修飾子を付与することができます. これはその名の通りプロパティが読み取り専用であることを表していて, 代入して変更しようとしたりするとコンパイルエラーになります.

type T = {
  readonly foo: number;
  readonly [key: string]: number | undefined;
};

declare const x: T;

const foo: number = x.foo;
x.foo = 42; //
// ~~
// Cannot assign to 'foo' because it is a read-only property.(2540)

const bar: number | undefined = x.bar;
x.bar = 42;
// ~~
// ndex signature in type 'T' only permits reading.(2542)

ただし readonly 修飾子のない型へのキャストは無条件でできてしまう点には注意が必要です.

type T = {
  readonly foo: number;
};
type U = {
  foo: number;
};

declare const x: T;
const y: U = x;
y.foo = 42; // エラーなし

オプショナル修飾子

プロパティに対してオプショナル修飾子 ? を付与することで, そのプロパティが存在しないかもしれないことを表すことができます.

type T = {
  foo?: number;
};

デフォルト (strict のみ) の設定では, 上の例の foo には以下の 3 通りの可能性があります.

  • プロパティが存在して, 値は number のいずれか
  • プロパティが存在して, 値は undefined
  • プロパティが存在しない

exactOptionalPropertyTypes が有効な場合は以下の 2 つになります.

  • プロパティが存在して, 値は number のいずれか
  • プロパティが存在しない

しばしば foo: number | undefined の略記法と誤解されていますが, 上記の通り異なった意味を持ちます.

readonly とは異なり, index signatures には付与することができません. そもそも index signature 自体が存在しないかもしれないという意味を内包しているからでしょう.

いずれにせよ, index signatures と同様に型安全性の問題があるため, JSON の構造を表現したり, オプショナルなパラメータを表したりするといった場合を除いては使用しない方が無難です.

部分型関係

オブジェクト型同士の幅や深さといった部分型関係については特筆すべきことはないので省略.

その他にここまでの内容で部分型関係に関連して注目すべきことは,

  • undefined, null を除くプリミティブ型 (number, string など) はオブジェクト型の部分型である
  • メソッドは引数について双変であるため, 型安全でなくなっている
  • Index signatures とオプショナル修飾子については部分型判定に穴があり, 型安全でなくなっている

あたりでしょうか.

keyof によるメンバーの列挙

keyof によってオブジェクト型のキーを列挙した場合は以下のような挙動になります.

  • プロパティとメソッドはそのままキーが列挙されます
  • Index signatures はキーが string ならば string | number, キーが numbersymbol ならばそのまま numbersymbol となります
  • Call signatures と construct signatures は列挙されません
  • Object.prototypeFunction.prototype から継承されたメンバーは列挙されません

Mapped Types

Mapped types はキーの集合からオブジェクト型を作るための構文です.

type Keys = "foo" | "bar" | "baz";

type T = { [K in Keys]: boolean };
// = {
//   foo: boolean;
//   bar: boolean;
//   baz: boolean;
// }

見た目は index signatures に似ていますが, 上で見たようなオブジェクト型のメンバーの記述とは異なる機能です. 例えば複数の mapped type を { ... } の中に記述することはできませんし, 他のメンバーと並べて書くこともできません.

マップ元のキーの集合 (上の例での Keys の部分) は string | number | symbol の部分型である必要があります. またキーの集合に string, number, symbol が与えられた場合はプロパティではなく index signature に変換されます.

type T = { [K in string | number | symbol]: boolean };
// = {
//   [x: string]: boolean;
//   [x: number]: boolean;
//   [x: symbol]: boolean;
// }

プロパティごとの型定義 (: の右側) では, キーの集合に含まれる個別のキー (上の例での K の部分) を取り出して使うこともできます.

type Keys = "foo" | "bar" | "baz";

type T = { [K in Keys]: Uppercase<K> };
// = {
//   foo: "FOO";
//   bar: "BAR";
//   baz: "BAZ";
// }

また as を使うとキーを変換した上でオブジェクト型を作ることもできます.

type Keys = "foo" | "bar" | "baz";

type T = { [K in Keys as Uppercase<K>]: boolean };
// = {
//   FOO: boolean;
//   BAR: boolean;
//   BAZ: boolean;
// }

as でキーが never に変換されるとそのキーは消えます.

type Keys = "foo" | "bar" | "baz";

type T = { [K in Keys as K extends "foo" ? never : K]: boolean };
// = {
//   bar: boolean;
//   baz: boolean;
// }

オブジェクト型のキーを使った mapped type

オブジェクト型のキーを keyof で列挙した上で mapped type で別のオブジェクト型を作ることもできますが, この場合には特別扱いがあり, キーだけでなく修飾子も合わせて新しいオブジェクト型にコピーされます.

type Obj = {
  foo: number;
  readonly bar: string;
  baz?: number[];
};
type T = { [K in keyof Obj]: boolean };
// = {
//   foo: boolean;
//   readonly bar: boolean;
//   baz?: boolean;
// }

// キーを mapped type の外で列挙している場合は修飾子はコピーされない
type Keys = keyof Obj;
type U = { [K in Keys]: boolean };
// = {
//   foo: boolean;
//   bar: boolean;
//   baz: boolean;
// }

ただし, index signature の readonly 修飾子については (なぜか) コピーされません.

type Obj = {
  readonly foo: number;
  readonly [key: string]: number | undefined;
};

type T = { [K in keyof Obj]: boolean };
// = {
//   readonly foo: boolean;
//   [x: string]: boolean;
// }

またこの例で, もし mapped type の外で keyof Obj でキーを列挙した場合は string | number となるはずですが, 結果から分かるようにここにも特別扱いがされて, 元のキーである "foo"string が維持されています.

型引数のキーを使った mapped type

エイリアスの中で型引数のキーを keyof で列挙した上で mapped type を使った場合はさらなる特別扱いがあり, 配列やタプルがそのまま配列やタプルの形を保ちます.

以下の例では型エイリアスの型引数に対して mapped type を使った場合と, それをインライン展開した場合の結果を比較しています. 前者は配列の要素の型が boolean に変わっただけでそのまま配列になっていますが, 後者は配列のプロパティやメソッドも全て boolean に変換されてしまっています.

type A = number[];

type Boolify<T> = { [K in keyof T]: boolean };
type T = Boolify<A>;
// = boolean[]

type U = { [K in keyof A]: boolean };
// = {
//   [x: number]: boolean;
//   length: boolean;
//   toString: boolean;
//   ...
// }

なお MapSet などにはこういった特別扱いはありません.

また distributive conditional types と同様に, 型引数にユニオン型を与えた場合は, ユニオン型の各要素ごとに mapped type が適用されたような結果となります.

type A = { foo: number } | { bar: string };

type Boolify<T> = { [K in keyof T]: boolean };
type T = Boolify<T>;
// = { foo: boolean } | { bar: boolean };

// ユニオン型の要素に共通のメンバーがないので keyof A = never となり, 結果は空になる
type U = { [K in keyof A]: boolean };
// = {}

修飾子の付け外し

プロパティと同様に mapped types にも readonly? といった修飾子を付与することができます.

type Keys = "foo" | "bar" | "baz";

type T = { readonly [K in Keys]?: boolean };
// = {
//   readonly foo?: boolean;
//   readonly bar?: boolean;
//   readonly baz?: boolean;
// }

逆にオブジェクト型のキーに元々修飾子が付いていた場合は -readonly-? を使って外すこともできます.

type Obj = {
  foo: number;
  readonly bar: string;
  baz?: number[];
};

type T = { -readonly [K in keyof Obj]-?: boolean };
// = {
//   foo: boolean;
//   bar: boolean;
//   baz: boolean;
// }

型引数のキーを使った mapped types の場合, 配列 T[] やタプル [T, U] は,

  • readonly 修飾子を付与した場合は, 読み取り専用の配列 readonly T[] やタプル readonly [T, U] に変換される
  • ? 修飾子を付与した場合は, 各要素に | undefined が付与される

といった変換がされます. 修飾子を外した場合はこの逆です.

// 組み込みのユーティリティ型なので定義は不要
// type Readonly<T> = { readonly [K in keyof T]: T[K] };
// type Partial<T> = { [K in keyof T]?: T[K] };

type T = Readonly<number[]>;
// = readonly number[]

type U = Partial<number[]>;
// = (number | undefined)[]

MapSet にも ReadonlyMapReadonlySet がありますが, やはりこれらには配列のような特別扱いはありません.


  1. フロー解析による narrowing を行う上では型 {} が付いた値は常に truthy という扱いになっていて, これは 0 など反例があり矛盾してしまうこともあるのですが, それはまた別の話
  2. そもそも共変になるように注意深くメソッドの定義をするべきであって, そこがあまりよくないという話でもある
  3. React の型定義にある bivarianceHack など