💂

【TS】ユーザー定義型ガードを自分で書くな。ライブラリを使え。

に公開

概要

TypeScript では、「ユーザー定義型ガード」という種類の関数が書けます。その一例をまず見てみましょう。

guards/is-string.ts
/**
 * ユーザー定義型ガード。入力値が `string` 型であるときに、真を返す。
 */
export function isString(input: unknown): input is string {
  return typeof input === 'string';
}
example.ts
import { isString } from './guards/is-string';

function doSomething1(input: unknown) {
  if (isString(input)) {
    // input は string 型に絞り込まれる
    console.log(input.toUpperCase());
  }
}
「ユーザー定義型ガード」について、詳しい補足

typeof 演算子を使うと、このように、条件分岐によって「ここでは inputstring 型である」とコンパイラが判断して、型チェックを通してくれるのですが…

function doSomething0(input: unknown) {
  if (typeof input === 'string') {
    // input は string 型に絞り込まれる
    console.log(input.toUpperCase());
  }
}

この typeof input === 'string' という「ガード」の部分を、関数として(モジュールとして)切り出してみましょう。

素朴に boolean を返す関数にしてしまうと、型の絞り込みが無効になってしまいます。これでは、切り出す前のコードと比べて後退してしまっていますね…

guards/is-string.ts
export function isString(input: unknown): boolean {
  return typeof input === 'string';
}
example.ts
function doSomething1(input: unknown) {
  if (isString(input)) {
    console.log(input.toUpperCase()); 
    //          ^^^^^^^^^^^^^^^^^^^
    // input は unknown 型のままなので、コンパイル時にエラーになる。
  }
}

そこで、 isString の返り値の型を input is string という形式の「型述語(Type Predicate)」に変えてみましょう。

- export function isString(input: unknown): boolean {
+ export function isString(input: unknown): input is string {
    return typeof input === 'string';
  } 

すると、コンパイラは元のコードの doSomething0 と同様に、型の情報を認識して絞り込みを効かせてくれます。これで先ほどの「後退」は解消されましたね

function doSomething1(input: unknown) {
  if (isString(input)) {
    // input は string 型に絞り込まれる
    console.log(input.toUpperCase());
  }
}

型述語(Type Predicate, v is T)が実行時の型チェック結果をコンパイラに教えてくれる格好になるので、切り出された1つの関数が、《実行時の型チェック》だけでなく《型チェックの保証》まで提供できるようになります。

そんな優秀な機能ですが、アプリケーションを開発するときに、型ガードを自力で書くのは、破綻しやすく危険です。自分で書くのではなく、ライブラリを通じて利用したり、型推論に任せたりしましょう。

つまり、"You might not need an user-defined type guard" ってことですね。

  1. 自分で型述語を書く(ユーザー定義型ガード)
    ⚠️ これは危険
  2. ライブラリが提供した型ガード(例: スキーマ、Remeda)を使う
    ✅️ これは安全
  3. (TS 5.5 から)実行時コードから、勝手に型述語を推論して型を絞り込んでくれる
    ✅️ これは安全

▼ 3. の「型述語を推論」については以下の記事が詳しいです。

https://zenn.dev/ubie_dev/articles/ts-infer-type-predicates

なぜ「自分で書く」は危ないの?

ユーザー定義型ガードは、以下のような「型チェック時」「実行時」の二重性があります。

  • 呼び出し元の側では:
    • 型チェック時には、型述語 v is T の通り「正しく実装されている」テイで扱われる
  • 型ガード関数の本文では:
    • (型チェックの恩恵を受けず)実行時のコードで正確に検証する必要がある

なので、内部の実装に誤りがあってもコンパイラが検出してくれず、「黙って落ちる」、つまり実行時エラーや単位テストまで、発見が遅れるということです。

つまり、実装者が 自己責任で正しいチェック処理を書く 必要があるのです。

このとき重要なのは、人間が肩代わりした部分の型安全性は人間が責任を持つということです。本来は全部TypeScriptが完璧にやってくれるのが理想的ですが、現実にはそうもいきませんので、足りない部分を人間が補ってあげます。その際に人間にも責任が生じてしまう、これがユーザー定義型ガードの危険性です。

ユーザー定義型ガード、asで書くかanyで書くか | uhyo / blog
https://blog.uhy.ooo/entry/2021-04-09/typescript-is-any-as/

不完全なチェックになりやすい

  • オブジェクトのネスト
  • 配列とその要素の扱い
  • オプショナルなプロパティ(null | undefined も許容する)の扱い

といった要因があると、ランタイムチェックのコードを書くのが難しくなってきて、バグが混入しやすくなります。

ちゃんと間違いなく書ける自信ありますか?僕はありません。

types/user.ts
type User = {
  id: string;
  profile: { name: string; tags: string[] } | undefined | null;
};

function isUser(x: unknown): x is User {
  // tag の型チェックを忘れているが、コンパイル時の警告なし。
  const o = x as any;
  return (
    typeof o?.id === "string" &&
    (o.profile == null || typeof o.profile?.name === "string")
  );
}
example.ts
import { type User, isUser } from './types/user';

function doSomething(input: unknown) {
  if (isUser(input)) {
    const user = input;
    user.profile?.tags.forEach((tag) => {
      console.log(tag.toUpperCase());
      //          ^^^^^^^^^^^^^^^^^
      // 型チェック時にはエラーにならないが、実行時にエラーが発生する。
      // TypeError: tag.toUpperCase is not a function
    });
  }
}

doSomething({ id: "a", profile: { name: "n", tags: [1, 2, 3] }});

コード修正のたびに漏れのリスクがある

単に「正しく作るのが難しい」だけでは終わりません。作ったあとのメンテナンスも大変です。

アプリケーションに新機能を追加するために、型の微変更(オプショナル化、プロパティ追加・プロパティ名変更)があった場合を考えてみましょう。

User 型の定義を変えただけでは、型チェックはエラーを検出してくれません。isUser の関数本体を修正して型定義に追従させるのはあなたの責任です

よくよく注意して、修正を忘れないようにする必要があります。さもないと「静的には通るがランタイムで壊れる」という恐ろしい状態につながります。

やってられんわ、こんなクソゲー。

方針:スキーマライブラリを使う

このような二重管理の問題は、「スキーマ」というパターンによって解消されます。

このパターンは、いくつかのライブラリが提供してくれています。

  • Zod - スキーマライブラリ界の定番(?)
  • Valibot - 個人的なお気に入り
  • ArkType
  • Yup
  • etc.

スキーマオブジェクトは、いわば「実行時の検証だけでなく、コンパイル時の型情報までまとめた SSoT」といえます。これによって、《型》《実行時検証》の二重管理による保守性の低下を防いでくれます。

Valibot の例

とりあえず、Valibot の例から、その SSoT としてのスキーマがどのように使われるか見てみましょう。

動作確認に使用した Valibot のバージョン: v1.x (Playground を使用)

schemas/user.ts
import * as v from 'valibot';

// スキーマ定義。これが SSoT となる。
export const UserSchema = v.object({
  id: v.string(),
  profile: v.optional(
    v.object({
      name: v.string(),
      tags: v.array(v.string()),
    })
  ),
});
example.ts
import * as v from 'valibot';
import { UserSchema } from './schemas/user';

// 型情報は、スキーマから取得する。
type User = v.InferOutput<typeof UserSchema>;

function doSomething(input: unknown) {
  // スキーマで実行時検証
  const result = v.safeParse(UserSchema, input);
  if (result.success) {
    const user = result.output;
    //    ^ User 型に絞り込まれる
    console.log(`User (id:${user.id})`);
  }
}

実際に、

  • 型情報も実行時検証も、UserSchema に内包されているので、二重管理が発生しない
  • 利用者にとって「OOUI 的に美しく凝集した API」になる
    • export されるモノが、UserSchema の1つだけ
    • クラスのように、1つの知識が1つの項目に集約されている

ことが確認できると思います。

トレードオフとして、以下のような問題もありますが、個人的には「OOUI 的な美しさ」が最優先なので、ガンガン使っていこうと思っています。

  • Valibot の「スキーマを使うための API」の知識が必要になる
    • 例: v.InferOutput とか v.safeParse とか
    • これは「OOUI 的な美しさ」の代償
  • トレードオフとして、Valibot の「スキーマを組み立てるための」API の知識が必要になる
    • 例: v.objectv.stringv.optionalv.array とか
    • これは 二重管理が発生しないメリットの代償

ついでに、"Parse, don't validate" 原則

ちなみに、 v.is を使えば従来と同じく「型ガード」を使った書き方も可能ですが、今回はそちらではなく v.safeParse を使った例を示しました。(なので、型述語 v is T は現れず、成功/失敗の判別可能ユニオン型を使います。)

それは、僕は "Parse, don't validate"(バリデーションせずパースせよ) という原則に従うことに決めているからです。

example.ts
import * as v from 'valibot'; 
import { UserSchema } from './schemas/user';

function doSomething(input: unknown) {
  // △ Parse, don't validate 原則に反するので、あまり好ましくない。
  if (v.is(UserSchema, input)) {
    const user = input;
    //    ^ User 型に絞り込まれる
    console.log(`User (id:${user.id})`);
  }
}

僕が "Parse, don't validate" 原則を信奉している理由については、脳内の整理が付いていないので、解説は割愛します。

以下の記事(海外記事の和訳)は Haskell を使っていますが、改めて読み込んで、整理して記事にできたらいいなと思っています。

https://zenn.dev/mj2mkt/articles/2024-10-11-parse-dont-validate

単純なパターンなら Remeda でも OK

特に簡単な型チェックをするだけなら、スキーマライブラリを使うまでもなく、単純なユーティリティを提供してくれる Remeda のようなライブラリを使うだけでも十分かもしれません。

Remeda は、文字列、数値、null | undefined のような、単純な型に対する型ガード関数が用意されています。

これらは「配列から null | undefined を取り除く」のようなユースケースで便利なので頻出です。

[5, null].filter(R.isNonNullish);
// -> [5], 型は number[]

まとめ

TypeScript で型ガードを自前で書くのがいかに危険か、理解いただけたかと思います。

「こういう機能がある」からといって「それをアプリケーション開発者が積極的に使うべき」とは限りません。型ガードそのような機能の一つです。主にライブラリ(または自作ユーティリティ)向けで、慎重に慎重を重ねて扱うべき機能です。

その代わりに、ライブラリが提供してくれる、品質の安定した型ガード類を使うようにしましょう。

  • 詳細なオブジェクトの型について、実行時型チェックのモジュール化が必要になる場面
    • 例: クエリパラメータの検証や外部APIのレスポンス検証など
    • スキーマライブラリを使う
  • 普遍的または単純なケース
    • 例: 配列から null | undefined を取り除くなど
    • Remeda のようなユーティリティライブラリを使う

それでは、よい型安全ライフを!

関連記事

https://zenn.dev/yumemi_inc/articles/use-client-and-zod-codec-for-data-transfer

ゆめみ

Discussion