TypeScript をより安全に使うために その 2: オブジェクトの具体的な形にアクセスするのを避ける

前回はこちら.

susisu.hatenablog.com

引き続き環境は以下を前提とします:

  • TypeScript 4.4 (この記事を書いている 2021 年 11 月時点の最新版)
  • strict: true

原則: オブジェクトの具体的な形にアクセスするのを避ける

ここで「オブジェクトの具体的な形にアクセスする」とは, 静的な型によらずに, 実行時にオブジェクトがどのようなプロパティを持っているかといった情報を取得することを指しています. ある種のリフレクションと呼んでも良いかもしれません.

こういった操作には, JavaScript / TypeScript 標準の機能では以下のようなものが含まれます:

TypeScript の設計方針として, 静的な型に関する情報は実行時には一切残らないようになっています.

Add or rely on run-time type information in programs, or emit different code based on the results of the type system. Instead, encourage programming patterns that do not require run-time metadata.

したがって, 上のような機能が静的な型を参照して動作を変えることはなく, それゆえ部分型のように一つの値に複数通りの型付けが考えられる場合において, 実行時に取得したオブジェクトの具体的な形に関する情報は, そのオブジェクトの静的な型とは必ずしも完全には一致しません.

例えば以下のようにオブジェクトのプロパティが少なくなる方向にキャストが行われた場合, 静的な型には含まれていないように見えるプロパティであっても, 実行時に含まれていれば取得できてしまいます.

const x: { foo: number; bar: string } = { foo: 42, bar: "xxx" };
const y: { foo: number } = x;
console.log(Object.entries(y)); // => [["foo", 42], ["bar", "xxx"]]

その一方で, TypeScript の型システムやライブラリの型定義には, 上記のようなs機能で取得した具体的なオブジェクトの形に関する情報が, 静的な型と完全に一致することを仮定したふるまいをする箇所がいくつかあります. 実際, 上の例の Object.entries(y) の型は [string, number][] で, 見てのとおり安全ではありません. このような設計になっているのは, 前の記事でも述べたように利便性と天秤にかけて判断された結果なのでしょう.

こういった TypeScript の設計判断を鑑みると, 安全性を優先したい場合は, 基本的にはオブジェクトの具体的な形にアクセスする機能は避けておくのが賢明です. とはいえ全く利用しないというのは現実的ではないため, 以下のような指針を持って利用することを推奨します:

  • Object spread ... はオブジェクトリテラルの先頭でのみ使用する
  • Object.keys, Object.values, Object.entries は後述するような安全な場合に限定して使用する
  • その他は使用しない

以下ではなぜこのような指針で利用すべきなのか, つまりこれ以外の利用方法ではどのようにして安全性が損なわれてしまうのかについて, いくつか具体的に紹介していきます.

具体例

in 演算子による型の絞り込み

in 演算子を使うと, プロパティの有無に応じて変数の型を絞り込むこと (narrowing) が可能です.

例えば以下の関数 getUrl では, in 演算子によって引数 repo がプロパティ url を持っているかどうかに応じて型が絞り込まれています.

type GitRepository =
  | { url: string }
  | { owner: string; name: string };

function getUrl(repo: GitRepository): string {
  if ("url" in repo) {
    // repo: { url: string }
    return repo.url;
  } else {
    // repo: { owner: string; name: string }
    return `https://github.com/${repo.owner}/${repo.name}.git`;
  }
}

console.log(getUrl({ url: "https://example.com/repo.git" }));
// => "https://example.com/repo.git"

console.log(getUrl({ owner: "microsoft", name: "TypeScript" }));
// => "https://github.com/microsoft/TypeScript.git"

ここでオブジェクト型はプロパティがより少ない型の部分型であること思い出しましょう. つまり以下のように, GitRepository 型の 2 つめのパターン { owner: string; name: string } に余計にプロパティ xxx が付与されたような値は, アップキャストして GitRepository 型の値としても扱えることを思い出してみましょう.

const repo = {
  owner: "susisu",
  name: "typefuck",
  xxx: 666,
};
console.log(getUrl(repo));
// => "https://github.com/susisu/typefuck.git"

これだけであれば特に問題はありません. ではこの余計なプロパティの名前が, 1 つめのパターンのプロパティ url と衝突した場合はどうでしょうか.

const repo = {
  owner: "susisu",
  name: "typefuck",
  url: 666,
};
console.log(getUrl(repo));
// => 666

なんとこの場合も型エラーにはなりません. そして結果が期待したものではないことに加えて, getUrl の戻り値の型が string と宣言されているにも関わらず数値が返っており, 明らかに正しくない状態となってしまっています.

この問題は in 演算子が上の例ようなアップキャストは行われないものとして動作し, 型を絞り込んでしまうために起こります. これを防ぐためには, in 演算子を使ってプロパティの有無に応じた分岐をするのではなく, 各パターンに絞り込みのためのプロパティ (タグ) を含めておき, それを元に分岐を行うようにしましょう. このようにした union 型は discriminated unions または tagged unions と呼ばれており, TypeScript では極めてよく使うテクニックなので覚えておきましょう.

type GitRepository =
  | { type: "url"; url: string }
  | { type: "github"; owner: string; name: string };

function getUrl(repo: GitRepository): string {
  if (repo.type === "url") {
    // repo: { type: "url"; url: string }
    return repo.url;
  } else {
    // repo: { type: "github"; owner: string; name: string }
    return `https://github.com/${repo.owner}/${repo.name}.git`;
  }
}

const repo = {
  type: "github" as const,
  owner: "susisu",
  name: "typefuck",
  url: 666,
};
console.log(getUrl(repo));
// => "https://github.com/susisu/typefuck.git"

ちなみに discriminated union を使っていない元の例でも, 以下のように直接オブジェクトリテラルを関数に渡すと TypeScript はエラーを出してくれます. とはいえオブジェクトリテラルを直接渡すようにしたところで型に変化はないはずなので, これは純粋な型エラーというよりは TypeScript コンパイラによるお節介という側面が強いです.

// Types of property 'url' are incompatible.
console.log(getUrl({
  owner: "susisu",
  name: "typefuck",
  url: 666,
}));

もし引数をこのようにリテラルで渡すことしかない (名前付き引数として利用する) のであれば, わざわざタグを付与しないといけない discriminated union を使うよりも, 利便性を優先して in 演算子による絞り込みを選択することもないわけではないでしょう.

Object.keys の誤った使用

これは TypeScript ではなく人間が悪い例なのですが, しばしば見かけるのでここで紹介します.

例えば以下のような TypeScript のコードを考えてみましょう.

type Counts = { foo: number; bar: number; baz: number };

function calcTotal(counts: Counts): number {
  let total = 0;
  for (const key of Object.keys(counts)) {
    // key の型が string になってしまい, t[key] とはアクセスできない.
    // key の型は keyof Counts のはずなので as でキャストする.
    total += counts[key as keyof Counts];
  }
  return total;
}

const counts = { foo: 1, bar: 2, baz: 3 };
console.log(calcTotal(counts)); // => 6

ここで Object.keys の型定義は以下のようになっています (lib.es2015.core.d.ts より編集して抜粋).

interface ObjectConstructor {
  keys(o: {}): string[];
}

つまり Object.keys の戻り値の型は引数によらず常に string[] です. したがって上のコードでは key as keyof Counts のように as を使ったキャストが必要になってしまっていました.

さて勘の良い方ならもう気がついていると思いますが, 「key の型は keyof Counts のはず」という仮定は一般には誤りで, アップキャストによって関数の引数には Counts には列挙されていないプロパティが含まれる可能性があります.

const badCounts = { foo: 1, bar: 2, baz: 3, qux: "66" };
console.log(calcTotal(badCounts)); // => "666"

この例では TypeScript が折角安全な型を提供してくれているのに, 人間がそれを無視して as によるダウンキャストを行ってしまっていました. as は基本的には危険な道に足を踏み入れているサインなので, 余程の自信がなければ踏みとどまるようにしましょう.

ここでの calcTotal のような関数を書く場合は, プロパティの列挙に Object.keys を使うのではなく, 予め必要なプロパティを手で列挙しておくようにしましょう. これはとても面倒そうに聞こえるかもしれませんが, 実はプロパティの列挙を行いたいような状況では大抵は型を mapped type を使って定義できるので, 以下のように大きな手間なく記述できることが多いです.

// countKeys: readonly ["foo", "bar", "baz"]
const countKeys = ["foo", "bar", "baz"] as const;
// CountKey = "foo" | "bar" | "baz"
type CountKey = typeof countKeys[number];

// Counts = { foo: number; bar: number; baz: number };
type Counts = { [K in CountKey]: number };

function calcTotal(counts: Counts): number {
  let total = 0;
  for (const key of countKeys) {
    total += counts[key];
  }
  return total;
}

const badCounts = { foo: 1, bar: 2, baz: 3, qux: "66" };
console.log(calcTotal(badCounts)); // => 6

Object.values, Object.entries の使用

Object.entries は最初にも紹介しましたが, これらは戻り値の型と実際の値が矛盾してしまうことがあります.

const x: { foo: number; bar: string } = { foo: 42, bar: "xxx" };
const y: { foo: number } = x;

const vs: number[] = Object.values(y);
// = [42, "xxx"]
const es: [string, number][] = Object.entries(y);
// = [["foo", 42], ["bar", "xxx"]]

Object.values, Object.entries の型定義はそれぞれ以下のようになっています (lib.es207.core.d.ts より編集して抜粋).

interface ObjectConstructor {
  values<T>(o: { [s: string]: T } | ArrayLike<T>): T[];
  values(o: {}): any[];

  entries<T>(o: { [s: string]: T } | ArrayLike<T>): [string, T][];
  entries(o: {}): [string, any][];
}

それぞれ引数の型を元に戻り値の型を決定していますが, 上記のようなプロパティが減るようなアップキャストについては考慮されていません.

プロパティの列挙をしたい場合は基本的にはこれらを使うのではなく, Object.key の節で紹介したような, 予めプロパティを列挙しておく方法を使用しましょう. ただし例外として, 引数に対してプロパティが減少するアップキャストが行われていないことが保証できるのであれば, これらの関数を使用することに安全性の面での問題はありません.

また index signature を持つオブジェクトに対して使用するのであれば, アップキャストに伴う問題は発生しづらいため, ある程度許容できると言えます. ただし次回の記事で index signature について詳しく紹介しますが, これでも完全に安全であるというわけではありません. そもそもこのようなデータには Map を使うことも検討しましょう.

function calcTotal(counts: { [key: string]: number }): number {
  let total = 0;
  for (const value of Object.values(counts)) {
    total += value;
  }
  return total;
}

リテラルの先頭以外での object sperad

オブジェクトリテラルでの spread syntax ... は, オブジェクトのプロパティを列挙し, 新しく作られるオブジェクトへコピーします (以下「展開」と呼びます).

const a1 = { x: 0, y: "xxx" };

const b1 = { ...a1, z: true };
// b1: { x: number; y: string; z: boolean }
// b1 = { x: 0, y: "xxx", z: true }

この spread syntax がリテラルの先頭以外に書かれている場合, それよりも前に同名のプロパティが存在していれば上書きするという動作をします.

const a2 = { x: 0, y: 1 };
const b2 = { y: "xxx", z: true };

const c2 = { ...a2, ...b2 };
// c2: { x: number; y: string; z: boolean }
// c2 = { x: 0, y: "xxx", z: true }

Object spread を使って作られた新しいオブジェクトの型は, 展開されたオブジェクトの型を, 上書きの挙動に従ってマージしたものになっています.

ここで毎度おなじみのプロパティが減るアップキャストが行われた場合を考えてみましょう.

const a3 = { x: 0, y: "xxx" };
const b3: { x: number } = a3;

const c3 = { y: true, ...b3 };
// c3: { x: number; y: boolean }
// c3 = { x: 0, y: "xxx" }

TypeScript はオブジェクトの型に明示的に含まれているプロパティのみが展開されるかのようにして型を決定しますが, 当然ながら実行時の挙動としては全ての存在するプロパティについて展開してしまいます. そのため, この例では型の上では現れない b3.y が前に定義された y の値を上書きしてしまい, 結果として c3.y の型と値が矛盾してしまいました.

このような矛盾を引き起こさないためには, プロパティの上書きの挙動を引き起こさないようにすれば良いはずです. これはオブジェクトリテラルの先頭でのみ spread syntax を許すことによって実現できます.

const a4 = { x: 0, y: "xxx" };
const b4: { x: number } = a4;

const c4 = { ...b4, y: true };
// c4: { x: number; y: boolean }
// c4 = { x: 0, y: true }

もちろん例外として, 上記のようなプロパティが減るアップキャストが起こっていないことが保証できていれば, 先頭以外で使用しても大丈夫です.

次回

susisu.hatenablog.com