TypeScript型設計の実務|any禁止・ユニオン型・zodバリデーションの指針

TypeScriptを導入したのに「anyだらけで実質JavaScript」という状態のコードベースは珍しくありません。型は書けば良いというものではなく、「何を型で保証したいのか」という設計の意図があって初めて効果を発揮します。

本記事では、チーム開発で型の恩恵を最大化するための実務的な指針を、any禁止の運用、ユニオン型と絞り込み、ジェネリクス、ランタイムバリデーションとの関係という4つの観点で整理します。

目次

any禁止をどう運用するか

anyの問題は、それに触れたコードすべての型チェックを無効化してしまう「伝染性」にあります。1箇所のanyが下流の広範囲を型なし状態にするため、原則として禁止するのが定石です。

  • tsconfigで "strict": true"noImplicitAny": true を有効化する
  • ESLintの @typescript-eslint/no-explicit-any でexplicitなanyも検出する
  • 型が不明な外部入力には any ではなく unknown を使う

unknown は「使う前に型を確認しないとコンパイルエラーになるany」です。APIレスポンスや JSON.parse の結果など、実行時まで型が分からない値の受け口として使い、後述のバリデーションで絞り込みます。既存コードのanyを一掃するのが現実的でない場合は、新規コードのみルールを厳格化し、既存分はコメント付きの暫定許可で段階的に減らす運用が現実的です。

ユニオン型と絞り込みで「ありえない状態」を排除する

型設計の核心は「ありえない状態を型で表現できなくする」ことです。たとえば通信状態を isLoadingerror の2つのフラグで持つと、「ローディング中かつエラー」という矛盾した状態が型上は許されてしまいます。判別可能ユニオン(discriminated union)を使えば、状態を排他的に表現できます。

type FetchState<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(state: FetchState<User>) {
  switch (state.status) {
    case "success":
      return state.data.name; // ここでは data の存在が保証される
    case "error":
      return state.error.message;
  }
}

status で分岐すると、TypeScriptが各分岐内の型を自動的に絞り込みます。switch文に never を使った網羅性チェックを組み合わせると、状態の追加漏れをコンパイル時に検出できます。

ジェネリクスは「関係性の保証」に使う

ジェネリクスの本質は「入力の型と出力の型の関係を保証する」ことです。逆にいえば、入出力に関係がない場面でジェネリクスを使うのは過剰設計です。

  • 使いどころ: 「Userの配列を渡したらUserが返る」など、入出力の型を連動させたい共通関数・共通コンポーネント
  • 避けどころ: 型パラメータが1箇所でしか使われない関数。単に具体的な型か unknown を書けば済みます
  • 制約は extends で最小限に付ける。制約のない T は中で何もできず、結局asキャストを誘発しがちです

レビューで「このジェネリクスは何の関係を守っているのか」を説明できないなら、外すことを検討したほうがよいでしょう。

型とバリデーションの境界|zodの位置づけ

忘れてはいけないのは、TypeScriptの型はコンパイル時にしか存在しないことです。APIレスポンスに as User とキャストしても、実行時に別の形のJSONが来ればそのまま通ってしまい、離れた場所で原因不明のエラーになります。

そこで、システムの境界(API・フォーム入力・localStorage・URLパラメータなど)ではzodのようなスキーマバリデーションライブラリで実行時検証を行います。

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});
type User = z.infer<typeof UserSchema>; // スキーマから型を導出

const user = UserSchema.parse(await res.json()); // 実行時に検証

スキーマを唯一の情報源にして型を z.infer で導出すれば、型とバリデーションの二重管理を避けられます。境界の内側では検証済みの型を信頼し、キャストを使わない。この線引きがチームの規律として機能します。

まとめ

型設計の実務は「anyを封じて型チェックを機能させる」「ユニオン型でありえない状態を排除する」「ジェネリクスは入出力の関係保証に絞る」「境界ではzod等で実行時検証し、型はスキーマから導出する」の4点に集約されます。型は書くこと自体が目的ではなく、リファクタリングと変更に耐えるコードベースを作るための投資です。まずはstrictモードとunknownの徹底から始めるのがおすすめです。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

クラウド・バックエンドエンジニア。AWSを中心に設計・構築から運用までを担当しています。主要言語は Java・JavaScript・Python。運用の現場で拾った知見を、再現できる手順に落として残すのがこのブログのテーマです。

目次