第21章:フォームの項目別エラー設計(UX)🫶📝✨
この章のゴール🎯
フォームで失敗したときに、ユーザーが 「どこが悪いか一瞬で分かって、すぐ直せる」 形を作れるようになるよ😊💪 具体的には👇
- fieldErrors(項目別):どの入力欄がダメかをピンポイントで伝える🎯
- formError(全体):フォーム全体の問題(例:送信できない、矛盾がある)を伝える🧾
- 再入力ガイド:直し方まで短く案内する🗺️✨
![フォームアシスト:入力欄の横で優しくガイドする妖精[(./picture/err_model_ts_study_021_form_assist.png)
1) まず大前提:エラー表示は「設計」だよ🧠🎀
フォームのエラーは、ただ出せばOKじゃなくて、
- 見つけやすさ(どこ?)👀
- 直しやすさ(どう直す?)🛠️
- 安心感(怒られない&責めない)🥹
- アクセシビリティ(誰でも使える)♿✨
が大事!
GOV.UKのデザインガイドでも、入力欄の近くのエラーメッセージ+ページ上部の要約(Error summary) をセットで出すのを推奨してるよ📌(GOV.UK Design System)
2) エラーの置き場所は3つだけ覚えればOK🧺✨
A. fieldErrors(項目別)🧷
- 「メールが変」「パスワード短い」みたいな 入力欄に紐づく失敗
- できれば その入力欄のすぐ近く に表示💡
- さらに、ページ上部の エラー要約 にも同じ文言で載せると迷子になりにくいよ🗺️(GOV.UK Design System)
B. formError(全体)🧾
- 「送信に失敗しました(通信)」みたいな フォーム全体の問題
- 「パスワードと確認用が一致しない」みたいな 複数項目にまたがる矛盾(※どこかの項目に寄せられるなら寄せるのが基本🙆♀️)
C. 再入力ガイド(直し方)🧭
- 「正しい形式で」だけだと冷たい&分かりにくい😢
- 「@を含むメールアドレスを入力してね」みたいに 次の行動 がわかる文にする✨
3) TypeScriptで「エラーの形」を決めよう🏗️🎁
ここでは、フォームのエラー状態を 毎回ブレない形 に固定するよ😊 おすすめは「文字列だけ」より、code(機械向け)+message(表示向け) の二段構え✨
// ① どのフィールドがあるか(例:サインアップ)
type SignUpField = "email" | "password" | "passwordConfirm";
// ② 項目別エラー(1項目に複数あり得る)
type FieldIssue = {
code:
| "REQUIRED"
| "INVALID_FORMAT"
| "TOO_SHORT"
| "TOO_LONG"
| "NOT_MATCH"
| "DUPLICATE";
message: string; // 画面に出す文(最終的に日本語)
hint?: string; // 直し方ガイド(任意)
};
// ③ フォーム全体エラー(ページ上部バナー等)
type FormIssue = {
code: "CROSS_FIELD" | "SUBMIT_FAILED" | "NETWORK" | "SERVER";
message: string;
actionHint?: string; // 例:「時間をおいて再試行してね」など
};
// ④ これが「フォームエラーの標準形」✨
type FormProblems<F extends string> = {
fieldErrors: Partial<Record<F, FieldIssue[]>>;
formError?: FormIssue;
};
// ⑤ Resultっぽく返す(この教材の流れに合わせて)
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;
ポイント💡
- 1フィールド=1エラーとは限らない(「必須」「形式」「長さ」など)
- でも全部出すとウザいときもあるので、出す数の戦略が必要(後でやるよ😆)
4) 「同じ失敗」をfieldErrorsとformErrorで出し分ける練習💡📝
例題:パスワード確認が一致しない🔁
- 本質:2項目にまたがる矛盾(Cross-field)
- でもユーザーにとっては「どこ直すの?」が大事!
おすすめ設計👇
- passwordConfirm に fieldErrors を出す(直す場所が明確)
- 必要なら formError は出さない(二重に言われると疲れる🥺)
function validatePasswordMatch(
password: string,
confirm: string
): FormProblems<SignUpField> | null {
if (password === confirm) return null;
return {
fieldErrors: {
passwordConfirm: [
{
code: "NOT_MATCH",
message: "パスワードが一致していないよ😣",
hint: "もう一度、同じパスワードを入力してね✍️",
},
],
},
};
}
5) バリデーションライブラリのエラーを「標準形」に変換する🧼✨
Zod v4の最新:z.treeifyError() / z.flattenError() が主役🌳🧾
Zod v4では ZodErrorの .flatten() が非推奨 になっていて、代わりに トップレベル関数を使うのが今の流れだよ📌(Zod)
特にフォーム用途なら z.flattenError() がちょうど良い!
formErrors(全体)と fieldErrors(項目別)が分かれて返ってくるのが強い💪✨(Zod)
import * as z from "zod";
const SignUpSchema = z.object({
email: z.string().email({ error: "メールの形がちがうよ📧💦" }),
password: z.string().min(8, { error: "パスワードは8文字以上だよ🔐" }),
passwordConfirm: z.string(),
}).refine((v) => v.password === v.passwordConfirm, {
path: ["passwordConfirm"],
message: "パスワードが一致していないよ😣",
});
type SignUpInput = z.infer<typeof SignUpSchema>;
function validateSignUp(input: unknown): Result<SignUpInput, FormProblems<SignUpField>> {
const r = SignUpSchema.safeParse(input);
if (r.success) return { ok: true, value: r.data };
const flat = z.flattenError(r.error); // v4の推奨ルート🧾✨
const fieldErrors: FormProblems<SignUpField>["fieldErrors"] = {};
// flat.fieldErrors は { [key: string]: string[] } みたいな形
for (const [key, messages] of Object.entries(flat.fieldErrors)) {
// 必要なら code 付与(ここでは簡易に全部 INVALID_FORMAT にしてる)
fieldErrors[key as SignUpField] = messages.map((m) => ({
code: "INVALID_FORMAT",
message: m,
}));
}
const formError = flat.formErrors.length
? { code: "CROSS_FIELD", message: flat.formErrors[0] }
: undefined;
return { ok: false, error: { fieldErrors, formError } };
}
Valibotも「flatten」があるよ🧻✨
Valibotも issues を flatten して root(全体)/nested(項目) を取り出せる設計になってるよ🧩(Valibot)
6) UIに出すときの「アクセシブル設計」超重要♿✨
基本セット🎒
- 入力が無効なら
aria-invalidを使う(無効状態を支援技術に伝える)(MDN Web Docs) - エラーメッセージは
aria-describedbyで入力欄に紐づける(MDN Web Docs) aria-errormessageを使うなら aria-invalid が true のときだけ&参照先は 表示されている必要があるよ📌(MDN Web Docs)
type Props = {
id: string;
label: string;
value: string;
onChange: (v: string) => void;
issues?: FieldIssue[];
};
export function TextField({ id, label, value, onChange, issues }: Props) {
const errorId = `${id}-error`;
const hasError = !!issues?.length;
return (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-invalid={hasError ? "true" : undefined}
aria-describedby={hasError ? errorId : undefined}
/>
{hasError && (
<p id={errorId}>
{/* まず1件だけ出す戦略(後で説明するね) */}
{issues![0].message} {issues![0].hint ? `(${issues![0].hint})` : ""}
</p>
)}
</div>
);
}
7) UXで事故らないための鉄板ルール集🧷✨
✅ ルール1:項目の近く+上部の要約(エラーサマリ)📌
「上にエラーあります」だけだと探すの大変🥺 GOV.UKは エラー要約+各項目のエラーメッセージ をセットで出す方針だよ(GOV.UK Design System)
✅ ルール2:同じ文言を使う(上と下で違うと混乱)🔁
GOV.UKも「一貫性」を強く言ってるよ🧠✨(GOV.UK Design System)
✅ ルール3:入力内容を消さない🧽❌
直すために必要な情報まで消えると地獄😇 「消さない」も明確に推奨されてるよ(GOV.UK Design System)
✅ ルール4:ふわっとした言葉を避ける🙅♀️
「無効です」「正しくありません」だけは弱い… 何がダメで、どう直すかが分かる文にしよ💬✨(GOV.UK Design System)
8) 「複数エラー」をどう見せる?戦略を決めよ😵💫➡️😌
戦略A:1項目につき最初の1件だけ(初心者向け・優しい)🌷
- 例:「必須」→直したら次のエラーが出る
- UIがスッキリ✨
戦略B:1項目に最大2件まで(バランス型)⚖️
- 「必須」+「形式」みたいに関連が強いものだけ
戦略C:全部出す(上級者向け・業務フォームでたまに)📚
- ただし圧が強いので注意😇
この教材ではおすすめは AかB 😊👍
9) ミニ演習📝🎀(同じ失敗を“項目別”と“全体”で表現し分ける)
演習1:次の失敗を分類してね🏷️
- メールが空
- メール形式が変
- パスワード8文字未満
- パスワード確認が一致しない
- 「登録」押したら通信エラー
- すでに使われているメール(サーバで判明)
👉 それぞれ fieldErrors / formError のどっち?(両方はなるべく避ける✨)
演習2:やさしい文言にリライト💬✨
「Invalid input」「Required」「Error 0x000...」みたいな文を、 女子大生の友だちに言う感じで直してみてね🥰🌸
10) AI活用テンプレ🤖✨(そのままコピペOK!)
- 「このフォーム(項目:email/password/passwordConfirm)の失敗ケースを、fieldErrors と formError に分けて列挙して😊」
- 「“責めない日本語”で、短くて分かりやすいエラーメッセージに直して!絵文字も少し入れて✨」
- 「このエラー文言、ユーザーが次に何をすればいいか分かる?改善案3つちょうだい🧭」
- 「アクセシビリティ的に aria-invalid / aria-describedby の付け方、落とし穴ある?チェックして✅」(MDN Web Docs)
- 「Zod v4 の z.flattenError の出力を FormProblems 型に変換する関数を書いて(型も丁寧に)🧾」(Zod)
まとめ🎉
この章で一番大事なのはこれだよ👇✨
- エラーは 「項目別」と「全体」 に分ける🧺
- 表示は 近く+上部要約 が強い🗺️(GOV.UK Design System)
- アクセシビリティは aria-invalid+aria-describedby が基本セット♿(MDN Web Docs)
- バリデーション結果は 標準形(FormProblems) に正規化して、UIをシンプルに保つ🧼✨
次の章(第22章)では、HTTP失敗(通信 vs HTTPステータス) を同じノリで「混ぜない」設計にしていくよ🌐🚦💕