なぜ Object.keys() は string[] を返すのか

TypeScript を書いていると、Object.keys() の戻り値が string[] であることに疑問を持つ人は多い。オブジェクトのキーがわかっているのに、なぜ (keyof T)[] ではないのか。この挙動は TypeScript の設計ミスではなく、構造的型付けという型システムの根幹に基づいた意図的な選択だ。

問題の再現

まずは典型的な場面を見てみよう。

const user = {
  name: "Alice",
  age: 30
};

const keys = Object.keys(user);
// 型は string[]

keys.forEach(key => {
  console.log(user[key]);  // 型エラー
});

keys の型が ("name" | "age")[] であれば user[key] は安全にアクセスできるはずだ。しかし実際には string[] と推論されるため、インデックスアクセスで型エラーが発生する。

直感的には不便に思えるこの挙動だが、TypeScript が string[] を返すのには正当な理由がある。

構造的型付けとは

TypeScript は構造的型付け(structural typing)を採用している。これは、型の互換性を名前ではなく構造で判断する方式だ。

interface Point {
  x: number;
  y: number;
}

const point3D = { x: 1, y: 2, z: 3 };
const p: Point = point3D;  // OK

point3Dz プロパティを持っているが、Point が要求する xy を満たしているので代入できる。これが構造的型付けの基本原則だ。

名目的型付け(nominal typing)を採用する Java や C# では、明示的に継承関係がなければこのような代入はできない。しかし TypeScript は JavaScript の柔軟性を型で表現するために、構造的型付けを選んだ。

なぜ string[] でなければならないのか

構造的型付けのもとでは、ある型の変数に「その型が要求するプロパティ以外のプロパティ」を持つオブジェクトが入っている可能性がある。

interface User {
  name: string;
  age: number;
}

function printKeys(user: User) {
  const keys = Object.keys(user);
  // keys の型を ("name" | "age")[] と仮定すると...
}

const admin = {
  name: "Bob",
  age: 25,
  role: "admin",
  permissions: ["read", "write"]
};

printKeys(admin);  // 構造的型付けにより OK

printKeys 関数は User 型の引数を受け取るが、実際に渡される adminrolepermissions という追加のプロパティを持つ。もし Object.keys(user)("name" | "age")[] を返すと型システムが保証してしまうと、実行時には "role""permissions" も含まれているのに、型は嘘をついていることになる。

function processUser(user: User) {
  const keys = Object.keys(user) as (keyof User)[];  // 危険な型アサーション
  
  keys.forEach(key => {
    const value: string | number = user[key];  // 型は string | number
    // しかし実際には role: string や permissions: string[] も来る可能性がある
  });
}

このように、Object.keys()(keyof T)[] を返すと仮定すると、型の健全性(soundness)が崩れる。TypeScript は完全に健全ではないが、明らかに危険な推論は避けるよう設計されている。

安全に回避する方法

この制約を理解した上で、実用的な回避策を見ていこう。

型ガードを使う方法がまず考えられる。

const user = { name: "Alice", age: 30 };

function isKeyOfUser(key: string): key is keyof typeof user {
  return key in user;
}

Object.keys(user).forEach(key => {
  if (isKeyOfUser(key)) {
    console.log(user[key]);  // OK
  }
});

オブジェクトが確実にその型のプロパティしか持たないとわかっている場合は、型アサーションも選択肢になる。

const CONFIG = {
  apiUrl: "https://api.example.com",
  timeout: 5000
} as const;

// CONFIG はリテラルで定義され、他のプロパティが混入する余地がない
(Object.keys(CONFIG) as (keyof typeof CONFIG)[]).forEach(key => {
  console.log(CONFIG[key]);
});

ただし、この型アサーションは「このオブジェクトには追加のプロパティがない」という前提を開発者が保証することになる。関数の引数として受け取ったオブジェクトに対しては、この前提は成り立たない可能性が高い。

より安全なアプローチとして、キーの配列を別途定義する方法もある。

const userKeys = ["name", "age"] as const;
type User = Record<typeof userKeys[number], string | number>;

userKeys.forEach(key => {
  // key は "name" | "age" 型
});

for...in も同じ理由で string になる

Object.keys() と同様に、for...in ループのキーも string 型になる。

const user = { name: "Alice", age: 30 };

for (const key in user) {
  // key は string 型
  console.log(user[key]);  // 型エラー
}

これも構造的型付けに起因する。user には宣言されていないプロパティが存在する可能性があるため、キーを keyof typeof user と推論することはできない。

他の言語との比較

TypeScript(構造的型付け)

型の互換性を構造で判断する。追加のプロパティがあっても、必要なプロパティを満たせば代入可能。そのため Object.keys()string[] を返さざるをえない。

Java / C#(名目的型付け)

型の互換性を名前(継承関係)で判断する。明示的に継承していなければ代入できない。もし TypeScript が名目的型付けなら、Object.keys()(keyof T)[] を返せた可能性がある。

TypeScript が構造的型付けを採用したのは、JavaScript の既存コードとの互換性を保つためだ。JavaScript では、あるオブジェクトが特定のプロパティを持っていれば、それを「その型」として扱うのが一般的だ。ダックタイピングと呼ばれるこのスタイルを型で表現するには、構造的型付けが適している。

まとめ

Object.keys()string[] を返すのは、構造的型付けのもとで型の健全性を保つためだ。関数に渡されたオブジェクトは、宣言された型よりも多くのプロパティを持っている可能性がある。そのため、キーの型を狭く推論すると、実行時の値と型が乖離してしまう。