はじめに
日本語のWebフォームでは、ユーザーが全角数字(123)で入力したり、ひらがな(かぶしきがいしゃ)でカタカナ欄に入力したりすることが日常的に起こります。
「半角数字で入力してください」とエラーを出すのは簡単ですが、ユーザー体験としては最悪です。
Zod の transform を使えば、バリデーション前に自動変換を挟めます。ユーザーは何も気にせず入力でき、システム側で正規化される。この実装パターンを紹介します。
課題: 日本語入力の表記揺れ
| 入力例 | 期待値 | 問題 |
|---|---|---|
1234 |
1234 |
全角数字 |
0005 |
0005 |
全角ゼロ |
かぶしきがいしゃ |
カブシキガイシャ |
ひらがな→カタカナ |
10,000 |
10000 |
全角数字 + カンマ |
(株) |
(株) |
全角括弧 |
これらを「入力エラー」にするのではなく、自動変換してから検証します。
正規化関数
全角英数字 → 半角変換
// lib/validators/normalize.ts
export function toHalfWidth(str: string): string {
return str
// 全角英数字(A-Z, a-z, 0-9) → 半角(A-Z, a-z, 0-9)
.replace(/[A-Za-z0-9]/g, (s) =>
String.fromCharCode(s.charCodeAt(0) - 0xFEE0)
)
// 全角スペース → 半角スペース
.replace(/ /g, " ");
}
仕組み: Unicode上で全角英数字は半角英数字の 0xFEE0 先にあります。コードポイントを引くだけで変換できます。
"A" (U+FF21) - 0xFEE0 = "A" (U+0041)
"0" (U+FF10) - 0xFEE0 = "0" (U+0030)
ひらがな → カタカナ変換
export function toZenginKana(str: string): string {
// ひらがな(U+3041-U+3096) → カタカナ(U+30A1-U+30F6)
return str.replace(/[\u3041-\u3096]/g, (s) =>
String.fromCharCode(s.charCodeAt(0) + 0x60)
);
}
仕組み: Unicode上でひらがなとカタカナは 0x60 離れています。
"あ" (U+3042) + 0x60 = "ア" (U+30A2)
"か" (U+304B) + 0x60 = "カ" (U+30AB)
Zod スキーマでの活用
.transform() + .pipe() パターン
Zodの transform で前処理し、pipe でバリデーションに渡します:
import { z } from "zod";
import { toHalfWidth, toZenginKana } from "./normalize";
// 数字フィールド: 全角→半角変換してからバリデーション
const digitsOnly = (len: number, label: string) =>
z.string()
.transform(toHalfWidth) // ★ 全角数字を半角に自動変換
.pipe(
z.string()
.length(len, `${label}は${len}桁です`)
.regex(/^\d+$/, `${label}は半角数字のみです`)
);
// カナフィールド: ひらがな→カタカナ変換してからバリデーション
const accountNameKanaSchema = z.string()
.transform(toZenginKana) // ★ ひらがなをカタカナに自動変換
.pipe(
z.string()
.min(1, "口座名義(カナ)は必須です")
.regex(
/^[ァ-ヶー \s()().\-\/0-9A-Z]+$/,
"口座名義はカナ・英数・記号のみです"
)
);
スキーマ全体
export const supplierCreateSchema = z.object({
name: z.string().min(1, "仕入先名は必須です"),
bankCode: digitsOnly(4, "金融機関コード"), // "0005" → "0005" ✓
branchCode: digitsOnly(3, "支店コード"), // "012" → "012" ✓
accountNumber: z.string()
.transform(toHalfWidth)
.pipe(
z.string()
.min(1, "口座番号は必須です")
.max(7, "口座番号は最大7桁です")
.regex(/^\d+$/, "口座番号は半角数字のみです")
),
accountNameKana: accountNameKanaSchema, // "かぶしきがいしゃ" → "カブシキガイシャ" ✓
accountType: z.enum(["普通", "当座"]).default("普通"),
});
使用例
// API Route で使う
export async function POST(request: Request) {
const body = await request.json();
const result = supplierCreateSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
// result.data は正規化済み
// "0005" → "0005" に変換された状態
const supplier = await prisma.supplier.create({
data: result.data,
});
}
Before / After
Before(transform なし)
ユーザー入力: "0005"
バリデーション: "半角数字のみです" ← エラー表示
ユーザー: 「え、半角ってなに...」(離脱)
After(transform あり)
ユーザー入力: "0005"
transform: "0005" に自動変換
バリデーション: ✓ 通過
ユーザー: (何も気づかない、ストレスゼロ)
フロントエンド側のバリデーション
サーバー側だけでなく、フロントエンドでもリアルタイムバリデーションを入れるとUXが向上します:
// React コンポーネント
const [bankCode, setBankCode] = useState("");
const [bankCodeError, setBankCodeError] = useState("");
const handleBankCodeChange = (value: string) => {
setBankCode(value);
const normalized = toHalfWidth(value);
if (normalized && !/^\d{0,4}$/.test(normalized)) {
setBankCodeError("金融機関コードは4桁の数字です");
} else {
setBankCodeError("");
}
};
注意: フロントエンドのバリデーションはUX向上のためで、セキュリティはサーバー側のZodバリデーションで担保します。
テスト
正規化関数は入出力が明確なのでテストしやすい:
describe("toHalfWidth", () => {
test("全角数字を半角に変換", () => {
expect(toHalfWidth("0123")).toBe("0123");
});
test("全角英字を半角に変換", () => {
expect(toHalfWidth("Abc")).toBe("Abc");
});
test("全角スペースを半角に変換", () => {
expect(toHalfWidth("A B")).toBe("A B");
});
test("半角はそのまま", () => {
expect(toHalfWidth("abc123")).toBe("abc123");
});
test("混在入力", () => {
expect(toHalfWidth("1A2b3")).toBe("1A2b3");
});
});
describe("toZenginKana", () => {
test("ひらがなをカタカナに変換", () => {
expect(toZenginKana("かぶしきがいしゃ")).toBe("カブシキガイシャ");
});
test("カタカナはそのまま", () => {
expect(toZenginKana("カタカナ")).toBe("カタカナ");
});
test("混在入力", () => {
expect(toZenginKana("かタかナ")).toBe("カタカナ");
});
});
応用: 他のフィールドにも使える
| フィールド | 変換 |
|---|---|
| 電話番号 |
toHalfWidth + ハイフン除去 |
| 郵便番号 |
toHalfWidth + ハイフン除去 |
| メールアドレス |
toHalfWidth + .toLowerCase()
|
| 住所のカナ | toZenginKana |
| 金額入力 |
toHalfWidth + カンマ除去 |
まとめ
- 日本語フォームでは全角↔半角、ひらがな↔カタカナの表記揺れが頻発
- Zod の
transform→pipeパターンで入力前に自動正規化 - ユーザーにエラーを出さず、システム側で吸収するのがベストプラクティス
- Unicode のコードポイント計算で変換関数は数行で書ける
この正規化パターンを使ったサービス: トルカ 振込アシスト — 請求書からAIで全銀フォーマットの振込データを自動生成。銀行コードや口座名義の入力も全角→半角を自動変換。