keyof、typeof、TypeScript の indexed access types

1. 概要

コードベースが大きくなると、注文ステータスなど同じキーのセットが複数の場所に現れるという状況に遭遇するでしょう:オブジェクト内、ユニオン型内、関数のパラメーターなど。1 つの場所を修正してもう 1 つを忘れるとバグになります。

TypeScript には 3 つのツールがあり、1 度だけ宣言してから残りを推論できます。keyof は型のキーのセットを取得します。typeof は値から型を取得します。Indexed access type(T["key"])はプロパティの型を取得します。

この記事を読み終わると、これら 3 つのツールを組み合わせて型安全な generic 関数を書き、厳密に型が指定された event bus を構築する方法がわかります。また、これらを使用する際によくあるエラーについても学べます。

2. 準備

この記事では TypeScript 5.x と Vitest を使用してテストを書きます。以下のようにインストールしてください:

npm install --save-dev typescript vitest

tsconfig.json で strict を有効にすることをお勧めします。有効にしない場合、undefined に関する多くのエラーが報告されません:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true
  }
}

3. keyof 演算子

3.1. 基本的な使い方

keyof T は T のプロパティ名の union を返します:

type Order = {
  id: string;
  total: number;
  status: "pending" | "paid" | "cancelled";
  note?: string;
};

type OrderKey = keyof Order;
// "id" | "total" | "status" | "note"

const sortBy: OrderKey = "total"; // OK
const wrong: OrderKey = "price";  // エラー

最後の行はコンパイルできません:

error TS2322: Type '"price"' is not assignable to type 'keyof Order'.

"id" | "total" | ... という union を手で入力する必要はありません。新しいプロパティを Order に追加すると、OrderKey は自動的に更新されます。

3.2. index signature を使った keyof

型が index signature を使う場合、結果は予想外かもしれません:

type PriceTable = { [sku: string]: number };
type PriceKey = keyof PriceTable; // string | number

type ByIndex = { [index: number]: string };
type IndexKey = keyof ByIndex;    // number

PriceKey は単に string ではなく string | number です。理由は JavaScript で obj[1] と obj["1"] が同じプロパティにアクセスするためです。したがって TypeScript は number 型のキーも許可します。

3.3. generic での keyof

keyof は generic constraint と一緒に使うときに最も効果的です:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };

const total = getProperty(order, "total");   // number
const status = getProperty(order, "status"); // "pending" | "paid" | "cancelled"
getProperty(order, "price");                 // コンパイルエラー

この関数は次のことを行います:

  • K extends keyof T は key を制限するため、obj の実在するプロパティ名だけを受け付けます。
  • 戻り値の型 T[K] は indexed access type です(セクション 5 で説明します)。おかげで、関数を呼び出すたびに対応するプロパティの正しい型が得られます。
  • "total" で呼び出すと number が得られます。"status" で呼び出すと union literal が得られます。any はありません。

4. 型コンテキストでの typeof 演算子

JavaScript には typeof が runtime で実行され、"string" や "object" のような文字列を返します。TypeScript は 2 番目の意味を追加します。型の位置に配置されたとき、typeof x は変数 x の型を与えます。

4.1. 値から型を取得する

const defaultCustomer = {
  name: "Nguyễn Văn An",
  email: "an@example.com",
  loyaltyPoints: 0,
};

type Customer = typeof defaultCustomer;
// { name: string; email: string; loyaltyPoints: number }

function createCustomer(input: Partial<Customer>): Customer {
  return { ...defaultCustomer, ...input };
}

console.log(createCustomer({ name: "Trần Thị Bình" }));

結果:

{ name: 'Trần Thị Bình', email: 'an@example.com', loyaltyPoints: 0 }

ここでは、デフォルト値が唯一の情報源です。defaultCustomer にフィールドを追加すると、Customer もそのフィールドを持つようになります。

関数の場合、typeof を utility type と組み合わせます:

function loadConfig() {
  return { apiUrl: "https://api.shop.local", timeoutMs: 5000, retries: 3 };
}

type AppConfig = ReturnType<typeof loadConfig>;
// { apiUrl: string; timeoutMs: number; retries: number }

4.2. as const と typeof

デフォルトでは TypeScript はリテラルを「拡大」します:"pending" を string に、配列を string[] に。as const を追加すると、リテラルが保持され、readonly にマークされます:

const ORDER_STATUSES = ["pending", "paid", "shipped", "cancelled"] as const;

type OrderStatusList = typeof ORDER_STATUSES;
// readonly ["pending", "paid", "shipped", "cancelled"]

as const がない場合、typeof ORDER_STATUSES は string[] で、すべてのリテラル情報が失われます。

4.3. keyof typeof の組み合わせ

これは定数オブジェクトがあり、その key の union を取得したいときに非常に一般的な書き方です:

const SHIPPING_FEES = {
  standard: 30_000,
  express: 60_000,
  sameDay: 120_000,
} as const;

type ShippingMethod = keyof typeof SHIPPING_FEES;
// "standard" | "express" | "sameDay"

function shippingFee(method: ShippingMethod): number {
  return SHIPPING_FEES[method];
}

console.log(shippingFee("express"));
60000

読み方は内側から外側へです。typeof SHIPPING_FEES はオブジェクトの型を取得し、次に keyof その型のキーを取得します。keyof SHIPPING_FEES と書くことはできません。SHIPPING_FEES は型ではなく値だからです。

5. Indexed access types

5.1. プロパティの型を取得する

T["key"] 構文はプロパティにアクセスするのに似ていますが、型レベルで発生します:

type OrderStatus = Order["status"];           // "pending" | "paid" | "cancelled"
type OrderNote = Order["note"];               // string | undefined
type IdOrTotal = Order["id" | "total"];       // string | number
type AnyOrderValue = Order[keyof Order];      // すべてのプロパティの型の union

注意すべき 3 つのポイントがあります:

  • オプションのプロパティ(note?)は undefined を含む型を生成します。
  • union キーを渡すと、対応する型の union が得られます。
  • T[keyof T] はオブジェクト内のすべての値の「型」を取得する一般的な方法です。

5.2. 配列要素の型を取得する

number でインデックスすると、要素の型が得られます:

type OrderStatusFromList = (typeof ORDER_STATUSES)[number];
// "pending" | "paid" | "shipped" | "cancelled"

function isOrderStatus(value: string): value is OrderStatusFromList {
  return (ORDER_STATUSES as readonly string[]).includes(value);
}

console.log(isOrderStatus("paid"), isOrderStatus("refunded"));
true false

配列 ORDER_STATUSES は現在 2 つの役割を果たしています。実行時には、API からの入力を検証するためのリストです。コンパイル時には、union type を生成します。ステータスを 1 度だけ宣言するだけです。

5.3. ネストされたアクセス

複数回のインデックスをチェーンして、構造を深く掘り下げることができます:

type Invoice = {
  customer: { name: string; address: { city: string; zip: string } };
  lines: Array<{ sku: string; quantity: number; unitPrice: number }>;
};

type City = Invoice["customer"]["address"]["city"]; // string
type InvoiceLine = Invoice["lines"][number];
// { sku: string; quantity: number; unitPrice: number }

function lineTotal(line: InvoiceLine): number {
  return line.quantity * line.unitPrice;
}

InvoiceLine は Invoice から直接取得されるため、別個に型を宣言して両者を同期させておく必要がありません。

6. 実例:型安全な event bus

ここで 3 つのツールすべてを組み合わせます。目標は、各イベントのペイロードが正しく型チェックされる event bus です:

type OrderEvents = {
  created: { orderId: string; total: number };
  paid: { orderId: string; method: "card" | "cash" };
  cancelled: { orderId: string; reason: string };
};

type Handler<K extends keyof OrderEvents> = (payload: OrderEvents[K]) => void;

export class OrderEventBus {
  private handlers: { [K in keyof OrderEvents]?: Handler<K>[] } = {};

  on<K extends keyof OrderEvents>(event: K, handler: Handler<K>): void {
    (this.handlers[event] ??= []).push(handler);
  }

  emit<K extends keyof OrderEvents>(event: K, payload: OrderEvents[K]): void {
    this.handlers[event]?.forEach((handle) => handle(payload));
  }
}

const bus = new OrderEventBus();

bus.on("paid", (p) => console.log(`Đơn ${p.orderId} thanh toán bằng ${p.method}`));
bus.emit("paid", { orderId: "ORD-42", method: "card" });

// bus.emit("paid", { orderId: "ORD-42", reason: "x" }); // エラー: 'method' がない
// bus.emit("refunded", { orderId: "ORD-42" });          // エラー: イベントが存在しない

出力:

Đơn ORD-42 thanh toán bằng card

各部分は次のように機能します:

  • keyof OrderEvents はイベント名を "created" | "paid" | "cancelled" の 3 つの値に制限します。
  • OrderEvents[K] はペイロードを正しいイベントに結び付けます。"paid" のハンドラー内では、パラメーター p は method を持ちますが、reason は持ちません。
  • Mapped type [K in keyof OrderEvents] はハンドラー リポジトリを作成し、各イベントに独自のリストがあります。
  • refunded イベントを追加したい場合は、OrderEvents を修正するだけです。その他はすべて自動的に更新されます。

7. Vitest による型のテスト

Vitest には型を確認するための expectTypeOf があります。@ts-expect-error と組み合わせて、不正なコードがブロックされることを確認できます:

import { describe, it, expect, expectTypeOf, vi } from "vitest";
import { OrderEventBus } from "./order-event-bus";

type Order = { id: string; total: number; status: "pending" | "paid" };

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

describe("keyof と indexed access", () => {
  const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };

  it("正しい値と正しい型を返す", () => {
    const total = getProperty(order, "total");

    expect(total).toBe(250_000);
    expectTypeOf(total).toEqualTypeOf<number>();
    expectTypeOf(getProperty(order, "status")).toEqualTypeOf<"pending" | "paid">();
  });

  it("存在しないキーを拒否する", () => {
    // @ts-expect-error - 'price' は Order のキーではない
    getProperty(order, "price");
  });

  it("event bus は正しいペイロードでハンドラーを呼び出す", () => {
    const bus = new OrderEventBus();
    const handler = vi.fn();

    bus.on("created", handler);
    bus.emit("created", { orderId: "ORD-7", total: 99_000 });

    expect(handler).toHaveBeenCalledWith({ orderId: "ORD-7", total: 99_000 });
  });
});

テストは 2 つのコマンドで実行します:

npx vitest run --typecheck
npx tsc --noEmit

注: expectTypeOf は --typecheck または tsc を通じて型が確認されている場合にのみ有効です。通常の vitest run を実行している場合、型が間違っていてもテストは成功します。型が実際に正しいかどうかは検証されていません。

誰かが "price" をエラー リストから削除した場合、たとえば Order に price を追加した場合、@ts-expect-error は「Unused ‘@ts-expect-error’ directive」をレポートします。これは良い兆候です。あなたの型テストが API コントラクトを保護しています。

8. よくあるエラー

変数をインデックス型として使用する。 const key = "total" を使って、Order[key] と書くとエラーになります:

error TS2749: 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?

修正方法は Order[typeof key] と書くことです。

式で typeof を呼び出す。 typeof loadConfig() は構文エラーです。typeof は識別子またはプロパティ アクセス(typeof config.db など)のみを受け付けます。関数の結果を使用する場合は、ReturnType<typeof loadConfig> を使用します。

Object.keys が keyof T を返すと思う。 実際には string[] を返します:

for (const key of Object.keys(order)) {
  // order[key]; // エラー: 'string' は 'Order' にインデックスできない
}

TypeScript がそうする理由があります。型は「構造的」なため、実行時にオブジェクトは型が宣言しているより多くのプロパティを含むことができます。データについて確信がある場合は、Object.keys(order) as (keyof Order)[] を強制的にキャストできます。ただし、コンパイラーの代わりにコミットしていることを理解してください。

as const を忘れる。 それがない場合、(typeof ORDER_STATUSES)[number] はただの string となり、union literal のすべての利点がエラー報告なしに消えてしまいます。

注: 乱用しないでください。Invoice["customer"]["address"]["city"] のようなチェーンは、型が本当に Invoice に依存している場合に理にかなっています。Address が多くの場所で使用される独立した概念である場合は、名前付き型として宣言します。コードはより読みやすく、エラー メッセージも簡潔になります。

9. まとめ

この記事の 3 つのツールは 3 つの異なることを行います:

  • keyof は型のキーのセットを取得します。K extends keyof T 制約を作成するときに最も役立ちます。
  • 型の位置の typeof は値を型に変えます。as const と一緒に使用されると、配列とオブジェクト定数を唯一の情報源にするのに役立ちます。
  • Indexed access type T[K] は、プロパティの型、配列の要素([number])、またはネストされた構造を取得します。

これらを組み合わせると、generic 関数と event bus を書き、コンパイラーが不正なキーや不正なペイロードのエラーをコンパイル時に見つけることができます。複数の場所で同じリストを繰り返す必要もありません。strict を有効にし、CI で tsc または vitest --typecheck を実行し、型が本当に依存している場合にのみ長いインデックス チェーンを使用することを忘れないでください。

typescripttype systemkeyoftypeofgenerics