npmライブラリ・class-validatorでバリデーションをする

# はじめに
この記事は、入力値バリデーション用のライブラリ・class-validatorを使うためのポイントについて記したものです。
ライブラリによって提供されているバリデーターの種類・詳細な仕様については、公式ドキュメントを参照してください。

# 環境情報
* class-validator: `0.12.2`

# バリデーションをする
## サンプルクラス

ありがちな例ですが、
この`Employee`(社員情報)クラスにバリデーションの設定を行っていきます。

```ts
/** 部署 */
export enum Department {
  /** 総務 */
  GENERAL = 1,
  /** 営業 */
  SALES = 2,
  /** 技術 */
  ENGINEERING = 3,
}

/** 在職期間 */
export class Tenure {
  /** 入社日 */
  from: string;
  /** 退職日 */
  to?: string;

  constructor(init?: Partial<Tenure>) {
    Object.assign(this, init);
    this.from = init?.from ?? '';
  }
}

/** メモ */
export class Note {
  /** 日付 */
  date: string;
  /** 文章 */
  text?: string;

  constructor(init?: Partial<Note>) {
    Object.assign(this, init);
    this.date = init?.date ?? '';
  }
}

/** 社員情報 */
export class Employee {
  /** 社員番号 */
  id: string;
  /** 名前 */
  name: string;
  /** 所属部署 */
  department: Department;
  /** メールアドレス */
  email?: string;
  /** 在職期間 */
  tenure?: Tenure;
  /** メモ */
  notes?: Note[];

  constructor(init: Partial<Employee>) {
    Object.assign(this, init);
    this.id = init.id ?? '';
    this.name = init.name ?? '';
    this.department = init.department ?? 0;
  }
}
```

## バリデーションルールの設定
class-validatorでは、クラスのメンバーに対してデコレータを記述することで、バリデーションルールを設定していきます。
1メンバーに対して、ルールはいくつでも設定することが出来ます。記載されたルールはエラーの有無によらず全てチェック対象となるため、1つ目のルールでエラーになると、2つ目のチェックが行われなくなる、といったことはありません。

### シンプルな例
デコレータを記載するだけでルールが適用されます。以下の例では、社員番号`id`が数値文字列のみで構成されているかどうかをチェックします。

```ts
  @IsNumberString()
  id: string;
```

### エラーメッセージを指定する
先ほどの`id`に、長さのチェックを追加しました。
`Length`デコレータの最後のパラメータは、オプションです。オプションでは、任意のエラーメッセージを指定することができます。メッセージ中の`$constraint1`と`$constraint2`は、条件の値に置き換えられて出力されます。
以下の例では、5〜6桁以外の文字列を入力すると、`5〜6桁の数値で入力してください`というエラーメッセージが返却されます。

```ts
  @Length(5, 6, { message: '$constraint1〜$constraint2桁の数値で入力してください' })
  @IsNumberString()
  id: string;
```

### 未設定の場合はチェックをしない
未設定項目をチェック対象外とする場合は、`@IsOptional`を付けます。
`@IsOptional`では空文字は未設定として扱われないため、空文字もチェック対象外としたい場合は、`@ValidateIf`で条件を指定します。

```ts
  @IsOptional()
  // 空文字をエラーとしない場合は、@IsOptionalの代わりに@ValidateIfを使う
  // @ValidateIf((o, v) => v != null && v.length)
  @IsEmail()
  email?: string;
```

### オブジェクトに対してチェックする
メンバーがオブジェクトの場合は、`@ValidateNested`を付けることで、オブジェクト内のメンバーに対してもチェックが行われるようになります。(以下の例の場合は、`Tenure`クラスにもバリデーションルールを設定しておく必要があります)

```ts
  @ValidateNested()
  tenure?: Tenure;
```

### オブジェクトの配列に対してチェックする
メンバーがオブジェクトの配列である場合も、`@ValidateNested`でチェックが可能です。ただし、配列内の各要素に対してチェックを行うためには、オプション`each`を`true`に設定する必要があります。
`@IsArray`は配列であるかどうかのチェック、`@ArrayNotEmpty`は配列が空以外のときのみチェックを行う設定で、オブジェクトの配列をチェックする際は併用することが多いデコレータです。

```ts
  @ArrayNotEmpty()
  @IsArray()
  @ValidateNested({ each: true })
  notes?: Note[];
```

## カスタムバリデーターをつくる
日本語に関するチェックなど、あらかじめ用意されているチェックだけではカバーできない場合は、カスタムバリデーターを用意して対応します。

### 1項目に対するチェック
`PropertyDecorator`を返却する関数を作成します。以下は、対象が全角カナのみで構成されているかどうかをチェックする例です。
(執筆時点では)公式には記載されていませんが、class-validatorが提供する`ValidateBy`関数を呼び出すことで、少しだけ記載を簡略化することができます。

```ts
export function isZenkakuKana(validationOptions?: ValidationOptions): PropertyDecorator {
  return ValidateBy(
    {
      name: 'isZenkakuKana',
      validator: {
        validate(value): boolean {
          return RegExp('^[ァ-ヶー]+$').test(value);
        },
      },
    },
    validationOptions,
  );
}
```

### 複数項目の相関チェック
カスタムバリデーターを使えば、他の項目と比較する相関チェックも可能になります。
以下は、デコレータを付けた日付項目が、`property`で指定した日付項目よりも前の(もしくは同じ)日付であることをチェックするバリデーターの例です。
`isISO8601`は、validatorが提供するISO-8601日付形式であることをチェックするための関数です。先頭が大文字である`IsISO8601`は、デコレータとして使用するものであり、どちらもclass-validatorからインポートすることができます。

```ts
export function isLessEqualDate(property: string, validationOptions?: ValidationOptions): PropertyDecorator {
  // メッセージ未指定の場合は、デフォルトメッセージを設定する
  const message = validationOptions?.message ?? `${property}以前の日付を指定してください`;
  return ValidateBy(
    {
      name: 'isLessEqualDate',
      constraints: [property],
      validator: {
        validate(value, args: ValidationArguments): boolean {
          // 比較するvalueを取得(constraintsには、比較対象の項目名が入っている)
          const [relatedPropertyName] = args.constraints;
          const relatedValue = (args.object as any)[relatedPropertyName];

          if (isISO8601(value) && isISO8601(relatedValue)) {
            // 両方とも日付文字列ならばチェックする
            return value <= relatedValue;
          }
          // 2つの値が比較できない場合にエラーとするのならば、falseをreturnする
          return true;
        },
      },
    },
    { ...validationOptions, message },
  );
}
```

## 完成形(バリデーションルール設定後)
先ほどのサンプルクラスに、バリデーションルールを設定した後のクラスは、以下のようになります。
(enumの定義とコンストラクタは割愛しています)

```ts
/** 在職期間 */
export class Tenure {
  @IsISO8601()
  @isLessEqualDate('to') // カスタムバリデーター
  /** 入社日 */
  from: string;

  @IsOptional()
  @IsISO8601()
  /** 退職日 */
  to?: string;
}

/** メモ */
export class Note {
  @IsISO8601()
  /** 日付 */
  date: string;

  @IsOptional()
  @Length(1, 100, { message: '$constraint2文字以下で入力してください' })
  /** 文章 */
  text?: string;
}

/** 社員情報 */
export class Employee {
  /** 社員番号 */
  @Length(5, 6, { message: '$constraint1〜$constraint2桁で入力してください' })
  @IsNumberString()
  id: string;

  /** 名前 */
  @IsFullWidth()
  name: string;

  /** 所属部署 */
  // enumの値に合致するかチェックする
  // @IsIn([1, 2, 3]) でもOK
  @IsEnum(Department)
  department: Department;

  /** メールアドレス */
  @IsOptional()
  @IsEmail()
  email?: string;

  /** 在職期間 */
  @IsOptional()
  @ValidateNested()
  tenure?: Tenure;

  /** メモ */
  @IsOptional()
  @ArrayNotEmpty()
  @IsArray()
  @ValidateNested({ each: true })
  notes?: Note[];
}
```

# バリデーション結果の出力例

## エラーなし
空の配列が返却されます。

```ts:data
  const data = new Employee({
    id: '01234',
    name: 'やまだたろう',
    department: Department.SALES,
    email: 'taro-yamada@example.com',
  });
  const result = await validate(data);
```

```json:result
  []
```

## エラーあり
エラーのあった項目ごとにエラー情報(`ValidationError`)が作成されます。

```ts:data
  const data = new Employee({
    id: 'abcdefghi',
    name: 'やまだたろう',
    department: Department.SALES,
    email: 'taro-yamada@example.com',
  });
  const result = await validate(data);
```

```json:result
  [
    {
      "target": {
        "id": "abcdefghi",
        "name": "やまだたろう",
        "department": 2
      },
      "value": "abcdefghi",
      "property": "id",
      "children": [],
      "constraints": {
        "isNumberString": "id must be a number string",
        "length": "5〜6桁で入力してください"
      }
    }
  ]
```

## ネストがある場合
`children`にネストしているエラーの詳細が格納されます。
class-validatorはプレーンなオブジェクトに対してバリデーションを行えないため、`Object.assign`などを用いてインスタンスを生成する必要があります。

```ts:data
  const data = new Employee({
    id: '01234',
    name: 'やまだたろう',
    department: Department.SALES,
    tenure: Object.assign(new Tenure(), { from: 'aaaa', to: '2020-03-31' }),
  });
  const result = await validate(data);
```

```json:result
[
  {
    "target": {
      "id": "01234",
      "name": "やまだたろう",
      "department": 2,
      "tenure": {
        "from": "aaaa",
        "to": "2020-03-31"
      }
    },
    "value": {
      "from": "aaaa",
      "to": "2020-03-31"
    },
    "property": "tenure",
    "children": [
      {
        "target": {
          "from": "aaaa",
          "to": "2020-03-31"
        },
        "value": "aaaa",
        "property": "from",
        "children": [],
        "constraints": {
          "isIso8601": "from must be a valid ISO 8601 date string"
        }
      }
    ]
  }
]
```

# 参考リンク
* [class-validator](https://www.npmjs.com/package/class-validator)(npm): ライブラリの使用方法はこちら
* [validator](https://www.npmjs.com/package/validator)(npm): 各バリデーションルールの詳細はこちら