Notificaitonパターンでバリデーションを実装する in TypeScript
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されているUserEmptyException
がUserUniqueException
はメソッドを使用する側が型をキャストしてあげる必要がある。JavaであればthrowされているExceptionをcatchしていない場合はコンパイルエラーとなるため問題にはならないが、TypeScriptで型推論が壊れてしまうのは致命的というかTypeScriptを使っている意義も後退するような感じもして良くない。
二つ目の問題はエラーを一度に複数返すことができないことである。上記の例で言えば、ユーザーの名前が一意である必要があるバリデーションに加えて、何か他のバリデーションエラーをthrowすることになった場合(良い例が思いつかないが)、一度エラーを解消したと思ったら今度は次のエラーかよとモグラ叩き的なユーザー体験を提供してしまうことになりかねない。
Notificationパターン
そこで、Exceptionをthrowするのではなく、validate
メソッドがExceptionを返却するような設計を考えてみる。そうすることで型推論問題の解決と、バリデーションエラーを一度に複数表現することを達成したい。
実はこのパターンは既にNotificationパターンと名付けられている。NotificationパターンとGoogleで検索してもObserverパターンしか出てこないが、Martin Fowlerのブログ記事に具体的な実装例が載っている。
これをTypeScriptで実装したい。基本的にはMartin Fowlerが書いていることをTypeScriptに変換するだけという感じだが、当該ブログ記事ではNotificationクラスを返却するという実装になっているところをTypeScriptで扱いやすいようにしたい。
まず、validate
メソッドが返却する値を定義する。any
が割と沢山出てくるのは許してほしい。validate
メソッドはIssue
という型を配列で返却することになっており、Issue
はcode
というプロパティを持っている。後述するが、このプロパティを持っていることで、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の表現力を享受できる。
参考情報
Discussion