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の場合エラー
問題点
- ランタイム検証がない:APIから返されるデータの形式が変わってもエラーが発生しない
- 型の不整合:TypeScriptは型が正しいと信じてしまう
- デバッグが困難:実行時まで問題が発覚しない
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
は以下の流れで動作します:
- 基本的なスキーマバリデーションが成功
-
refine
で指定した関数が実行される - 関数が
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による検証層を設けることで、予期しないデータ形式によるバグを事前に防げます。
Discussion