【2025年版】JavaScript 全角半角 自動変換 | “半角で入力してください”を撲滅する実装ガイド

技術

はじめに

「電話番号は半角で入力してください」 「カタカナは全角で」 「メールアドレスに全角が含まれています」

日本のWebサービスを開発していると、必ず直面する全角・半角問題。ユーザビリティとデータ整合性の間で、どうバランスを取るべきか。実装例とともに、2025年時点でのベストプラクティスをまとめました。

なぜ全角半角問題は起きるのか

日本のWeb開発において、全角半角の混在は避けて通れない問題です。この問題が起きる主な原因を整理してみましょう。

まず、IMEの自動変換による意図しない入力があります。日本語入力モードのまま英数字を入力すると、デフォルトで全角文字が入力されることが多く、ユーザーが意識していなくても全角文字が混入してしまいます。

スマートフォンでの入力も問題を複雑にしています。特にiOSやAndroidの日本語キーボードでは、入力モードの切り替えが煩雑で、ユーザーが面倒に感じて全角のまま入力することがよくあります。

世代による入力習慣の違いも無視できません。特に年配の方には「英数字は全角で入力するもの」という習慣が根強く残っており、半角入力を求めてもなかなか改善されないケースがあります。

さらに、バックエンドシステムの制約も考慮する必要があります。銀行系や基幹システムなど、レガシーシステムと連携する場合、文字コードやバイト数の制約から半角英数字しか受け付けないケースが多々あります。

基本方針:寛容な入力、厳格な保存

全角半角問題に対する基本的なアプローチは「ユーザーには寛容に、システムには厳格に」です。

javascript

// ❌ ユーザーに負担を強いるパターン
if (hasFullWidth(input)) {
  return "半角で入力してください";
}

// ✅ 自動変換して受け入れるパターン
const normalized = toHalfWidth(input);
saveToDatabase(normalized);

ユーザーにエラーメッセージを表示して再入力を求めるのではなく、システム側で自動的に変換処理を行うことで、ユーザビリティを大幅に向上させることができます。

フィールド別の実装パターン

それでは、具体的なフィールドごとの実装パターンを見ていきましょう。

電話番号の正規化

電話番号は全角数字、全角ハイフン、長音記号など、様々なパターンで入力される可能性があります。

const normalizePhoneNumber = (input) => {
  // 全角数字を半角に変換
  let normalized = input.replace(/[0-9]/g, s => 
    String.fromCharCode(s.charCodeAt(0) - 0xFEE0)
  );
  
  // 各種ハイフン記号を統一
  normalized = normalized.replace(/[ー−―-—‐]/g, '-');
  
  // 数字とハイフン以外を除去
  normalized = normalized.replace(/[^\d-]/g, '');
  
  // ハイフンなしの場合、適切な位置に挿入
  if (!normalized.includes('-') && normalized.length === 10) {
    // 市外局番が2桁の場合(東京03など)
    if (normalized.startsWith('03') || normalized.startsWith('06')) {
      return normalized.replace(/^(\d{2})(\d{4})(\d{4})$/, '$1-$2-$3');
    }
    // 携帯電話番号
    return normalized.replace(/^(\d{3})(\d{3})(\d{4})$/, '$1-$2-$3');
  } else if (!normalized.includes('-') && normalized.length === 11) {
    // 携帯電話番号(090/080/070)
    return normalized.replace(/^(\d{3})(\d{4})(\d{4})$/, '$1-$2-$3');
  }
  
  return normalized;
};

// 使用例
console.log(normalizePhoneNumber('090−1234−5678')); // 090-1234-5678
console.log(normalizePhoneNumber('09012345678')); // 090-1234-5678
console.log(normalizePhoneNumber('03ー1234ー5678')); // 03-1234-5678

郵便番号の正規化

郵便番号も全角入力や、ハイフンの有無など様々なパターンに対応する必要があります。

const normalizeZipCode = (input) => {
  // 全角を半角に変換
  let normalized = input.replace(/[0-9]/g, s => 
    String.fromCharCode(s.charCodeAt(0) - 0xFEE0)
  );
  
  // ハイフン類を統一
  normalized = normalized.replace(/[ー−―-—‐〒]/g, '-');
  
  // 数字とハイフン以外を除去
  normalized = normalized.replace(/[^\d-]/g, '');
  
  // ハイフンなしの7桁の場合、3-4で区切る
  if (normalized.length === 7 && !normalized.includes('-')) {
    normalized = normalized.replace(/^(\d{3})(\d{4})$/, '$1-$2');
  }
  
  // 前後の空白を削除
  return normalized.trim();
};

// バリデーション関数
const isValidZipCode = (zipCode) => {
  return /^\d{3}-?\d{4}$/.test(zipCode);
};

メールアドレスの正規化

メールアドレスは全角の英数字や記号が混入しやすいフィールドです。

const normalizeEmail = (input) => {
  let normalized = input;
  
  // 全角英数字を半角に変換
  normalized = normalized.replace(/[A-Za-z0-9]/g, s => 
    String.fromCharCode(s.charCodeAt(0) - 0xFEE0)
  );
  
  // 全角記号を半角に変換
  normalized = normalized
    .replace(/@/g, '@')
    .replace(/./g, '.')
    .replace(/_/g, '_')
    .replace(/-/g, '-')
    .replace(/+/g, '+');
  
  // 小文字に統一
  normalized = normalized.toLowerCase();
  
  // 前後の空白を削除
  return normalized.trim();
};

// より詳細なバリデーション
const isValidEmail = (email) => {
  // RFC 5322に準拠した正規表現(簡略版)
  const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
  return emailRegex.test(email);
};

カタカナ(フリガナ)の正規化

名前のフリガナフィールドでは、ひらがな、全角カタカナ、半角カタカナが混在することがあります。

const normalizeKatakana = (input) => {
  let normalized = input;
  
  // ひらがなをカタカナに変換
  normalized = normalized.replace(/[\u3041-\u3096]/g, s => 
    String.fromCharCode(s.charCodeAt(0) + 0x60)
  );
  
  // 半角カタカナを全角カタカナに変換
  const halfToFullKatakana = {
    'ア': 'ア', 'イ': 'イ', 'ウ': 'ウ', 'エ': 'エ', 'オ': 'オ',
    'カ': 'カ', 'キ': 'キ', 'ク': 'ク', 'ケ': 'ケ', 'コ': 'コ',
    'サ': 'サ', 'シ': 'シ', 'ス': 'ス', 'セ': 'セ', 'ソ': 'ソ',
    'タ': 'タ', 'チ': 'チ', 'ツ': 'ツ', 'テ': 'テ', 'ト': 'ト',
    'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', 'ノ': 'ノ',
    'ハ': 'ハ', 'ヒ': 'ヒ', 'フ': 'フ', 'ヘ': 'ヘ', 'ホ': 'ホ',
    'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ',
    'ヤ': 'ヤ', 'ユ': 'ユ', 'ヨ': 'ヨ',
    'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ',
    'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン',
    'ァ': 'ァ', 'ィ': 'ィ', 'ゥ': 'ゥ', 'ェ': 'ェ', 'ォ': 'ォ',
    'ャ': 'ャ', 'ュ': 'ュ', 'ョ': 'ョ', 'ッ': 'ッ',
    '゙': '゛', '゚': '゜', 'ー': 'ー', '・': '・'
  };
  
  // 濁音・半濁音の処理
  normalized = normalized.replace(/ガ/g, 'ガ').replace(/ギ/g, 'ギ')
    .replace(/グ/g, 'グ').replace(/ゲ/g, 'ゲ').replace(/ゴ/g, 'ゴ')
    .replace(/ザ/g, 'ザ').replace(/ジ/g, 'ジ').replace(/ズ/g, 'ズ')
    .replace(/ゼ/g, 'ゼ').replace(/ゾ/g, 'ゾ')
    .replace(/ダ/g, 'ダ').replace(/ヂ/g, 'ヂ').replace(/ヅ/g, 'ヅ')
    .replace(/デ/g, 'デ').replace(/ド/g, 'ド')
    .replace(/バ/g, 'バ').replace(/ビ/g, 'ビ').replace(/ブ/g, 'ブ')
    .replace(/ベ/g, 'ベ').replace(/ボ/g, 'ボ')
    .replace(/パ/g, 'パ').replace(/ピ/g, 'ピ').replace(/プ/g, 'プ')
    .replace(/ペ/g, 'ペ').replace(/ポ/g, 'ポ');
  
  // 残りの半角カタカナを変換
  Object.keys(halfToFullKatakana).forEach(half => {
    normalized = normalized.replace(new RegExp(half, 'g'), halfToFullKatakana[half]);
  });
  
  // スペースを全角に統一(任意)
  normalized = normalized.replace(/\s/g, ' ');
  
  return normalized.trim();
};

リアルタイムバリデーション vs 送信時変換

実装方法には大きく2つのアプローチがあります。

リアルタイム変換アプローチ

入力中にリアルタイムで変換を行う方法です。ユーザーは正規化された値を常に確認できます。

import React, { useState } from 'react';

const PhoneInput = () => {
  const [value, setValue] = useState('');
  const [isComposing, setIsComposing] = useState(false);
  
  const handleChange = (e) => {
    // IME入力中は変換しない
    if (isComposing) return;
    
    const normalized = normalizePhoneNumber(e.target.value);
    setValue(normalized);
  };
  
  return (
    <input
      type="tel"
      value={value}
      onChange={handleChange}
      onCompositionStart={() => setIsComposing(true)}
      onCompositionEnd={(e) => {
        setIsComposing(false);
        handleChange(e);
      }}
      placeholder="090-1234-5678"
    />
  );
};

送信時変換アプローチ

フォーム送信時にまとめて変換を行う方法です。実装がシンプルで、入力中のちらつきがありません。

const ContactForm = () => {
  const [formData, setFormData] = useState({
    phone: '',
    email: '',
    zipCode: '',
    nameKana: ''
  });
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    // 送信時に一括正規化
    const normalizedData = {
      phone: normalizePhoneNumber(formData.phone),
      email: normalizeEmail(formData.email),
      zipCode: normalizeZipCode(formData.zipCode),
      nameKana: normalizeKatakana(formData.nameKana)
    };
    
    // バリデーション
    const errors = validateForm(normalizedData);
    if (errors.length > 0) {
      showErrors(errors);
      return;
    }
    
    // API送信
    submitToAPI(normalizedData);
  };
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        name="phone"
        value={formData.phone}
        onChange={handleChange}
        placeholder="電話番号"
      />
      {/* 他のフィールド */}
      <button type="submit">送信</button>
    </form>
  );
};

ライブラリの活用

車輪の再発明を避けるため、既存のライブラリを活用することも重要です。

moji – 日本語文字変換ライブラリ

npm install moji
import moji from 'moji';

// 様々な変換パターン
const input = 'アイウエオ ABC 123';

// 半角カナ→全角カナ
const fullKana = moji(input).convert('HK', 'ZK').toString();
// アイウエオ ABC 123

// 全角英数→半角英数
const halfAlphaNum = moji(input).convert('ZE', 'HE').toString();
// アイウエオ ABC 123

// チェーンして複数変換
const normalized = moji(input)
  .convert('HK', 'ZK')  // 半角カナ→全角カナ
  .convert('ZE', 'HE')  // 全角英数→半角英数
  .toString();
// アイウエオ ABC 123

japanese-addresses – 住所正規化

npm install japanese-addresses
import { normalize } from 'japanese-addresses';

const address = '東京都千代田区丸の内1−9−1';
const normalized = await normalize(address);
// 東京都千代田区丸の内1-9-1

バックエンド側の実装

フロントエンドだけでなく、バックエンド側でも正規化処理を行うことが重要です。

// Node.js/Express の例
const express = require('express');
const app = express();

// 正規化ミドルウェア
const normalizeMiddleware = (req, res, next) => {
  if (req.body.phone) {
    req.body.phone = normalizePhoneNumber(req.body.phone);
  }
  if (req.body.email) {
    req.body.email = normalizeEmail(req.body.email);
  }
  if (req.body.zipCode) {
    req.body.zipCode = normalizeZipCode(req.body.zipCode);
  }
  if (req.body.nameKana) {
    req.body.nameKana = normalizeKatakana(req.body.nameKana);
  }
  next();
};

app.use(express.json());
app.use(normalizeMiddleware);

app.post('/api/users', async (req, res) => {
  try {
    // この時点で既に正規化済み
    const { phone, email, zipCode, nameKana } = req.body;
    
    // バリデーション
    if (!isValidPhone(phone)) {
      return res.status(400).json({ 
        error: '電話番号の形式が正しくありません' 
      });
    }
    
    if (!isValidEmail(email)) {
      return res.status(400).json({ 
        error: 'メールアドレスの形式が正しくありません' 
      });
    }
    
    // DB保存
    const user = await User.create({
      phone,
      email,
      zipCode,
      nameKana
    });
    
    res.json({ success: true, user });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

検索機能での考慮

データベースに保存されたデータを検索する際も、検索クエリを正規化する必要があります。

// 検索エンドポイント
app.get('/api/users/search', async (req, res) => {
  const { phone, email } = req.query;
  
  const conditions = {};
  
  if (phone) {
    // 検索クエリも正規化
    conditions.phone = normalizePhoneNumber(phone);
  }
  
  if (email) {
    conditions.email = normalizeEmail(email);
  }
  
  const users = await User.findAll({ where: conditions });
  res.json(users);
});

テストケースの実装

正規化処理が正しく動作することを保証するため、網羅的なテストケースを用意します。

// Jest を使用したテスト例
describe('全角半角正規化テスト', () => {
  describe('電話番号の正規化', () => {
    test('全角数字とハイフンを正規化', () => {
      expect(normalizePhoneNumber('090−1234−5678'))
        .toBe('090-1234-5678');
    });
    
    test('ハイフンなしの番号にハイフンを追加', () => {
      expect(normalizePhoneNumber('09012345678'))
        .toBe('090-1234-5678');
    });
    
    test('様々なハイフン記号を統一', () => {
      expect(normalizePhoneNumber('090ー1234ー5678')).toBe('090-1234-5678');
      expect(normalizePhoneNumber('090−1234−5678')).toBe('090-1234-5678');
      expect(normalizePhoneNumber('090—1234—5678')).toBe('090-1234-5678');
    });
    
    test('市外局番2桁のケース', () => {
      expect(normalizePhoneNumber('0312345678'))
        .toBe('03-1234-5678');
    });
  });
  
  describe('メールアドレスの正規化', () => {
    test('全角英数字を半角に変換', () => {
      expect(normalizeEmail('test@example.com'))
        .toBe('test@example.com');
    });
    
    test('大文字を小文字に変換', () => {
      expect(normalizeEmail('Test@Example.COM'))
        .toBe('test@example.com');
    });
    
    test('前後の空白を削除', () => {
      expect(normalizeEmail('  test@example.com  '))
        .toBe('test@example.com');
    });
  });
  
  describe('カタカナの正規化', () => {
    test('ひらがなをカタカナに変換', () => {
      expect(normalizeKatakana('やまだ たろう'))
        .toBe('ヤマダ タロウ');
    });
    
    test('半角カタカナを全角に変換', () => {
      expect(normalizeKatakana('ヤマダ タロウ'))
        .toBe('ヤマダ タロウ');
    });
    
    test('濁音・半濁音の処理', () => {
      expect(normalizeKatakana('パソコン')).toBe('パソコン');
      expect(normalizeKatakana('データ')).toBe('データ');
    });
  });
});

パフォーマンスの考慮

大量のデータを処理する場合、正規化処理のパフォーマンスも重要です。

// メモ化を使用したパフォーマンス改善
const memoize = (fn) => {
  const cache = new Map();
  return (input) => {
    if (cache.has(input)) {
      return cache.get(input);
    }
    const result = fn(input);
    cache.set(input, result);
    return result;
  };
};

const memoizedNormalizePhone = memoize(normalizePhoneNumber);
const memoizedNormalizeEmail = memoize(normalizeEmail);

// バッチ処理の例
const processBatch = async (users) => {
  const normalized = users.map(user => ({
    ...user,
    phone: memoizedNormalizePhone(user.phone),
    email: memoizedNormalizeEmail(user.email)
  }));
  
  return normalized;
};

まとめ

全角半角が混在するフォームバリデーションにおいて重要なポイントは以下の通りです。

まず、ユーザーには寛容に、システムには厳格にという基本方針を忘れないことです。ユーザーに再入力を求めるのではなく、可能な限り自動変換で対応しましょう。

フィールドごとに適切な正規化ルールを設定し、一貫性を保つことも大切です。電話番号、郵便番号、メールアドレス、カタカナそれぞれに最適な処理を実装してください。

フロントエンドとバックエンドの両方で正規化処理を行うことで、より堅牢なシステムを構築できます。フロントエンドはUXのため、バックエンドはデータ整合性のためと、それぞれの役割を明確にしましょう。

そして、網羅的なテストケースを用意して、エッジケースにも対応できるようにすることが重要です。

これらのベストプラクティスを実装することで、日本のWebサービスにおける全角半角問題を効果的に解決し、ユーザーにストレスのない入力体験を提供することができます。

コメント

タイトルとURLをコピーしました