TypeScriptのtype guardとassertion function

1. 概要

TypeScriptのnarrowingはtypeof、instanceof、===でうまく機能します。しかし、チェックロジックがより複雑になり、別の関数に分離すると、コンパイラはその結果を「認識」できなくなります。

本記事では、この問題を解決する2つのツールを紹介します。それはtype guard(value is T)とassertion function(asserts value is T)です。この記事を読み終えた後、あなたはそれらの書き方、unknownデータのチェック方法、テストの書き方を知り、それらがコンパイラに誤った情報を伝えている場合を認識できるようになります。

2. ビルトインのnarrowingとその制限

記事全体を通じて、シンプルな注文ドメインを例として使用します:

type PendingOrder = { status: "pending"; id: string; total: number };
type PaidOrder = { status: "paid"; id: string; total: number; paidAt: Date };
type CancelledOrder = { status: "cancelled"; id: string; reason: string };

type Order = PendingOrder | PaidOrder | CancelledOrder;

function describe(order: Order): string {
  if (order.status === "paid") {
    return `注文${order.id}は${order.paidAt.toISOString()}に支払われました`;
  }
  return `注文${order.id}は${order.status}状態です`;
}

Orderは弁別union型で、弁別フィールドはstatusです。if分岐内では、TypeScriptはorderがPaidOrder型であることを認識し、paidAtへのアクセスが有効です。

では、この条件を再利用し、別の関数に抽出したいとしましょう:

function isPaid(order: Order): boolean {
  return order.status === "paid";
}

function describe(order: Order): string {
  if (isPaid(order)) {
    return `支払い日時${order.paidAt.toISOString()}`;
    //              ^^^^^^ エラー
  }
  return "未支払い";
}
error TS2339: Property 'paidAt' does not exist on type 'Order'.
  Property 'paidAt' does not exist on type 'PendingOrder'.

isPaid関数はbooleanを返します。boolean型はorderに関する情報を持たないため、コンパイラは型を絞り込むことができません。Type guardはまさにこの問題を解決するために生まれました。

3. ユーザー定義type guard

3.1. value is T構文

戻り値の型をbooleanからtype predicateに変更するだけです:

function isPaidOrder(order: Order): order is PaidOrder {
  return order.status === "paid";
}

function describe(order: Order): string {
  if (isPaidOrder(order)) {
    return `支払い日時${order.paidAt.toISOString()}`;
  }
  // ここでorder: PendingOrder | CancelledOrder
  return `状態: ${order.status}`;
}

console.log(describe({ status: "paid", id: "A1", total: 120, paidAt: new Date("2026-10-01") }));
console.log(describe({ status: "cancelled", id: "A2", reason: "在庫切れ" }));
支払い日時2026-10-01T00:00:00.000Z
状態: cancelled

説明:

  • order is PaidOrderは「関数がtrueを返す場合、orderはPaidOrder型である」という意味です。
  • ランタイムでは、関数はtrue/falseを返すだけです。is部分はコンパイル時にのみ存在します。
  • false分岐では、TypeScriptはunionからPaidOrderを除外します。orderはPendingOrder | CancelledOrderになります。
  • predicateのパラメータ名(order)は関数のパラメータ名の1つと一致する必要があります。

3.2. 配列フィルタリングでのtype guard

Type guardは特にArray.prototype.filterで有用です。filterには述語を受け取るオーバーロードがあるからです:

const orders: Order[] = [
  { status: "pending", id: "A1", total: 50 },
  { status: "paid", id: "A2", total: 120, paidAt: new Date() },
  { status: "paid", id: "A3", total: 80, paidAt: new Date() },
];

const paidOrders = orders.filter(isPaidOrder); // PaidOrder[]
const revenue = paidOrders.reduce((sum, o) => sum + o.total, 0);

console.log(revenue);
200

paidOrdersはOrder[]ではなくPaidOrder[]型を持っています。おかげで、後のすべての操作は型キャストなしにpaidAtにアクセスできます。

TypeScript 5.5から、コンパイラはシンプルなアロー関数に対してtype predicateを自動推論します。例えばorders.filter(o => o.status === "paid")もPaidOrder[]を返します。この推論は式が十分にシンプルで、「true ⇔ その型に属する」という関係が正確である場合にのみ機能します。より複雑なロジックの場合、明示的なtype guardを書く必要があります。

3.3. unknownデータのチェック

type guardの最も実用的な用途は外部データのチェック、例えばAPIからのJSONです:

interface Customer {
  id: string;
  name: string;
  email: string;
}

function isCustomer(value: unknown): value is Customer {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value && typeof value.id === "string" &&
    "name" in value && typeof value.name === "string" &&
    "email" in value && typeof value.email === "string"
  );
}

const raw: unknown = JSON.parse('{"id":"C1","name":"太郎","email":"taro@example.com"}');

if (isCustomer(raw)) {
  console.log(raw.email.toUpperCase());
}
TARO@EXAMPLE.COM

TypeScript 4.9から、in演算子はvalueをobject & Record<"id", unknown>に絞り込みます。したがって、as anyなしでvalue.idを書くことができます。各フィールドが存在と型の両方でチェックされます。

3.4. 落とし穴:type guardが嘘をつく

コンパイラは完全にtype predicateを信頼します。関数本体が約束と実際に一致しているかは検証しません:

function isCustomerBroken(value: unknown): value is Customer {
  return typeof value === "object" && value !== null; // フィールドのチェックを忘れた
}

const data: unknown = { id: 1 };
if (isCustomerBroken(data)) {
  console.log(data.email.toUpperCase()); // コンパイルOK、実行時にクラッシュ
}
TypeError: Cannot read properties of undefined (reading 'toUpperCase')

さらに微妙な落とし穴が**false分岐**にあります:

function isLargeAmount(value: string | number): value is number {
  return typeof value === "number" && value > 1000;
}

function format(value: string | number) {
  if (isLargeAmount(value)) return `大: ${value}`;
  return value.toUpperCase(); // TS は value: string と考える
}

format(5); // TypeError: value.toUpperCase is not a function

関数は数値5に対してfalseを返します。TypeScriptは残りの分岐ではvalueは**number型ではない**と推論します。これは間違っています。

注意: Type predicateは双方向の関係です。trueを返す場合は「T型に属する」を意味する必要があります。falseを返す場合は「T型に属さない」を意味する必要があります。type guardにビジネスロジック(> 1000など)を詰め込まないでください。それらを別のboolean関数に分離してください。

4. Assertion function

4.1. asserts value is T

Type guardはif内のコードをラップすることを強制します。「間違っていればエラーを投げ、そうでなければ実行を続ける」という動作が必要な場合があります。それがassertion functionの役割です:

function assertIsCustomer(value: unknown): asserts value is Customer {
  if (!isCustomer(value)) {
    throw new TypeError("有効なCustomerデータではありません");
  }
}

function greet(payload: unknown): string {
  assertIsCustomer(payload);
  // ここからpayload: Customer
  return `こんにちは${payload.name}`;
}

console.log(greet({ id: "C1", name: "太郎", email: "taro@example.com" }));
console.log(greet({ id: "C2" }));
こんにちは太郎
TypeError: 有効なCustomerデータではありません

type guardとの主な違い:

  • 戻り値の型はasserts value is Customerです。関数本体は値をreturnしません。つまり関数はvoidを返します。
  • 関数は条件が偽の場合にエラーを投げます。関数が正常に返る場合、TypeScriptは条件が真であると見なします。
  • 型は呼び出し後のコード全体に対して絞り込まれ、ifブロック内だけではありません。

4.2. asserts condition

2番目の形式は特定の型と関連付けられません。これはNode.jsのassertのようにtruthy式を保証します:

function assert(condition: unknown, message: string): asserts condition {
  if (!condition) {
    throw new Error(message);
  }
}

function applyDiscount(order: Order, percent: number): number {
  assert(order.status !== "cancelled", `注文${order.id}はキャンセルされています`);
  // order: PendingOrder | PaidOrder → total を持つ
  assert(percent > 0 && percent <= 50, "割引は(0, 50]の範囲内である必要があります");
  return order.total * (1 - percent / 100);
}

console.log(applyDiscount({ status: "pending", id: "A1", total: 200 }, 10));
180

assert(order.status !== "cancelled", ...)の後、TypeScriptはちょうどifを書いたときのようにorderを絞り込みます。したがってorder.totalは有効です。CancelledOrderにはtotalフィールドがないにもかかわらず。

4.3. エラーTS2775:明示的な型注釈の欠落

これはassertion functionをアロー関数として書く際に最も一般的なエラーです:

const assertPositive = (n: number): asserts n => {
  if (n <= 0) throw new Error("正の数である必要があります");
};

assertPositive(5);
error TS2775: Assertions require every name in the call target
to be declared with an explicit type annotation.

コンパイラは宣言時にassertPositiveの型を知る必要があり、推論に依存することはできません。2つの修正方法があります。最初の方法はfunction宣言を使用することです。2番目は変数に型を割り当てることです:

type AssertPositive = (n: number) => asserts n;

const assertPositive: AssertPositive = (n) => {
  if (n <= 0) throw new Error("正の数である必要があります");
};

このルールは、オブジェクトを通じて呼び出されるメソッド(validator.assertCustomer(x)など)にも適用されます。変数validatorはconst validator: CustomerValidator = new CustomerValidator()のような明示的な型宣言を持つ必要があります。

注意: Assertion functionは非同期にできません。Promise<asserts x is T>型は存在しません。非同期チェックが必要な場合は、データを取得するためにawaitを使用し、その後、結果に対して同期assertion functionを呼び出してください。

5. Type guardとassertion functionのテスト

これはランタイムロジックなので、他の関数と同様にテストが必要です。以下の例はVitestを使用しています:

npm install -D vitest typescript
// customer.test.ts
import { describe, it, expect } from "vitest";
import { isCustomer, assertIsCustomer } from "./customer";

describe("isCustomer", () => {
  it("有効なオブジェクトでtrueを返す", () => {
    expect(isCustomer({ id: "C1", name: "太郎", email: "taro@example.com" })).toBe(true);
  });

  it.each([
    null,
    "C1",
    { id: "C1", name: "太郎" },
    { id: 1, name: "太郎", email: "taro@example.com" },
  ])("%jでfalseを返す", (input) => {
    expect(isCustomer(input)).toBe(false);
  });
});

describe("assertIsCustomer", () => {
  it("データが不正な場合TypeErrorを投げる", () => {
    expect(() => assertIsCustomer({ id: "C1" })).toThrow(TypeError);
  });

  it("データが正しい場合、型を絞り込む", () => {
    const payload: unknown = { id: "C1", name: "太郎", email: "taro@example.com" };
    assertIsCustomer(payload);
    expect(payload.email).toBe("taro@example.com"); // narrowingのため、コンパイル可能
  });
});
npx vitest run
 ✓ customer.test.ts (7 tests)
 Test Files  1 passed (1)
      Tests  7 passed (7)

ネガティブケースに注意してください:フィールドの欠落、型の不一致、null、プリミティブ値。type guardのほとんどのバグは、falseであるべき場合にtrueを返す場合にあります。3.4項の例を参照してください。最後のテストはコンパイル時テストの役割も果たします。誰かが関数シグネチャをvoidに変更すると、テストファイルはコンパイルされなくなります。

6. Type guardかassertion functionか?

状況 使用すべき
不正なデータが一般的で、別の処理が必要 Type guard
配列フィルタリング、find、filterとの組み合わせ Type guard
不正なデータはプログラミングエラーまたは契約違反で、すぐに停止すべき Assertion function
関数の先頭チェック(前提条件)、ネストされたifを避ける Assertion function

2つのツールはしばしば一緒に使用されます。type guardをコアとして書き、それをassertion functionでラップします。assertIsCustomerがisCustomerを呼び出すように。これにより、チェックロジックは1ヶ所だけに存在します。

自分で書く必要がない場合:

  • 大規模またはネストされたスキーマ。 30個のフィールドとネストされた配列を持つオブジェクト用に手でisCustomerを書くことはバグの原因になりやすいです。Zodやvalibotのようなvalidationライブラリは、同じスキーマから型とチェックを生成するため、2つが発散することはありません。
  • asの代替として。 空の本体を持つtype guard(return true)はvalue as Customerより安全ではありません。問題をより微妙に隠しているだけです。
  • シンプルな弁別union。 if (order.status === "paid")が十分に明確な場合、関数を持つためだけに分離しないでください。

7. まとめ

Type guard(value is T)を使うことで、型チェックロジックを関数に封カプセル化しながら、呼び出し元でnarrowingを保つことができます。特にifとfilterに適しています。Assertion function(asserts value is Tまたはasserts condition)は、条件が偽の場合にエラーを投げることで、後続のコード全体に対して型を絞り込みます。前提条件のチェックと入力データの検証に適しています。

どちらも、コンパイラが検証しない約束です。したがって、3つのことを忘れずに:

  • 「true ⇔ その型に属する」という関係を保つ。
  • Assertion functionに対して明示的な型注釈を宣言する。
  • ネガティブケースのテストを徹底的に行う。
typescripttype guardassertion functionnarrowingtype safety