📑

Notificaitonパターンでバリデーションを実装する in TypeScript

2022/08/06に公開

TypeScriptでのバリデーションパターン

TypeScriptでバリデーションを設計すると、単純に考えるとこういったinterfaceを定義したくなる。

interface Validation<Value> {
  validate(value: Value): boolean
}

単純なバリデーションであればこのinterfaceだけで事足りるかもしれないが、複数の項目をチェックするような(こういうユースケースは必ず発生する)複雑なバリデーションになると、返り値のbooleanではバリデーションが失敗した理由を表現することができない。特にデータベース問い合わせが発生するような非同期バリデーションだと、失敗理由はより一層複雑になる。

Specificationパターンを使って細かいバリデーションルールを組み合わせて実現するにしてもやはり失敗理由を表現することは難しい。

class CreateUserSpecification extends Specification<Rule, Candidate> {
  constructor(private rules: Rule[]) {}
  
  isSpecifiedBy(value: Candidate) {
    return rules.some((rule) => rule.validate(value))
  }
}

Exceptionをthrowするようなパターンならどうか

class CreateUserSpecification implements Validation<string> {
  constructor(private dataSource: DataSource) {}
  
  async validate(userName: string): boolean {
    if (userName.length === 0) {
      throw new UserEmptyException("User name should not be empty");
    }
    
    const user = await this.dataSource.findByName(userName);
    if (user) {
      throw new UserUniqueException("User name should be unique");
    }
    return true;
  }
}

この場合は当然のことながら、try-catchでメソッドコール部分を囲う必要がある

const spec = new CreateUserSpecification(new DataSource())
try {
  await spec.validate("John")
} catch(error) {
  // Handle a validation error
}

これはこれでありな気もするが、このコードの問題点は複数ある。

一つ目はtry-catchの型推論が壊れてしまうことで、TypeScriptにおいてはtry-catchのcatchで受け取る引数(ここではerrorパラメーター)は、型推論が効かずunknownになってしまう。spec.validate中でthrowされているUserEmptyExceptionUserUniqueExceptionはメソッドを使用する側が型をキャストしてあげる必要がある。JavaであればthrowされているExceptionをcatchしていない場合はコンパイルエラーとなるため問題にはならないが、TypeScriptで型推論が壊れてしまうのは致命的というかTypeScriptを使っている意義も後退するような感じもして良くない。

二つ目の問題はエラーを一度に複数返すことができないことである。上記の例で言えば、ユーザーの名前が一意である必要があるバリデーションに加えて、何か他のバリデーションエラーをthrowすることになった場合(良い例が思いつかないが)、一度エラーを解消したと思ったら今度は次のエラーかよとモグラ叩き的なユーザー体験を提供してしまうことになりかねない。

Notificationパターン

そこで、Exceptionをthrowするのではなく、validateメソッドがExceptionを返却するような設計を考えてみる。そうすることで型推論問題の解決と、バリデーションエラーを一度に複数表現することを達成したい。

実はこのパターンは既にNotificationパターンと名付けられている。NotificationパターンとGoogleで検索してもObserverパターンしか出てこないが、Martin Fowlerのブログ記事に具体的な実装例が載っている。

https://martinfowler.com/articles/replaceThrowWithNotification.html

これをTypeScriptで実装したい。基本的にはMartin Fowlerが書いていることをTypeScriptに変換するだけという感じだが、当該ブログ記事ではNotificationクラスを返却するという実装になっているところをTypeScriptで扱いやすいようにしたい。

まず、validateメソッドが返却する値を定義する。anyが割と沢山出てくるのは許してほしい。validateメソッドはIssueという型を配列で返却することになっており、Issuecodeというプロパティを持っている。後述するが、このプロパティを持っていることで、switch文やif文でいい感じで型推論を効かせることができる。なお、ValidaitonErrorではなくIssueという型名にしているのはErrorだと大仰な感じがするから。

type Issue<Code extends string, T = Record<any, any>> = T & { code: Code }

interface Validation<Value, TIssue extends Issue<any, any>> {
  validate(value: Value): Promise<TIssue[]>
}

これを実装するクラスはこんな感じになる。Exceptionをthrowしていた箇所がissues.pushになっている。複数のバリデーションエラーを一度に返却したいのでissuesはmutableになっているが、もちろんpushのところをreturnにしても良い。

type UserUniqueNameIssue = Issue<"UNIQUE_USER_NAME">
type UserEmptyNameIssue = Issue<"EMPTY_USER_NAME">
type Issues = (UserUniqueNameIssue | UserEmptyNameIssue)[]

class CreateUserSpecification implements Validation<string, UserUniqueNameIssue | UserEmptyNameIssue> {
  constructor(private dataSource: DataSource) {}
  
  async validate(userName: string): Promise<Issues> {
    const issues: Issues = [];

    if (userName.length === 0) {
      issues.push({ code: "EMPTY_USER_NAME"  })
    }
    
    const user = await this.dataSource.findByName(userName);
    if (user) {
      issues.push({ code: "UNIQUE_USER_NAME"  })
    }

    return issues;
  }
}

使う側はこんな感じ。先述した通り、codeプロパティを入れておくことで文字列で型推論が効いてそれぞれのIssue型を取得することが可能になっている。これをErrorクラスを継承したカスタムErrorクラスのインスタンスを返却すると、instanceofを使用するためにカスタムErrorクラスをインポートする必要があり、(個人的には)面倒なのでstringで比較する形としている。

const spec = new CreateUserSpecification(new DataSource());
const issues = await spec.validate("John");
if(issues.length) {
  for(const issue of issues) {
    switch(issue.code) {
      case "EMPTY_USER_NAME": {
        // Handle UNIQUE_USER_NAME issue
      }
      case "UNIQUE_USER_NAME": {
        // Handle UNIQUE_USER_NAME issue
      }
      default: {
        // Handle an unexpected issue
      }
    }
  }
}

classなどを使わずにTypeScriptの型システムだけで表現することで、実行時のことを気にせずにTypeScriptの表現力を享受できる。

参考情報

https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-model-layer-validations
https://martinfowler.com/articles/replaceThrowWithNotification.html

Discussion