API がリクエストを受け取るたびにバリデーションを行う。外部システムから引数を受け取る関数も同様にチェックする。これを手作業で行うと、1つのエンドポイントにビジネスロジックを超える量のバリデーションコードが蓄積する。

これは runtime contracts に伴う隠れたコストだ。type system は真実を語らない。HTTP 越しの JSON、データベースのフィールド、環境変数はすべて実行時に型付けされていないデータとして到達する。しかし、手作りの if 文でこれを実装すると、シンプルな関数が読めない防衛的コードの壁に変わってしまう。

もっと良い方法がある。

runtime contracts とは何か

runtime contract とは、関数が入力と出力について保証するものだ。有効なデータを渡せば、有効なデータが返ってくる。contract を破ると、関数は明確なエラーを伴って即座に失敗する。

これは Design by Contract であり、Eiffel の時代から存在する。アイデア自体は新しくない。新しいのは、すべてのフィールドに対して validator を書かずに contracts を表現できるツールだ。

核心となる洞察は、contracts はスキーマであり、コードではないということだ。スキーマは形状、制約、関係性を記述する。これらを手作業でチェックするコードは、言語を間違えて書いたスキーマに過ぎない。

定型コードの罠

実際の手作りバリデーションがどう見えるか示そう。ユーザー登録リクエストを処理する関数があるとする。

function createUser(payload: unknown) {
  if (!payload || typeof payload !== "object") {
    throw new Error("Invalid payload");
  }
  if (!("email" in payload) || typeof (payload as any).email !== "string") {
    throw new Error("email is required and must be a string");
  }
  if (!(payload as any).email.includes("@")) {
    throw new Error("email must be valid");
  }
  if (!("age" in payload) || typeof (payload as any).age !== "number") {
    throw new Error("age is required and must be a number");
  }
  if ((payload as any).age < 13 || !Number.isInteger((payload as any).age)) {
    throw new Error("age must be an integer of at least 13");
  }
  if (
    "tags" in payload &&
    (!Array.isArray((payload as any).tags) ||
      !(payload as any).tags.every((t: any) => typeof t === "string"))
  ) {
    throw new Error("tags must be an array of strings");
  }
  // ... finally, the actual work
}

これは疲れる、エラーを起こしやすく、かつ不完全だ。email が実際のメール形式にマッチするかチェックしたか?ageNaN でないことを確認したか?tags の各要素が空でない文字列かを検証したか?たぶんしていない。疲れてしまったからだ。

これはたった1つの関数だ。システム内のすべての boundary で同じことを考えてみろ。

宣言的スキーマが定型コードを置き換える仕組み

validator を書く代わりに、形状を記述し、ライブラリに enforce させる。

import { z } from "zod";

const UserSchema = z.object({
  email: z.string().email(),
  age: z.number().int().min(13),
  tags: z.array(z.string().min(1)).optional(),
});

function createUser(payload: unknown) {
  const user = UserSchema.parse(payload);
  // user is typed as { email: string; age: number; tags?: string[] }
  // actual business logic goes here
}

スキーマはドキュメントである。同時に validator、型定義、エラーメッセージの生成器でもある。1箇所でスキーマを変更すれば、それが使われているすべての場所で contract が更新される。

これは魔法ではない。ライブラリは実行時にスキーマを走査し、チェックを適用する。違いは、チェックが毎回手作業で書かれるのではなく、小さく再利用可能なプリミティブから構成されるということだ。

関数の boundary における contracts

真の力は、スキーマを関数の入力と出力に直接紐付けることから生まれる。contract は boundary に存在し、実装に漏れ出してほしくない。

function withContract<TInput, TOutput>(
  inputSchema: z.ZodSchema<TInput>,
  outputSchema: z.ZodSchema<TOutput>,
  handler: (input: TInput) => TOutput
) {
  return (rawInput: unknown): TOutput => {
    const input = inputSchema.parse(rawInput);
    const output = handler(input);
    return outputSchema.parse(output);
  };
}

const UserResponseSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
});

const createUser = withContract(
  UserSchema,
  UserResponseSchema,
  (user) => {
    // zero validation code here
    return db.users.create(user);
  }
);

これで関数本体にはバリデーションコードが一切ない。contract は境界で enforce され、コンパイラはスキーマが推論する型を通じて内部の型を把握している。

限界が露呈する場面:パフォーマンス、推論、過剰バリデーション

宣言的スキーマはタダではない。導入する前にトレードオフを理解しておくべきだ。

パフォーマンスは明らかな懸念材料だ。複雑なスキーマをパースするのは、数個の if チェックより遅い。実際には、ほとんどの API では差は無視できる程度だ。Zod はシンプルなオブジェクトの手作りチェックよりおおよそ10倍遅い。ほとんどの HTTP ハンドラでは、差はマイクロ秒単位だ。hot loop で数百万件のレコードをバリデーションする場合は、まずベンチマークを取るべきだ。

type inference の齟齬も問題を引き起こすことがある。スキーマから推論される型は、手作業で書いたものより厳しすぎたり緩すぎたりすることがある。カスタム型を定義してスキーマに一致させる必要があるかもしれない。逆ではなく。

過剰バリデーションは、見えないところで致命的な影響を与える。すべての関数が runtime contract を必要とするわけではない。API の boundary で既にバリデーション済みのデータを受け取る内部関数を再チェックする必要はない。contract はシステムの boundary に適用し、すべてのヘルパー関数には適用しない。

codebase を書き換えずに導入する方法

一気に移行する必要はない。API の入り口から始めよう。

  1. 外部に公開されている関数またはルートハンドラを1つ選ぶ。
  2. その入力用のスキーマを書く。
  3. 手作りのバリデーションをスキーマのパースに置き換える。
  4. スキーマに TypeScript の型を推論させ、手書きの interface を削除する。

1つのエンドポイントから始め、次に1つのサービス、そして1つのドメインへ。1週間もすれば、テストスイートが縮んでいくことに気づくだろう。スキーマが既にカバーしているバリデーションのエッジケースをテストする必要がなくなったからだ。

TypeScript を使っていなくても、このパターンは応用できる。Python には Pydantic がある。Go には go-playground/validator がある。Rust には validator がある。原則は同じだ。contract を記述し、チェックを生成する。

よくある質問

runtime contracts はユニットテストを置き換えるのか?

いいえ。入力バリデーションを検証するユニットテストの一部を置き換えるに過ぎない。ビジネスロジック、エラーハンドリング、統合動作は依然としてテストする必要がある。

カスタムバリデーションルールはどうか?

すべての本格的なスキーマライブラリはカスタム refinement をサポートしている。控えめに使うべきだ。カスタムルールが5行を超えるなら、おそらくスキーマではなく関数本体に属する。

出力時もバリデーションすべきか?

はい、クリティカルな関数ではそうすべきだ。output contract は、ビジネスロジックが不正なデータを生成するバグを捕捉する。不正なデータを別のサービスに伝播させるより、発生源で失敗する方がコストが低い。

結論

untyped data に触れるシステムにとって、runtime contracts は譲れない。唯一の選択肢は、定型コードで代価を支払うか、スキーマライブラリで支払うかだ。

スキーマライブラリが要求するのは依存関係とわずかなパフォーマンスオーバーヘッドだ。定型コードが奪うのは保守性、正確性、そして精神衛生だ。

依存関係を選べ。