2025 業務アプリ向け WinForms 入力チェックはデータモデルに任せよう

# 入力チェックはデータモデルに任せよう！  
データアノテーションに連動させて、テキストボックスの入力チェックコードをもう一度書くことをやめる話

### 注意：本記事での用語
| 本記事での表現            | 他の呼び方                                   |
| ------------- | ------------------------------------- |
| データモデル        | 業務モデル／モデルクラス                          |
| データアノテーション | モデルに定義されたバリデーション属性（DataAnnotations属性） |


```csharp
Model.cs

public class 顧客Model
{
    // --- 以下の2行がデータアノテーション -----------------------------
    [Required(ErrorMessage = "氏名漢字は必須です")]    
    [MaxLength(10)]
    // --------------------------------------------------------------
    public string 氏名漢字 { get; set; } = string.Empty;
```

---

## １．はじめに

WinForms で業務アプリを開発していると毎回やってくる「入力チェック」処理。  
せっかくデータモデルに記述したデータアノテーションによる Required（必須）、MaxLength（最大長）、書式チェックなど。

::: note alert
チェック処理をModelとForm側の2カ所に書くのはもうやめませんか？
:::


モデルの要件に連動させたカスタムコントロールでバリデーションを実現しました。

---

## ２．やりたいこと

以下のような要件を、**カスタムTextBoxコントロール側に実装**します：

### ✅ データチェック要件

#### 1) モデルのデータアノテーションを読み取って自動バリデーション
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/f6d3138e-3ea5-48c0-a8b4-ddf0e390f0c6.png)

```csharp
Model.cs

[Range(0, 999999999999, ErrorMessage = "0〜999999999999 の範囲で入力してください。")]
public decimal 年収 { get; set; }
```

```csharp
Form.cs

NVTextBox年収.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.年収));
```

#### 2) 追加で個別のチェック関数（Validator）も設定できる
:::note
データアノテーションには書き切れないチェック処理は、個別に追加可能
:::

```csharp
Form.cs

VTextBox氏名カナ.InputValidator = s =>
{
    foreach (char c in s)
    {
        if (!((c >= '\u30A0' && c <= '\u30FF') || c == '\u3000'))
            return (false, "全角カタカナまたは全角スペースのみを入力してください。");
    }
    return (true, string.Empty);
};
```

### ✅ 業務アプリで TextBox へ良く求められる要件も併せて実装

#### 1) Enterで次の入力欄へ移動（ビープ音なし）

#### 2) フォーカスを受けたことを強調（外枠が青く太くなる）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/a97058e5-1993-4565-a381-8dc368f54baf.png)

#### 3) フォーカスを外すと外枠がグレーに戻る
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/9e6ad455-b3ae-4f0a-a606-86f135b61206.png)

#### 4) エラーなら外枠が赤くなる（エラープロバイダーの❌マークも自動表示）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/e0c4bf5e-3930-447b-aac2-12fa575db710.png)

#### 5) エラープロバイダー❌をマウスオーバーでエラーメッセージを表示
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/691ece0b-f642-4d58-9d3b-a678058caa05.png)


## ３．前提条件
* Visual studio 2022 Version 17.14.1
* .Net 9

:::note 
なお、すべてのソースコードを公開しています。
:::
https://github.com/masayahak/CustomTextbox

## ４．実装ポイント

* バリデーションは「モデルと連携」＋「個別Validator」

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/678fc87a-0296-431b-9a4a-979379c064ce.png)

```csharp
Model.cs

public class 顧客Model
{
    [Required(ErrorMessage = "氏名漢字は必須です")]
    [MaxLength(10)]
    public string 氏名漢字 { get; set; } = string.Empty;

    [Required(ErrorMessage = "氏名カナは必須です")]
    [MaxLength(10)]
    public string 氏名カナ { get; set; } = string.Empty;

    [Required(ErrorMessage = "メールアドレスは必須です")]
    [EmailAddress(ErrorMessage = "メールアドレスの形式が正しくありません")]
    [MaxLength(100)]
    public string メールアドレス { get; set; } = string.Empty;

    [Required(ErrorMessage = "生年月日は必須です")]
    [Range(typeof(DateTime), "1900-01-01", "2025-12-31", ErrorMessage = "生年月日は1900年～2025年の間で指定してください")]
    public DateTime 生年月日 { get; set; }

    [Range(0, 999999999999, ErrorMessage = "0〜999999999999 の範囲で入力してください。")]
    public decimal 年収 { get; set; }

}
```

:::note
Form側のコードを見ると、ほとんどモデルのアノテーションだけでチェック処理が終わっていることがわかります
:::

```csharp
Form.cs

private void SetValidators()
{
    // 氏名（漢字）
    VTextBox氏名漢字.ErrorProvider = this.ErrorProvider;
    VTextBox氏名漢字.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.氏名漢字));

    // 氏名（カナ）
    VTextBox氏名カナ.ErrorProvider = this.ErrorProvider;
    VTextBox氏名カナ.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.氏名カナ));

    VTextBox氏名カナ.InputValidator = s =>
    {
        foreach (char c in s)
        {
            if (!((c >= '\u30A0' && c <= '\u30FF') || c == '\u3000'))
                return (false, "全角カタカナまたは全角スペースのみを入力してください。");
        }
        return (true, string.Empty);
    };

    // メールアドレス
    VTextBoxメールアドレス.ErrorProvider = this.ErrorProvider;
    VTextBoxメールアドレス.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.メールアドレス));

    // 生年月日
    VDateTimePicker生年月日.ErrorProvider = this.ErrorProvider;
    VDateTimePicker生年月日.Format = DateTimePickerFormat.Custom;
    VDateTimePicker生年月日.CustomFormat = "yyyy/MM/dd";
    VDateTimePicker生年月日.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.生年月日));

    // 年収
    NVTextBox年収.ErrorProvider = this.ErrorProvider;
    NVTextBox年収.BindValidationToModelProperty(typeof(顧客Model), nameof(顧客Model.年収));
    NVTextBox年収.NumberFormat = "#,##0";
}
```

::: note alert
氏名カナの「全角カナチェック」のみ追加実装してます
:::


### 1) 必須入力チェック

データアノテーションの Required が自動で反映されます。
```csharp
[Required(ErrorMessage = "氏名漢字は必須です")]
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/46a418e1-7809-4f39-bc67-042e72496ac6.png)

### 2) 文字列長

データアノテーションの MaxLength が自動で反映されます。
```csharp
[MaxLength(10)]
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/19e86e00-e8e9-454a-bb22-015435b1dbcf.png)

:::note warn
MaxLength を自動的に反映し、指定の文字列長までしか入力できません
:::

### 3) 値の範囲
```csharp
[Range(typeof(DateTime), "1900-01-01", "2025-12-31", ErrorMessage = "生年月日は1900年～2025年の間で指定してください")]
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/a3520af6-b065-4385-b262-30cad3910dd6.png)


## ５．TextBox以外のコントロール
:::note
ついでに、数値用の `NumericValidatingTextBox` や  
日付用の `ValidatingDateTimePicker` も作りました。
:::

### 1) 数値入力に対応：NumericValidatingTextBox
* 最小値／最大値
* 負の数許可／ゼロ許可
* 小数点以下の桁数制限
* 書式指定（例："#,##0"）

### 2) 日付入力に対応：ValidatingDateTimePicker
* DateTimePicker を拡張し、RequiredやRangeをモデルから読み取る
* 枠線制御や Enter移動も統一
※ 全選択は不安定なのでオミット（理由もコード内に記載）

## ６．最後に
業務アプリの現場で、
２回同じことを書くことで発生する 非同期、メンテナンス漏れを 防ぎたくて
このようなコントロールにしてみました。
「各画面で毎回同じようなバリデーションコードを書く」のをやめて
モデルへ任せる設計にすると、コードも綺麗で再利用性も高まりますよね✨


## ７．参考

GitHubへソースは全てアップしてますが参考までに。

```csharp
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Reflection;

namespace CustomTextbox.Contols
{
    /// <summary>
    /// 基本仕様：
    /// １．フォーカスを受けたときボーダーラインを強調し青い太枠へ変える
    /// ２．フォーカスを失った時にボーダーラインを元に戻す
    /// ３．フォーカスを受けたとき、文字が入力されていたら全選択する
    /// ４．エンターキーで次のタブインデックスへ遷移する
    /// ５．Modelのプロパティのデータアノテーションから、チェック内容を受け取れる。
    /// ６．そのテキストボックスの入力値のチェックメソッドを受け取れる。また、文字列の最大長をプロパティに持つ。
    /// ７．チェックメソッドや文字列長に違反した場合、フォーカスを失った時にボーダーラインを太い赤色にする
    /// </summary>
    public class ValidatingTextBox : TextBox
    {
        [Category("データ")]
        [Description("必須項目はTrueにしてください")]
        [DefaultValue(false)]
        public bool Required { get; set; } = false;
        
        [Category("外観")]
        [Description("外枠の通常色です")]
        [DefaultValue(typeof(Color), "Gray")]
        public Color 通常色 { get; set; } = Color.Gray;

        [Category("外観")]
        [Description("フォーカスを受けた時の外枠の色です")]
        [DefaultValue(typeof(Color), "DeepSkyBlue")]
        public Color フォーカス色 { get; set; } = Color.DeepSkyBlue;

        [Category("外観")]
        [Description("エラー時の外枠の色です")]
        [DefaultValue(typeof(Color), "Red")]
        public Color エラー色 { get; set; } = Color.Red;

        [Browsable(false)]
        [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
        public ErrorProvider? ErrorProvider { get; set; }

        //バインドするModleのプロパティから自動生成するValidator
        private Func<string, (bool isValid, string errorMessage)>? _modelValidator;
        // 個別に追加指定するValidator
        private Func<string, (bool isValid, string errorMessage)>? _customValidator;
        [Browsable(false)]
        [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
        public Func<string, (bool isValid, string errorMessage)>? InputValidator
        {
            get => _combinedValidator;
            set
            {
                _customValidator = value;
                UpdateCombinedValidator();
            }
        }

        // モデルからのValidatorと、個別追加Validator ２つを合成した最終的なValidator
        private Func<string, (bool isValid, string errorMessage)>? _combinedValidator;
        private void UpdateCombinedValidator()
        {
            _combinedValidator = CombineValidators();
        }


        [Browsable(false)]
        [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
        public bool IsValid => _isValid;

        internal bool _hasFocus = false;
        private bool _isValid = true;
        private RequiredAttribute? _requiredAttr;


        public ValidatingTextBox()
        {
            BorderStyle = BorderStyle.FixedSingle;
        }

        protected override void OnEnter(EventArgs e)
        {
            _hasFocus = true;
            Invalidate();

            if (!string.IsNullOrEmpty(Text))
            {
                // ３．フォーカスを受けたとき、文字が入力されていたら全選択する
                SelectAll(); // 全選択
            }

            base.OnEnter(e);
        }

        protected override void OnLeave(EventArgs e)
        {
            _hasFocus = false;
            _isValid = ValidateInput();
            Invalidate();
            base.OnLeave(e);
        }

        protected override void OnKeyDown(KeyEventArgs e)
        {
            if (e.KeyCode == Keys.Enter)
            {
                // ４．エンターキーで次のタブインデックスへ遷移する
                Parent?.SelectNextControl(this, true, true, true, true);
                e.Handled = true;
                e.SuppressKeyPress = true; // ← ★これでビープ音防止！
            }

            base.OnKeyDown(e);
        }

        // フォーカスあり／なし／エラー状態で外枠の色を変える
        protected override void WndProc(ref Message m)
        {
            base.WndProc(ref m);

            const int WM_PAINT = 0x000F;
            if (m.Msg == WM_PAINT)
            {
                using Graphics g = CreateGraphics();
                // １．フォーカスを受けたときボーダーラインを強調し青い太枠へ変える
                Color borderColor = 通常色;

                // ２．フォーカスを失った時にボーダーラインを元に戻す
                if (_hasFocus)
                    borderColor = フォーカス色;
                // ７．チェックメソッドや文字列長に違反した場合、フォーカスを失った時にボーダーラインを太い赤色にする
                else if (!_isValid)
                    borderColor = エラー色;

                using Pen pen = new Pen(borderColor, borderColor != 通常色 ? 2 : 1);
                Rectangle rect = new Rectangle(1, 1, this.Width - 3, this.Height - 3);
                g.DrawRectangle(pen, rect);
            }
        }

        // ５．Modelのプロパティのデータアノテーションから、チェック内容を受け取れる。
        public void BindValidationToModelProperty(Type modelType, string propertyName)
        {
            var prop = modelType.GetProperty(propertyName);
            if (prop == null) return;

            // Requiredの有無
            _requiredAttr = prop.GetCustomAttribute<RequiredAttribute>();
            this.Required = _requiredAttr != null;


            // 最大長
            var maxLengthAttr = prop.GetCustomAttribute<MaxLengthAttribute>();
            if (maxLengthAttr != null)
            {
                MaxLength = maxLengthAttr.Length;
            }

            // その他のValidation
            _modelValidator = s =>
            {
                // 空文字は Validator に流さず、Required で判定
                if (string.IsNullOrWhiteSpace(s)) return (true, string.Empty);

                // 型変換チェック
                var prop = modelType.GetProperty(propertyName);
                if (prop == null) return (true, string.Empty);

                var targetType = prop.PropertyType;

                // TryParse 相当の安全な型変換
                object? typedValue;
                try
                {
                    typedValue = TypeDescriptor.GetConverter(targetType).ConvertFromString(s);
                }
                catch (Exception ex)
                {
                    return (false, $"形式が正しくありません: {ex.Message}");
                }


                var dummyInstance = Activator.CreateInstance(modelType) ?? 
                    throw new InvalidOperationException("モデルのインスタンス生成に失敗しました");
                var validationContext = new ValidationContext(dummyInstance) 
                { 
                    MemberName = propertyName 
                };

                var results = new List<ValidationResult>();
                bool isValid = System.ComponentModel.DataAnnotations.Validator.TryValidateProperty(typedValue, validationContext, results);

                if (isValid)
                    return (true, string.Empty);
                else
                    return (false, results[0].ErrorMessage ?? "無効な入力です");
            };
        }

        // モデルからの属性チェックと個別チェックを組み合わせる
        // （両方のチェックを有効にする）
        private Func<string, (bool isValid, string errorMessage)>? CombineValidators()
        {
            if (_customValidator == null && _modelValidator == null)
                return null;

            return s =>
            {
                if (_modelValidator != null)
                {
                    var result = _modelValidator(s);
                    if (!result.isValid) return result;
                }

                if (_customValidator != null)
                {
                    var result = _customValidator(s);
                    if (!result.isValid) return result;
                }

                return (true, string.Empty);
            };
        }

        // Validation実行
        private bool ValidateInput()
        {
            // ６．そのテキストボックスの入力値のチェックメソッドを受け取れる。また、文字列の最大長をプロパティに持つ。
            var errorMessage = string.Empty;

            // 検証ロジック
            if (Required && string.IsNullOrWhiteSpace(Text))
            {
                errorMessage = _requiredAttr?.ErrorMessage ?? "必須入力です。";
                ErrorProvider?.SetError(this, errorMessage);
                return false;
            }

            if (InputValidator != null)
            {
                var result = InputValidator(Text);
                if (!result.isValid)
                {
                    errorMessage = result.errorMessage;
                    ErrorProvider?.SetError(this, errorMessage);
                    return false;
                }
            }

            ErrorProvider?.SetError(this, string.Empty);
            return true;
        }
    }
}
```