Zod で「never に推論される」問題の原因と対処:`narrowing` と `as const`
Zod を使ったスキーマ定義で、型推論が突然 never になってしまい、「なぜ?」と頭を抱えた経験はありませんか。
特に、配列やオブジェクトのリテラル値を使った定義では、TypeScript の型推論の仕組みと Zod の内部処理が絡み合い、予期せぬ never 型が生まれることがあります。この問題の根本原因は、TypeScript の 型の拡張(Type Widening) と 型の絞り込み(Narrowing) にあるのです。
今回は、Zod で never に推論される問題の原因を深掘りし、narrowing と as const を使った具体的な対処法を詳しく解説します。実際のコード例とともに、型推論のメカニズムを理解していきましょう。
背景
TypeScript の型推論の基本
TypeScript は、変数やオブジェクトのリテラル値から自動的に型を推論します。
しかし、この推論には「拡張」という特性があります。例えば、文字列リテラル "hello" は、そのまま "hello" 型として推論されるのではなく、より広い string 型として扱われることが一般的です。
typescript// 型推論の例
const str = 'hello'; // 型は string に拡張される
const arr = ['a', 'b', 'c']; // 型は string[] に拡張される
この拡張は、柔軟性を提供する一方で、Zod のような 厳密なスキーマ定義 と組み合わせると問題を引き起こします。
Zod におけるリテラル型の重要性
Zod は、スキーマ定義から TypeScript の型を推論するライブラリです。
特に、z.enum() や z.union() などでは、リテラル型("admin" や 42 といった具体的な値の型)を正確に捉える必要があります。しかし、TypeScript が型を拡張してしまうと、Zod は正確なリテラル型を取得できず、結果として never 型に推論されることがあるのです。
typescript// 問題が起きる例
const roles = ['admin', 'user', 'guest']; // string[] と推論される
const roleSchema = z.enum(roles); // ❌ Error: 型エラーが発生
以下の図は、TypeScript の型推論と Zod の期待する型の関係を示しています。
mermaidflowchart TB
literal["リテラル値<br/>['admin', 'user']"]
widening["TypeScript の型拡張"]
stringArray["string[] 型"]
zodExpect["Zod の期待<br/>リテラル型の配列"]
narrow["型の絞り込み<br/>(as const)"]
literalArray["readonly ['admin', 'user']"]
zodSuccess["Zod で正常に推論"]
literal --> widening
widening --> stringArray
stringArray -.->|型情報が失われる| zodExpect
literal --> narrow
narrow --> literalArray
literalArray -->|リテラル型を保持| zodSuccess
style stringArray fill:#ffcccc
style literalArray fill:#ccffcc
style zodSuccess fill:#ccffcc
図で理解できる要点:
- TypeScript はデフォルトでリテラル値を広い型(
string[])に拡張する - Zod はリテラル型の配列を期待するため、拡張された型では正しく動作しない
as constにより型を絞り込むことで、リテラル型を保持できる
課題
never 型が発生する具体的なケース
Zod で never 型が発生する代表的なケースを見ていきましょう。
ケース 1: z.enum での型拡張
z.enum() は、文字列リテラルの配列を受け取り、その値のいずれかに一致するスキーマを作成します。
typescript// 問題のあるコード
const userRoles = ['admin', 'editor', 'viewer'];
const userRoleSchema = z.enum(userRoles);
// ❌ エラー: Argument of type 'string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'
このエラーが発生する理由は、userRoles が string[] 型として推論されるためです。
Zod の z.enum() は、最初の要素を含む readonly な文字列リテラルのタプルを期待しています。
ケース 2: z.union での型情報の喪失
z.union() を使った複数スキーマの結合でも、同様の問題が発生します。
typescript// 動的に生成した配列からスキーマを作成
const statusList = ['pending', 'approved', 'rejected'];
const statusSchemas = statusList.map((s) => z.literal(s));
const statusSchema = z.union(statusSchemas);
// ❌ 型推論が never になる可能性
map() によって生成された配列は、元のリテラル型情報を失い、ZodLiteral<string>[] のような広い型になってしまいます。
ケース 3: オブジェクトのキーを使った動的スキーマ
オブジェクトのキーを使って動的にスキーマを生成する場合も要注意です。
typescript// 設定オブジェクトからスキーマを生成
const config = {
development: 'dev',
staging: 'stg',
production: 'prod',
};
const envKeys = Object.keys(config); // string[] になる
const envSchema = z.enum(envKeys);
// ❌ エラー: 型が合わない
Object.keys() は常に string[] を返すため、リテラル型情報が失われます。
以下の図は、これらのケースで型情報が失われるプロセスを示します。
mermaidflowchart TD
case1["ケース 1<br/>配列リテラル"]
case2["ケース 2<br/>map で生成"]
case3["ケース 3<br/>Object.keys"]
widen1["string[] に拡張"]
widen2["ZodLiteral<string>[] に拡張"]
widen3["string[] に拡張"]
zodEnum["z.enum()"]
zodUnion["z.union()"]
error["never 型または型エラー"]
case1 --> widen1
case2 --> widen2
case3 --> widen3
widen1 --> zodEnum
widen2 --> zodUnion
widen3 --> zodEnum
zodEnum --> error
zodUnion --> error
style error fill:#ffcccc
図で理解できる要点:
- 各ケースで型が拡張され、リテラル型情報が失われる
- Zod のスキーマ関数は拡張された型を受け入れられない
- 結果として
never型や型エラーが発生する
型エラーメッセージの例
実際に発生する型エラーを見てみましょう。
typescript// エラーコード: TS2769
const roles = ['admin', 'user'];
const schema = z.enum(roles);
このコードでは、以下のようなエラーメッセージが表示されます。
bashError: TS2769
Argument of type 'string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.
Target requires 2 element(s) but source may have fewer.
このエラーが示すのは、Zod が readonly なタプル型(最低 1 つの要素を持つ)を期待しているのに対し、実際には 可変長の string 配列が渡されているという不一致です。
解決策
as const による型の絞り込み
never 型問題の最も基本的な解決策は、as const アサーションを使うことです。
as const は、TypeScript にリテラル値を そのままの型として扱う ように指示します。
基本的な使い方
配列リテラルに as const を付けることで、型の拡張を防ぎます。
typescript// as const を使った解決
const userRoles = ['admin', 'editor', 'viewer'] as const;
このコードにより、userRoles の型は以下のように推論されます。
typescript// 推論される型
const userRoles: readonly ['admin', 'editor', 'viewer'];
これは readonly タプル型 であり、各要素がリテラル型として保持されます。
z.enum での適用
as const を使うことで、z.enum() が正常に動作します。
typescript// 解決策 1: as const を使用
const userRoles = ['admin', 'editor', 'viewer'] as const;
const userRoleSchema = z.enum(userRoles);
// 型推論の結果
type UserRole = z.infer<typeof userRoleSchema>;
// type UserRole = "admin" | "editor" | "viewer"
正しくリテラル型の Union として推論されました。
オブジェクトでの as const
オブジェクトに as const を適用すると、すべてのプロパティが readonly かつリテラル型になります。
typescript// オブジェクトに as const を適用
const config = {
development: 'dev',
staging: 'stg',
production: 'prod',
} as const;
推論される型は以下のようになります。
typescript// 推論される型
const config: {
readonly development: 'dev';
readonly staging: 'stg';
readonly production: 'prod';
};
このオブジェクトのキーや値を使って、正確なスキーマを定義できます。
以下の図は、as const による型の絞り込みプロセスを示しています。
mermaidflowchart LR
before["通常の配列<br/>['a', 'b', 'c']"]
infer1["型推論"]
wideType["string[]<br/>(拡張された型)"]
beforeConst["as const 付き<br/>['a', 'b', 'c'] as const"]
infer2["型推論"]
narrowType["readonly ['a', 'b', 'c']<br/>(リテラル型)"]
before --> infer1
infer1 --> wideType
beforeConst --> infer2
infer2 --> narrowType
wideType -.->|Zod で問題発生| zodFail["never 型"]
narrowType -->|Zod で正常動作| zodSuccess["'a' | 'b' | 'c'"]
style wideType fill:#ffcccc
style narrowType fill:#ccffcc
style zodFail fill:#ffcccc
style zodSuccess fill:#ccffcc
図で理解できる要点:
as constなしでは型がstring[]に拡張されるas constありでは readonly タプル型として推論される- Zod は readonly タプル型から正確なリテラル型を抽出できる
satisfies による型安全性の向上
TypeScript 4.9 以降で使える satisfies 演算子も、型の絞り込みに有効です。
satisfies は、値が特定の型を満たすことを検証しつつ、より厳密な型推論を保持します。
satisfies の基本
satisfies を使うと、型チェックを行いながら、リテラル型を保持できます。
typescript// satisfies を使った型安全な定義
const userRoles = [
'admin',
'editor',
'viewer',
] as const satisfies readonly string[];
const userRoleSchema = z.enum(userRoles);
この書き方により、以下の両方が保証されます。
| # | 保証内容 | 説明 |
|---|---|---|
| 1 | 型の制約 | satisfies readonly string[] により、文字列の配列であることを検証 |
| 2 | リテラル型の保持 | as const により、各要素がリテラル型として推論される |
| 3 | コンパイル時チェック | 誤った型の値が含まれていれば、コンパイルエラーになる |
satisfies を使った実践例
オブジェクトの型定義でも satisfies は便利です。
typescript// satisfies を使った設定オブジェクト
const apiConfig = {
development: {
url: 'http://localhost:3000',
timeout: 5000,
},
staging: {
url: 'https://stg.example.com',
timeout: 10000,
},
production: {
url: 'https://api.example.com',
timeout: 15000,
},
} as const satisfies Record<
string,
{ url: string; timeout: number }
>;
この定義により、以下が実現されます。
typescript// 環境名のスキーマ
const envNames = Object.keys(apiConfig) as Array<
keyof typeof apiConfig
>;
const envSchema = z.enum(envNames as [string, ...string[]]);
// 型推論の結果
type Env = z.infer<typeof envSchema>;
// type Env = "development" | "staging" | "production"
型ガードと narrowing の活用
より複雑なケースでは、型ガードを使った型の絞り込みが有効です。
カスタム型ガードの作成
特定の値が特定の型であることを保証する型ガード関数を作成します。
typescript// リテラル型の配列であることを保証する型ガード
function isStringLiteralArray<T extends readonly string[]>(
arr: readonly string[]
): arr is T {
return true;
}
この型ガードを使うことで、動的な配列でも型安全にスキーマを定義できます。
typescript// 型ガードを使った動的スキーマ定義
function createEnumSchema<T extends readonly string[]>(
values: T
) {
if (isStringLiteralArray<T>(values)) {
return z.enum(values as [T[0], ...T[]]);
}
throw new Error('Invalid enum values');
}
const roles = ['admin', 'user', 'guest'] as const;
const roleSchema = createEnumSchema(roles);
ジェネリクスとの組み合わせ
ジェネリクスを活用することで、再利用可能な型安全なヘルパー関数を作成できます。
typescript// ジェネリクスを使った汎用的なヘルパー関数
function createLiteralUnion<
T extends readonly [string, ...string[]]
>(values: T): z.ZodEnum<T> {
return z.enum(values);
}
// 使用例
const statusValues = [
'pending',
'approved',
'rejected',
] as const;
const statusSchema = createLiteralUnion(statusValues);
type Status = z.infer<typeof statusSchema>;
// type Status = "pending" | "approved" | "rejected"
このヘルパー関数により、以下のメリットが得られます。
| # | メリット | 詳細 |
|---|---|---|
| 1 | 型安全性 | ジェネリクスにより、入力値の型が正確に保持される |
| 2 | 再利用性 | 複数の enum 定義で同じ関数を使い回せる |
| 3 | 可読性 | 型アサーションの詳細を隠蔽し、コードがシンプルになる |
具体例
実践例 1: ユーザーロール管理
実際のアプリケーションで、ユーザーロールを管理するスキーマを作成しましょう。
ステップ 1: ロールの定義
まず、アプリケーションで使用するロールを定義します。
typescript// ユーザーロールの定義
const USER_ROLES = [
'admin',
'editor',
'viewer',
'guest',
] as const;
as const により、各ロールがリテラル型として保持されます。
ステップ 2: スキーマの作成
定義したロールから、Zod スキーマを作成します。
typescript// Zod スキーマの作成
import { z } from 'zod';
const userRoleSchema = z.enum(USER_ROLES);
このスキーマは、指定された 4 つのロールのいずれかに一致する値のみを受け入れます。
ステップ 3: 型の抽出と使用
スキーマから TypeScript の型を抽出し、型安全なコードを書きます。
typescript// 型の抽出
type UserRole = z.infer<typeof userRoleSchema>;
// type UserRole = "admin" | "editor" | "viewer" | "guest"
// ユーザーオブジェクトのスキーマ
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.email(),
role: userRoleSchema,
});
type User = z.infer<typeof userSchema>;
ステップ 4: バリデーション関数の実装
スキーマを使ったバリデーション関数を実装します。
typescript// バリデーション関数
function validateUser(data: unknown): User {
// parse は検証に失敗すると ZodError を throw する
return userSchema.parse(data);
}
// safeParse を使った安全なバリデーション
function safeValidateUser(data: unknown) {
// safeParse は検証結果を Result 型で返す
const result = userSchema.safeParse(data);
if (result.success) {
// 検証成功: result.data に型安全な値が入る
return { success: true, user: result.data };
} else {
// 検証失敗: result.error にエラー情報が入る
return { success: false, errors: result.error.errors };
}
}
ステップ 5: 実際の使用例
API レスポンスのバリデーションに使用します。
typescript// API レスポンスのバリデーション例
async function fetchUser(
userId: string
): Promise<User | null> {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// Zod でバリデーション
const validatedUser = validateUser(data);
return validatedUser;
} catch (error) {
if (error instanceof z.ZodError) {
// バリデーションエラーをログに記録
console.error('Validation error:', error.errors);
}
return null;
}
}
この実装により、以下が保証されます。
- API から受け取ったデータが正しい形式であることを検証
roleフィールドが定義されたロールのいずれかであることを保証- TypeScript の型システムと連携し、型安全なコードを実現
実践例 2: 環境設定の管理
次に、アプリケーションの環境設定を管理する例を見ていきます。
ステップ 1: 環境設定オブジェクトの定義
環境ごとの設定を as const で定義します。
typescript// 環境設定の定義
const ENV_CONFIG = {
development: {
apiUrl: 'http://localhost:3000',
debug: true,
logLevel: 'verbose',
},
staging: {
apiUrl: 'https://stg-api.example.com',
debug: true,
logLevel: 'info',
},
production: {
apiUrl: 'https://api.example.com',
debug: false,
logLevel: 'error',
},
} as const;
ステップ 2: 環境名のスキーマ作成
環境名を検証するスキーマを作成します。
typescript// 環境名の型を取得
type EnvName = keyof typeof ENV_CONFIG;
// type EnvName = "development" | "staging" | "production"
// 環境名のタプルを作成
const envNames = Object.keys(ENV_CONFIG) as [
EnvName,
...EnvName[]
];
// Zod スキーマの作成
const envNameSchema = z.enum(envNames);
Object.keys() の結果を型アサーションすることで、正確な型を保持しています。
ステップ 3: ログレベルのスキーマ作成
ログレベルも同様にスキーマ化します。
typescript// ログレベルの定義
const LOG_LEVELS = [
'verbose',
'info',
'warn',
'error',
] as const;
// ログレベルのスキーマ
const logLevelSchema = z.enum(LOG_LEVELS);
type LogLevel = z.infer<typeof logLevelSchema>;
ステップ 4: 環境設定の型定義
環境設定全体のスキーマを定義します。
typescript// 環境設定のスキーマ
const envConfigSchema = z.object({
apiUrl: z.string().url(),
debug: z.boolean(),
logLevel: logLevelSchema,
});
// 環境設定マップのスキーマ
const envConfigMapSchema = z.record(
envNameSchema,
envConfigSchema
);
ステップ 5: 設定の取得と検証
環境に応じた設定を取得する型安全な関数を実装します。
typescript// 現在の環境を取得する関数
function getCurrentEnv(): EnvName {
// process.env.NODE_ENV から環境を取得
const nodeEnv = process.env.NODE_ENV || 'development';
// Zod で検証
const result = envNameSchema.safeParse(nodeEnv);
if (result.success) {
return result.data;
} else {
// デフォルトは development
console.warn(
`Unknown environment: ${nodeEnv}, using development`
);
return 'development';
}
}
// 環境設定を取得する関数
function getConfig(env: EnvName) {
// ENV_CONFIG から設定を取得し、検証
const config = ENV_CONFIG[env];
return envConfigSchema.parse(config);
}
// 現在の環境の設定を取得
const currentConfig = getConfig(getCurrentEnv());
この実装により、以下が実現されます。
| # | 実現内容 | 詳細 |
|---|---|---|
| 1 | 環境名の検証 | 不正な環境名を実行時に検出 |
| 2 | 設定の型安全性 | 設定オブジェクトの構造が保証される |
| 3 | IDE サポート | 補完やエラーチェックが効く |
| 4 | ランタイムセーフティ | 設定ミスを実行時に検出できる |
実践例 3: API ステータスコードの処理
API のレスポンスステータスを型安全に扱う例を見ていきましょう。
ステップ 1: ステータスコードの定義
API で使用するステータスコードを定義します。
typescript// HTTP ステータスコードの定義
const HTTP_STATUS = {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_SERVER_ERROR: 500,
} as const;
ステップ 2: ステータスコードのスキーマ作成
数値のリテラル型として扱うスキーマを作成します。
typescript// ステータスコードの値を配列で取得
const statusCodeValues = Object.values(HTTP_STATUS);
// readonly [200, 201, 400, 401, 403, 404, 500]
// Zod スキーマの作成
const httpStatusSchema = z.union([
z.literal(200),
z.literal(201),
z.literal(400),
z.literal(401),
z.literal(403),
z.literal(404),
z.literal(500),
]);
// 型の抽出
type HttpStatus = z.infer<typeof httpStatusSchema>;
// type HttpStatus = 200 | 201 | 400 | 401 | 403 | 404 | 500
数値のリテラル型を扱う場合は、z.literal() を使って各値を定義します。
ステップ 3: レスポンススキーマの定義
API レスポンス全体のスキーマを定義します。
typescript// 成功レスポンスのスキーマ
const successResponseSchema = z.object({
status: z.union([z.literal(200), z.literal(201)]),
data: z.unknown(), // データの型は状況に応じて定義
});
// エラーレスポンスのスキーマ
const errorResponseSchema = z.object({
status: z.union([
z.literal(400),
z.literal(401),
z.literal(403),
z.literal(404),
z.literal(500),
]),
error: z.object({
message: z.string(),
code: z.string().optional(),
}),
});
ステップ 4: ステータス判定関数の実装
レスポンスのステータスに応じた処理を行う関数を実装します。
typescript// ステータスが成功系かを判定する型ガード
function isSuccessStatus(
status: number
): status is 200 | 201 {
return status === 200 || status === 201;
}
// ステータスに応じた処理
function handleApiResponse(response: {
status: number;
data: unknown;
}) {
// ステータスコードの検証
const statusResult = httpStatusSchema.safeParse(
response.status
);
if (!statusResult.success) {
throw new Error(
`Unknown status code: ${response.status}`
);
}
const status = statusResult.data;
// 型ガードによる分岐
if (isSuccessStatus(status)) {
// 成功レスポンスの処理
const validResponse =
successResponseSchema.parse(response);
return { success: true, data: validResponse.data };
} else {
// エラーレスポンスの処理
const validResponse =
errorResponseSchema.parse(response);
return { success: false, error: validResponse.error };
}
}
ステップ 5: 実際の使用例
fetch API と組み合わせて使用します。
typescript// API 呼び出しのラッパー関数
async function callApi<T>(
url: string,
schema: z.ZodType<T>
) {
try {
const response = await fetch(url);
const data = await response.json();
// レスポンス全体を検証
const apiResponse = {
status: response.status,
data,
};
const result = handleApiResponse(apiResponse);
if (result.success) {
// データスキーマで追加検証
return schema.parse(result.data);
} else {
throw new Error(result.error.message);
}
} catch (error) {
if (error instanceof z.ZodError) {
console.error('Validation error:', error.errors);
}
throw error;
}
}
// 使用例: ユーザー取得 API
const userDataSchema = z.object({
id: z.string(),
name: z.string(),
email: z.email(),
});
const userData = await callApi(
'/api/user/123',
userDataSchema
);
以下の図は、API レスポンス処理のフローを示しています。
mermaidsequenceDiagram
participant Client as クライアント
participant Fetch as fetch API
participant Handler as handleApiResponse
participant Schema as Zod スキーマ
Client->>Fetch: API リクエスト
Fetch->>Handler: レスポンス
Handler->>Schema: ステータスコード検証
alt ステータスコードが不正
Schema-->>Handler: ZodError
Handler-->>Client: エラー
else ステータスコードが正常
Schema-->>Handler: 検証成功
alt 成功ステータス (200, 201)
Handler->>Schema: 成功レスポンス検証
Schema-->>Handler: データ
Handler-->>Client: 成功データ
else エラーステータス (4xx, 5xx)
Handler->>Schema: エラーレスポンス検証
Schema-->>Handler: エラー情報
Handler-->>Client: エラー情報
end
end
図で理解できる要点:
- API レスポンスは段階的に検証される
- ステータスコードによって異なるスキーマが適用される
- 型安全性が保たれたまま、エラーハンドリングが行われる
この実装により、以下のメリットが得られます。
- ステータスコードが型レベルで保証される
- 不正なステータスコードを実行時に検出
- 成功とエラーで異なる処理を型安全に実装
- IDE の補完が効き、開発効率が向上
実践例 4: 動的なフォームフィールド定義
最後に、動的に生成されるフォームフィールドを型安全に扱う例を見ていきます。
ステップ 1: フィールドタイプの定義
フォームで使用するフィールドタイプを定義します。
typescript// フィールドタイプの定義
const FIELD_TYPES = [
'text',
'email',
'number',
'select',
'checkbox',
] as const;
const fieldTypeSchema = z.enum(FIELD_TYPES);
type FieldType = z.infer<typeof fieldTypeSchema>;
// type FieldType = "text" | "email" | "number" | "select" | "checkbox"
ステップ 2: フィールド定義のスキーマ作成
各フィールドの設定を表すスキーマを作成します。
typescript// 基本フィールドのスキーマ
const baseFieldSchema = z.object({
name: z.string(),
label: z.string(),
type: fieldTypeSchema,
required: z.boolean().default(false),
placeholder: z.string().optional(),
});
// select フィールド専用のスキーマ
const selectFieldSchema = baseFieldSchema.extend({
type: z.literal('select'),
options: z
.array(
z.object({
value: z.string(),
label: z.string(),
})
)
.min(1), // 最低 1 つの選択肢が必要
});
// フィールド全体のスキーマ(判別可能な Union)
const fieldSchema = z.discriminatedUnion('type', [
baseFieldSchema.extend({ type: z.literal('text') }),
baseFieldSchema.extend({ type: z.literal('email') }),
baseFieldSchema.extend({ type: z.literal('number') }),
selectFieldSchema,
baseFieldSchema.extend({ type: z.literal('checkbox') }),
]);
type Field = z.infer<typeof fieldSchema>;
z.discriminatedUnion() を使うことで、type フィールドに基づいた型の絞り込みが可能になります。
ステップ 3: フォーム定義のスキーマ
フォーム全体の定義スキーマを作成します。
typescript// フォーム定義のスキーマ
const formDefinitionSchema = z.object({
formId: z.string(),
title: z.string(),
description: z.string().optional(),
fields: z.array(fieldSchema).min(1), // 最低 1 つのフィールドが必要
});
type FormDefinition = z.infer<typeof formDefinitionSchema>;
ステップ 4: フォームビルダー関数の実装
フォーム定義から実際のフォームを生成する関数を実装します。
typescript// フォームビルダー関数
function buildForm(definition: unknown): FormDefinition {
// フォーム定義を検証
return formDefinitionSchema.parse(definition);
}
// フィールドの検証ルールを生成する関数
function createFieldValidation(field: Field) {
// type に応じて適切なスキーマを返す
switch (field.type) {
case 'text':
return z.string().min(field.required ? 1 : 0);
case 'email':
return z.string().email();
case 'number':
return z.number();
case 'select':
// select の options から値を抽出
const validValues = field.options.map(
(opt) => opt.value
);
return z.enum(validValues as [string, ...string[]]);
case 'checkbox':
return z.boolean();
}
}
この関数では、TypeScript の型の絞り込みにより、field.type が "select" の場合のみ field.options にアクセスできます。
ステップ 5: 動的なバリデーションスキーマの生成
フォーム定義から、実行時にバリデーションスキーマを生成します。
typescript// 動的にバリデーションスキーマを生成
function createFormValidationSchema(
definition: FormDefinition
) {
// フィールド定義から Zod スキーマのオブジェクトを生成
const schemaShape: Record<string, z.ZodTypeAny> = {};
for (const field of definition.fields) {
const fieldValidation = createFieldValidation(field);
// required に応じて optional を追加
schemaShape[field.name] = field.required
? fieldValidation
: fieldValidation.optional();
}
// z.object でスキーマを作成
return z.object(schemaShape);
}
// 使用例
const formDef: FormDefinition = {
formId: 'user-registration',
title: 'ユーザー登録フォーム',
fields: [
{
name: 'username',
label: 'ユーザー名',
type: 'text',
required: true,
placeholder: '例: tanaka_taro',
},
{
name: 'email',
label: 'メールアドレス',
type: 'email',
required: true,
},
{
name: 'age',
label: '年齢',
type: 'number',
required: false,
},
{
name: 'country',
label: '国',
type: 'select',
required: true,
options: [
{ value: 'jp', label: '日本' },
{ value: 'us', label: 'アメリカ' },
{ value: 'uk', label: 'イギリス' },
],
},
],
};
// フォーム定義を検証
const validatedFormDef = buildForm(formDef);
// バリデーションスキーマを生成
const formValidationSchema = createFormValidationSchema(
validatedFormDef
);
ステップ 6: フォームデータの検証
生成したスキーマを使って、実際のフォームデータを検証します。
typescript// フォームデータの検証関数
function validateFormData(
definition: FormDefinition,
data: unknown
) {
// スキーマを生成
const schema = createFormValidationSchema(definition);
// データを検証
const result = schema.safeParse(data);
if (result.success) {
return { success: true, data: result.data };
} else {
// エラーをフィールド名ごとにマッピング
const errors: Record<string, string[]> = {};
for (const issue of result.error.issues) {
const fieldName = issue.path[0] as string;
if (!errors[fieldName]) {
errors[fieldName] = [];
}
errors[fieldName].push(issue.message);
}
return { success: false, errors };
}
}
// 使用例
const submittedData = {
username: 'tanaka_taro',
email: 'invalid-email', // 不正なメールアドレス
country: 'jp',
};
const validationResult = validateFormData(
validatedFormDef,
submittedData
);
if (!validationResult.success) {
console.log(validationResult.errors);
// { email: ["Invalid email"] }
}
この実装により、以下が実現されます。
| # | 実現内容 | 詳細 |
|---|---|---|
| 1 | 動的なフォーム定義 | JSON などから動的にフォームを生成できる |
| 2 | 型安全なフィールド定義 | フィールドタイプに応じた型チェックが効く |
| 3 | 実行時バリデーション | フォームデータを自動で検証できる |
| 4 | エラーメッセージのマッピング | フィールドごとのエラーを簡単に表示できる |
| 5 | 拡張性 | 新しいフィールドタイプを簡単に追加できる |
以下の図は、動的フォーム処理のフローを示しています。
mermaidflowchart TD
formDef["フォーム定義<br/>(JSON/Object)"]
validate["定義を検証<br/>buildForm()"]
generate["スキーマ生成<br/>createFormValidationSchema()"]
formData["フォームデータ<br/>(ユーザー入力)"]
validateData["データ検証<br/>validateFormData()"]
success["検証成功<br/>型安全なデータ"]
failure["検証失敗<br/>エラーマップ"]
formDef --> validate
validate -->|FormDefinition| generate
generate -->|Zod スキーマ| validateData
formData --> validateData
validateData -->|成功| success
validateData -->|失敗| failure
style formDef fill:#e1f5ff
style success fill:#ccffcc
style failure fill:#ffcccc
図で理解できる要点:
- フォーム定義は最初に検証される
- 検証済みの定義から動的にスキーマが生成される
- 生成されたスキーマでユーザー入力を検証する
- 型安全性が保たれたまま、動的な処理が実現される
まとめ
Zod で型が never に推論される問題は、TypeScript の型拡張と Zod のリテラル型要求のギャップから生じます。
この問題を解決する鍵は、as const による型の絞り込みです。as const を使うことで、配列やオブジェクトのリテラル値がそのまま型として保持され、Zod が正確な型推論を行えるようになります。
より高度な型安全性を求める場合は、satisfies 演算子を組み合わせることで、型チェックとリテラル型の保持を両立できます。また、カスタム型ガードやジェネリクスを活用することで、再利用可能で型安全なヘルパー関数を作成できます。
実践例で見てきたように、これらのテクニックは以下のような場面で威力を発揮します。
| # | 適用場面 | 効果 |
|---|---|---|
| 1 | 固定値の enum 定義 | ロールやステータスなどの定数を型安全に扱える |
| 2 | 環境設定の管理 | 環境ごとの設定を型付きで管理できる |
| 3 | API レスポンス処理 | ステータスコードに応じた型安全な分岐処理 |
| 4 | 動的フォーム生成 | 定義から実行時にバリデーションを生成 |
Zod と TypeScript の型システムを理解し、適切に活用することで、実行時とコンパイル時の両方で型安全なコードを書けるようになります。never 型問題に直面したときは、まず as const を試してみてください。それだけで多くの問題が解決するはずです。
型推論の仕組みを理解することは、より堅牢で保守性の高いコードを書く第一歩です。ぜひ、今回紹介したテクニックを実際のプロジェクトで活用してみてください。
関連リンク
以下のリンクで、Zod と TypeScript の型推論についてさらに詳しく学べます。
articleZod で「never に推論される」問題の原因と対処:`narrowing` と `as const`
articleZod vs Ajv/Joi/Valibot/Superstruct:DX・速度・サイズを本気でベンチ比較
articleZod × OpenAPI:`zod-to-openapi` で契約からドキュメントを自動生成
articleZod で CSV/TSV インポートを安全に処理:パース → 検証 → 差分レポート
articleZod のブランド型(Branding)設計:メール・ULID・金額などの値オブジェクト化
articleZod クイックリファレンス:`string/number/boolean/date/enum/literal` 速見表
article【2025 年 10 月 29 日発表】VS Code、Copilot が仕様作成を支援する「Plan モード」とは?
articleZustand × useTransition 概説:並列レンダリング時代に安全な更新を設計する
articleHaystack とは?RAG 検索 × 生成 AI を実務投入するための完全入門【2025 年版】
articleWordPress × Bedrock/Composer 入門:プラグイン管理をコード化する
articleZod で「never に推論される」問題の原因と対処:`narrowing` と `as const`
articleWebSocket 活用事例:金融トレーディング板情報の超低遅延配信アーキテクチャ
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来