T-CREATOR

Zod で「never に推論される」問題の原因と対処:`narrowing` と `as const`

Zod で「never に推論される」問題の原因と対処:`narrowing` と `as const`

Zod を使ったスキーマ定義で、型推論が突然 never になってしまい、「なぜ?」と頭を抱えた経験はありませんか。

特に、配列やオブジェクトのリテラル値を使った定義では、TypeScript の型推論の仕組みと Zod の内部処理が絡み合い、予期せぬ never 型が生まれることがあります。この問題の根本原因は、TypeScript の 型の拡張(Type Widening)型の絞り込み(Narrowing) にあるのです。

今回は、Zod で never に推論される問題の原因を深掘りし、narrowingas 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[]]'

このエラーが発生する理由は、userRolesstring[] 型として推論されるためです。

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&lt;string&gt;[] に拡張"]
  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設定の型安全性設定オブジェクトの構造が保証される
3IDE サポート補完やエラーチェックが効く
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環境設定の管理環境ごとの設定を型付きで管理できる
3API レスポンス処理ステータスコードに応じた型安全な分岐処理
4動的フォーム生成定義から実行時にバリデーションを生成

Zod と TypeScript の型システムを理解し、適切に活用することで、実行時とコンパイル時の両方で型安全なコードを書けるようになります。never 型問題に直面したときは、まず as const を試してみてください。それだけで多くの問題が解決するはずです。

型推論の仕組みを理解することは、より堅牢で保守性の高いコードを書く第一歩です。ぜひ、今回紹介したテクニックを実際のプロジェクトで活用してみてください。

関連リンク

以下のリンクで、Zod と TypeScript の型推論についてさらに詳しく学べます。