🚀

TypeScriptのas型アサーションをZodで型安全に置き換える

に公開

Effective Typescriptな勢いで書いています

TypeScriptで開発していると、外部APIのレスポンスや設定ファイルの読み込みなど、実行時まで型が確定しないデータを扱うことがよくあります。このような場面でasを使った型アサーションに頼ってしまうと、ランタイムエラーのリスクが高まります。

本記事では、Zodのz.infer(型推論)、parse(ランタイム検証)、refine(カスタムバリデーション)、transform(データ変換)を使って、as型アサーションを避ける方法をまとめます。

従来の問題:型アサーションの危険性

Before: 型アサーション

// 危険な例:型アサーション
interface User {
  id: number;
  name: string;
  email: string;
  age?: number;
}

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  
  // ここが危険:実際のデータ構造を検証していない
  return data as User;
}

// 実行時エラーの可能性
const user = await fetchUser("123");
console.log(user.name.toUpperCase()); // user.nameがundefinedの場合エラー

問題点

  1. ランタイム検証がない:APIから返されるデータの形式が変わってもエラーが発生しない
  2. 型の不整合:TypeScriptは型が正しいと信じてしまう
  3. デバッグが困難:実行時まで問題が発覚しない

Zodの基本概念

Zodは「Schema first」のアプローチを取ります。まずスキーマを定義し、そこから型を推論するという流れです。これにより、データの検証と型定義を一箇所で管理できます。

z.inferによる型推論の仕組み

z.inferは、Zodスキーマから対応するTypeScript型を自動生成するユーティリティです。スキーマを変更すれば型も自動的に更新されるため、型とスキーマの不整合を防げます。

// スキーマから型を推論
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

// z.inferで型を自動生成
type User = z.infer<typeof UserSchema>;
// => { id: number; name: string; email: string; }

この仕組みにより、interfaceの重複定義が不要になり、信頼できる唯一の情報源を実現できます。

解決策:Zodによる型安全なアプローチ

After: Zodによる型安全な実装

import { z } from "zod";

// Zodスキーマの定義
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().optional(),
});

// z.inferで型を自動生成
type User = z.infer<typeof UserSchema>;

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  
  // HTTP エラーチェック
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }
  
  const data = await response.json();
  
  // ランタイムでデータを検証
  const validatedUser = UserSchema.parse(data);
  return validatedUser; // 型安全が保証された状態
}

// 安全に使用可能
const user = await fetchUser("123");
console.log(user.name.toUpperCase()); // user.nameは確実にstring

実践例1:設定ファイルの読み込み

Before: 型アサーション版

interface Config {
  port: number;
  database: {
    host: string;
    port: number;
    name: string;
  };
  features: string[];
}

function loadConfig(): Config {
  const configFile = fs.readFileSync("config.json", "utf-8");
  const config = JSON.parse(configFile);
  
  // 設定ファイルの内容が正になってしまう
  return config as Config;
}

After: Zodを使った実装

import { z } from "zod";

const ConfigSchema = z.object({
  port: z.number().min(1).max(65535),
  database: z.object({
    host: z.string().min(1),
    port: z.number().min(1).max(65535),
    name: z.string().min(1),
  }),
  features: z.array(z.string()),
}).strict(); // 想定外のキーはエラーにして、タイポ混入を即時検知

type Config = z.infer<typeof ConfigSchema>;

function loadConfig(): Config {
  const configFile = fs.readFileSync("config.json", "utf-8");
  const rawConfig = JSON.parse(configFile);
  
  // 設定値を検証してから返す
  return ConfigSchema.parse(rawConfig);
}

実践例2:フォームバリデーション

Before: 手動バリデーション

interface FormData {
  username: string;
  email: string;
  age: number;
}

function validateForm(data: any): FormData | null {
  if (typeof data.username !== "string" || data.username.length < 3) {
    return null;
  }
  if (typeof data.email !== "string" || !data.email.includes("@")) {
    return null;
  }
  if (typeof data.age !== "number" || data.age < 0) {
    return null;
  }
  
  return data as FormData; // まだ型アサーションが必要
}

After: Zodを使ったバリデーション

import { z } from "zod";

const FormSchema = z.object({
  username: z.string().min(3, "ユーザー名は3文字以上である必要があります"),
  email: z.string().email("有効なメールアドレスを入力してください"),
  age: z.number().min(0, "年齢は0以上である必要があります"),
});

type FormData = z.infer<typeof FormSchema>;

function validateForm(data: unknown): FormData {
  return FormSchema.parse(data); // バリデーションと型変換を同時に実行
}

// 使用例
function handleFormSubmission(rawData: unknown) {
  const validData = validateForm(rawData);
  // validDataは確実にFormData型
  console.log(`ユーザー: ${validData.username}, 年齢: ${validData.age}`);
}

refineでカスタムバリデーション

Zodのrefineメソッドは、基本的なバリデーション(文字列の長さ、数値の範囲など)を超えた、より複雑な検証ができます。

refineの動作原理

refineは以下の流れで動作します:

  1. 基本的なスキーマバリデーションが成功
  2. refineで指定した関数が実行される
  3. 関数がfalseを返すかエラーを投げると、バリデーション失敗
const schema = z.string().refine(
  (value) => value.includes('@'), // バリデーション関数
  { message: 'メールアドレス形式ではありません' } // エラーメッセージ
);

After: refineでカスタムバリデーション

const UserSchema = z.object({
  username: z.string().min(3),
  email: z.string().email().refine(
    email => email.endsWith("@company.com"),
    "会社のメールアドレスを使用してください"
  ),
  age: z.number().min(18, "18歳以上である必要があります"),
});

type UserData = z.infer<typeof UserSchema>;

function validateUser(data: unknown): UserData {
  return UserSchema.parse(data); // カスタムバリデーション込みで安全に変換
}

superRefineで複雑な相関バリデーション

複数フィールドの相関チェックや、エラーの結び付け先を制御したい場合はsuperRefineを使います:

const PasswordSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "パスワードが一致しません",
      path: ["confirmPassword"], // エラーを特定フィールドに結び付け
    });
  }
});

transformでデータ変換

transformメソッドは、バリデーション成功後にデータを別の形に変換する機能です。単なる型キャストではなく、実際のデータ操作を伴います。

transformの特徴

  • パイプライン処理:複数のtransformを連鎖させて段階的に変換可能
  • 型安全性:変換後の型も正しく推論される
// 文字列 → 数値 → 文字列の変換パイプライン
const schema = z.string()
  .transform(str => parseInt(str, 10)) // string → number
  .refine(num => !isNaN(num), 'Invalid number')
  .transform(num => `ID: ${num.toString().padStart(4, '0')}`); // number → string

// 使用例
schema.parse("123"); // "ID: 0123"

asでは型変換しかできませんが、Zodなら実際のデータ変換も同時に行えます。

Before: 手動でデータ変換

interface ProcessedData {
  email: string;
  normalizedName: string;
  ageGroup: "adult" | "minor";
}

function processData(raw: any): ProcessedData {
  const email = (raw.email as string).toLowerCase();
  const normalizedName = (raw.name as string).trim().toLowerCase();
  const ageGroup = (raw.age as number) >= 18 ? "adult" : "minor";
  
  return { email, normalizedName, ageGroup } as ProcessedData;
}

After: transformで変換

const ProcessedDataSchema = z.object({
  email: z.string().email().transform(email => email.toLowerCase()),
  name: z.string().transform(name => name.trim().toLowerCase()),
  age: z.number().min(0),
}).transform(data => ({
  email: data.email,
  normalizedName: data.name,
  ageGroup: data.age >= 18 ? "adult" as const : "minor" as const,
}));

type ProcessedData = z.infer<typeof ProcessedDataSchema>;

function processData(raw: unknown): ProcessedData {
  return ProcessedDataSchema.parse(raw); // バリデーションと変換を同時実行
}

safeParse を使ったエラーハンドリング

Zodには2つの主要なパース方法があります:

parseとsafeParseの違い

メソッド エラー時の動作 戻り値の型 使用場面
parse 例外を投げる T エラーが予期されない場面
safeParse Result型を返す SafeParseResult<T> エラーハンドリングが必要な場面

safeParseの戻り値構造

// 成功時
{ success: true, data: T }

// 失敗時  
{ success: false, error: ZodError }

この構造により、TypeScriptの型ガードを活用した安全なエラーハンドリングが可能になります。parseメソッドはバリデーションエラー時に例外を投げますが、safeParseを使うことで、エラーハンドリングをより柔軟に行えます。

Before: parseメソッド

function processApiResponse(data: unknown): User | null {
  // parseは例外を投げる可能性がある
  const user = UserSchema.parse(data);
  return user;
}

After: safeParseメソッド

function processApiResponse(data: unknown): User | null {
  const result = UserSchema.safeParse(data);
  
  if (result.success) {
    // バリデーション成功時はresult.dataに型安全なデータが入る
    return result.data; // User型として確実に使用可能
  } else {
    // エラー情報はresult.errorに含まれる
    console.error("バリデーションエラー:", result.error.errors);
    return null;
  }
}

as const と satisfies

記事ではasをできるだけ避ける方法を紹介しましたが、以下のは有用です:

as const - リテラル型の固定

// リテラル型を固定して型安全性を高める
const statusCodes = [200, 404, 500] as const;
type StatusCode = typeof statusCodes[number]; // 200 | 404 | 500

satisfies - 型適合性チェック

// オブジェクトリテラルの型適合性をチェック
const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
} satisfies { apiUrl: string; timeout: number };

これらは実行時の安全性を損なわない型レベルの操作なので、積極的に活用できます。

まとめ

型アサーションを使わずに済むZodの活用で、ランタイムエラーのリスクを減らし、より安全なTypeScriptコードを書けます。(やりすぎない程度に)特に外部データを扱う際は、Zodによる検証層を設けることで、予期しないデータ形式によるバグを事前に防げます。

GMOペパボ株式会社

Discussion