はじめに
この投稿は、TypeScript学習者が自身の理解のために書いています。フォームのバリデーションエラーの型をMapped Typesで書くとき、フィールドごとに型を変えたり、ネストしたフィールドに対応したりする拡張パターンを整理していきます。
会員管理アプリの登録フォームを例にします。フォームの値の型MemberFormValuesに対し、エラーの型はMapped Typesで次のように書けます。
type MemberFormValues = {
name: string;
email: string;
phone: string;
department: string;
};
type MemberFormErrors = {
[K in keyof MemberFormValues]?: string;
};
[K in keyof T]?: stringは、Partial<Record<keyof T, string>>と同型です。この形を出発点に、Mapped TypesとConditional Typesを組み合わせた形と、ネストしたプロパティにMapped Typesを再帰的に適用する形を見ていきます。
React Hook FormやZodには、ネストしたエラーを扱う機能も用意されています。本投稿はライブラリを使わず、型の組み立て方だけを見ます。
1. フィールドごとにエラーの型を変える——Mapped TypesとConditional Types
すべてstringではなく、emailだけstring[]にしたい場合、Mapped Typesの値側をConditional Typesで分岐させます。
type MemberFormValues = {
name: string;
email: string;
phone: string;
department: string;
};
type MemberFormErrors = {
[K in keyof MemberFormValues]?: K extends "email" ? string[] : string;
};
const errors: MemberFormErrors = {};
errors.name = "氏名は必須です";
errors.email = ["形式が不正です", "すでに登録済みです"];
errors.phone = "数字のみ入力してください";
K extends "email" ? string[] : stringはConditional Typesです。[K in keyof MemberFormValues]でキーを1つずつ回しながら、キー名に応じて値の型を変えています。
会員フォームと商品フォームのように複数画面で使い回すなら、FormErrors<T, MultiLineKeys>として共通化できます。複数メッセージにしたいキーだけ型引数で渡す形にします。
type FormErrors<
T extends Record<string, unknown>,
MultiLineKeys extends keyof T = never,
> = {
[K in keyof T]?: K extends MultiLineKeys ? string[] : string;
};
type ProductFormValues = { sku: string; name: string; price: number };
type MemberFormErrors = FormErrors<MemberFormValues, "email">;
type ProductFormErrors = FormErrors<ProductFormValues>;
MemberFormErrorsではemailだけstring[]、それ以外はstringです。ProductFormErrorsはMultiLineKeysの既定値がneverなので、すべてstringのままです。
2. ネストしたフィールド——Mapped Typesを再帰的に適用
MemberFormValuesにAddressFormValues型のaddressプロパティを足すと、[K in keyof T]?: stringではaddressキー全体に1つのstringしか載せられません。postalCodeやcityのように子プロパティごとのエラーを返すには、Mapped Typesの値側をConditional Typesで分岐し、オブジェクトのプロパティにMapped Typesを再帰的に適用します。
type AddressFormValues = {
postalCode: string;
city: string;
};
type MemberFormValues = {
name: string;
email: string;
address: AddressFormValues;
};
type NestedFormErrors<T> = {
[K in keyof T]?: T[K] extends string | number | boolean
? string
: T[K] extends readonly unknown[]
? string
: T[K] extends Date
? string
: T[K] extends object
? NestedFormErrors<T[K]> | string
: string;
};
type MemberFormErrors = NestedFormErrors<MemberFormValues>;
const errors: MemberFormErrors = {};
errors.name = "氏名は必須です";
errors.address = { postalCode: "7桁で入力してください" };
errors.address = { postalCode: "7桁で入力してください", city: "市区町村は必須です" };
errors.address = "住所全体を確認してください";
T[K]がプリミティブならstring、プレーンなobjectならNestedFormErrors<T[K]> | stringです。addressプロパティには子プロパティごとのエラー对象か、プロパティ全体への1メッセージのどちらかを載せられます。
T[K] extends objectだけだと、string[]やDateもobjectに当たり、意図しない再帰になります。上の定義では配列とDateを先にstringの葉として分岐しています。フォームに関数やクラスインスタンスが混ざる場合は、同様にもう一段除外が要ります。
まとめ
- フォームエラーの型の基本は
{ [K in keyof T]?: string }としてMapped Typesで書ける。 - 値を
K extends MultiLineKeys ? string[] : stringのようにConditional Typesで分岐し、型引数で複数フォームに使い回せる。 - ネストしたプロパティごとにエラーを載せるには、Mapped Typesの値側をConditional Typesで分岐し、オブジェクトのプロパティにMapped Typesを再帰的に適用して書ける。