TypeScriptでの値オブジェクトとフロントエンド・バリデーション
この記事はトレタ Advent Calendar 2019の5日目です。
こんにちは、奥野賢太郎 ( @okunokentaro ) です。私は株式会社トレタの社員ではなく、フリーランスのデベロッパーなのですが、今年一年トレタ社のお仕事のお手伝いをさせていただいて、縁がありましてゲスト寄稿的にカレンダーに参加しています。
要約
- トレタ社では様々な種類の入力フォームを備えたアプリケーションを運用しています
- バリデーション処理の分散は修正漏れやバグに直結するので、共通化しましょう
- 単なる共通化では問題があるため、値オブジェクトに集約しましょう
- TypeScriptでの値オブジェクトの定義には一工夫が必要です
- constructor validationの手法を紹介します
なにをやっているの?
私はTypeScriptやJavaScriptを専門としたデベロッパーなので、主にフロントエンドや、場合によってはNode.jsバックエンドの開発も行います。これまでのキャリアでは、数社の比較的大きめなプロジェクトに参加しており、そのときに学んだDDD(ドメイン駆動設計)や各種アーキテクチャの知見、デザインパターンの知見をもとに、トレタ社ではアーキテクトやテクニカル・ディレクターの立場をとっています。
トレタ社では飲食店予約のシステムを取り扱います。あなたはウェブでの飲食店予約を試したことがありますか?ウェブからの予約ではおもに氏名、メールアドレス、電話番号といった入力情報のほか、人数、日時といったものを入力していきます。そういったサービスを開発する際に、フロントエンドで考慮したことについてお話しします。
バリデーションの山
どうやってバリデーションしよう
フロントエンドの開発をされたことがあれば、バリデーションの実装を頻繁にしていることでしょう。もちろんバックエンドでのバリデーション、データベースへの永続化直前での不正データの除去は重要です。ですが、だからといってフロントエンドはノーバリデーションでよいか?といえば、そうではありません。
例えば、指定文字数に満たない場合に「n文字以上必要です」と表示したり、逆に多すぎる場合は「n文字までです」といったエラー表示を動的に出したいとします。サーバーに値を送信する前に、利用者に入力値に誤りがあることを伝えるためにはフロントエンドでの動的なバリデーション処理も併せて必要となってきます。このような実装をしたいとき、どうしますか?
まずは、あまりよくない例から紹介していきましょう。
よく書かれるのはif文ですね。
if (customer.name.length === 0 || 100 < customer.name.length) {
this.nameIsValid = false;
return;
}
これで100文字より長いお客様名のときにフラグを扱う、といったことができるようになりました。なお、筆者はinvalid = true
よりはisValid = false
を好みます。否定の否定は可読性を下げるため、boolean変数名は正常系をtrue
とするように名付けます。
重複コードの登場
だんだんややこしくなってきます。今度は、入力に誤りがあるときに送信ボタンをグレーアウトしたい、なんて要件が現れたとします。「でもthis.nameIsValid
は別のコンポーネントの変数だし…今ここにはname
変数しかない…」となったとき、このように書くことが考えられます。
if (name.length === 0 || 100 < name.length) {
this.buttonIsEnabled = false;
return;
}
おやおや、100
という数字が重複していますね。もし仕様変更があって、名前の制限文字数を変えたいとなった場合どうなるでしょう。エディタでプロジェクト内を全検索して100
を見つける必要がある…でもその検索にマッチした100
は、文字数の100
か?幅100px
を示す100
もマッチしていないか?number
の値は「なんの数字」かを判別する手がかりが非常に少ないです。
こういった状況でよくとられるアプローチは2つです。
// 定数宣言ファイルにて
export const customerNameMax = 100;
// 別のファイルにて
if (name.length === 0 || customerNameMax < name.length) {
this.buttonIsEnabled = false;
return;
}
マジックナンバーの変数化はマーティン・ファウラーの著書にも載る初歩的なリファクタリングですね。もう一つのアプローチは判定式自体を関数にすることです。
// バリデーション関数記述ファイルにて
export function validateCustomerName(v: string): boolean {
return v.length === 0 || 100 < v.length;
}
// 別のファイルにて
if (validateCustomerName(name)) {
this.buttonIsEnabled = false;
return;
}
バリデーション関数に切り出して、その関数をimport
して呼ぶことは、比較的よくやられているんじゃないでしょうか。
あらゆる種類の文字列
トレタ社の予約サービスでは姓名、メールアドレス、電話番号、ご要望欄といったお客様の入力可能な欄があります。すべてTypeScript的にいえばstring
ですね。しかし次のような事故が考えられます。
// バリデーション関数記述ファイルにて
export function validateCustomerName(v: string): boolean {
return v.length === 0 || 100 < v.length;
}
export function validateEmail(v: string): boolean {
return // メールの正規表現処理、他のライブラリに依存するため割愛
}
// 別のファイルにて
if (validateEmail(name)) {
this.buttonIsEnabled = false;
return;
}
おっと、validateEmail(name)
なんてことをしてしまいました。コードレビューで弾ければいいですが、そうでなければこれはバグとして残ります。TypeScriptはstring
を見分けることができないので、name
変数がstring
型であれば、validateEmail(v: string): boolean
のシグネチャに適合します。
メールアドレスのバリデーションの場合、すぐ期待しない挙動を起こすので気付けるかもしれませんが…、こういった問題は他のあらゆる値で起こりえます。
バリデーション関数の取り違えに神経を尖らせながらフォームの開発…疲れますよね。
値オブジェクトを活用する
TypeScriptの挙動を理解しよう
TypeScriptで開発しているのであれば、値オブジェクトを活用しない手はないです。値オブジェクト (Value object) はDDDを中心に、モデリング駆動開発やオブジェクト指向プログラミングの文脈でよく語られる手法のひとつです。
プリミティブな値、TypeScriptでいうstring
やnumber
を、型定義することでコンパイラに識別可能なようにするやり方です。ただしTypeScriptの特性に気をつけなければなりません。
次のコードはコンパイルが通るコードですが、よくない例です。
type CustomerName = string;
type Email = string;
function validateCustomerName(v: CustomerName): boolean {
return false; // なにかの処理
}
const email: Email = 'a@b.com';
validateCustomerName(email);
Email
型の変数email
が(v: CustomerName)
に渡せてしまっている…?いやいや、よくみるとtype CustomerName = string
なんて宣言がありますね。Type Aliasesでいくらstring
に別名を与えても、それはstring
のままです。
type
を使って値オブジェクト的なことができると勘違いすると、思わぬミスに繋がるので注意しましょう。
もうひとつよくない例を紹介します。
class RestaurantKey {
constructor(readonly v: string) {}
}
class ReservationKey {
constructor(readonly v: string) {}
}
function fetchReservation(key: ReservationKey) {
// なにか取得処理
}
const restaurantKey = new RestaurantKey('asdfg');
fetchReservation(restaurantKey);
トレタ社では飲食店や予約を取り扱うので、プロダクトコード内にユニークキーが何種類か出てきます。type
と違ってclass
がちゃんと宣言されているから大丈夫なはず…と思いきや、fetchReservation(restaurantKey)
にてコンパイルが通ってしまっています。予約情報を取りたいところに店舗キーを渡しているので、これはバグとなります。
Structural TypingとNominal Typing
classを宣言したのにコンパイルエラーになってくれない、この現象を理解するためにはTypeScriptが型の判定としてStructural Typingを採用していることを理解しましょう。
前節の例からclass
名を外し、メンバだけを見てみましょう。
{
readonly v: string;
}
{
readonly v: string;
}
そう、どちらも全く同じです。TypeScriptはStructural Typingを採用しているため、class
名が違っていても構造が同じであれば同じ型であるとみなします。これを回避すれば、つまりStructural Typingと対になる概念であるNominal TypingをTypeScriptで模倣すれば、この問題を解決できます。
そのための手法を紹介する記事の日本語訳が上がっていたので紹介します。
トレタ社での値オブジェクト
トレタ社でどのようにNominal Typingを守りつつ値オブジェクトを宣言しているかを紹介します。やり方は前節の記事のもの以外にもありまして、記事通りではないですが、同様のことが実現できています。
// prefer-nominal.ts
export type PreferNominal = never;
// restaurant-key.ts
class RestaurantKey {
restaurantKey: PreferNominal;
constructor(readonly v: string) {}
}
// reservation-key.ts
class ReservationKey {
reservationKey: PreferNominal;
constructor(readonly v: string) {}
}
このようにPreferNominal
型エイリアスを宣言して、それを使うようにしています。Nominal Typingを実現するための識別メンバの型はなんだっていいのですが、プロダクトコードで突然any
やvoid
やnever
という文字が見えるとあまり心臓によくないので、PreferNominal
型エイリアスを使うことでそれを隠蔽しています。
値オブジェクトにバリデーション処理を集約させる
constructor validation
いよいよ仕上げです。バリデーション関数を値オブジェクトに対応させてもよいですが、この際クラスのインスタンスが成立するための必須条件として、コンストラクタに書いてしまいましょう。この発想は契約プログラミング、防御的プログラミングの文脈に由来します。
次の例は、プロダクトの実際のコードから、一部を間引いたものです。
export class CustomerName extends NotEmptyStringValue {
customerName: PreferNominal;
constructor(v: string) {
super(v);
const isWithinRange = 1 <= v.length && v.length <= 100;
if (!isWithinRange) {
throw new TypeError(invalidInstantiation);
}
}
}
NotEmptyStringValue
という抽象値オブジェクト型でまず空文字列を受け付けないようにしてから、さらに具象クラスにて文字数を決めています。この1と100はこのclass
のスコープより外には出ることがないので、マジックナンバーに対する定数化は冗長と判断して特に行っていません。
こうすることで、空文字列を許容せず100文字以内という型が作れました。(1 <= v.length
については、他の型で最小文字数が決まっている電話番号などもあるため、そことの体裁の揃えに由来しています。)
このやり方を量産してメールアドレスclass Email
や電話番号class Phone
、ご質問・ご要望欄のためのclass FreeText
といった値オブジェクトを実装していきます。
ここまでできたら、あとは単体テストをひたすら書いていくだけですね!
テストがグリーンかつコンパイルが通っている限り、その領域でバリデーションのバグはそうそう混入しなくなります。バリデーション処理に合格したことを静的に検知できるためです。
バリデーションエラーになったらどうする?
constructor validationは便利ですが、一方でnew
するたびに例外が発生してハンドリングせねばならないのも不便です。トレタ社では、次のようなinstantiate()
関数を作っています。
export function instantiate<T, U>(
ctor: new (v: U) => T,
v: U,
): [T | null, boolean] {
let retVal: T | null = null;
let retValIsValid = false;
try {
retVal = new ctor(v) as T;
retValIsValid = true;
} catch (e) {
if (!e.message.includes(invalidInstantiation)) {
throw e;
}
}
return [retVal, retValIsValid];
}
この関数を使うようにすることで、バリデーション処理に合格あるいは不合格していることに関係なく処理を続行して、判定を続けることができるようにしています。
retValIsValid
のフラグはあくまでも画面描画(文字を赤くしたり)に使うだけで、基本的にはタプルの第1要素の値を信用しておけばよいです。v === null
でisValid
が求まるため冗長そうにも見えますが、未入力時のことも考慮すると、それはnull
かつvalidなため、null
であれば常にinvalidというわけでもないです。
こんな感じでTypeScriptを推進しています
トレタ社が扱うプロダクトでは様々な入力欄が出てきますので、ここで紹介したように値オブジェクトやconstructor validationを駆使することで、保守性の向上、テスト可用性の向上に努めています。JavaScriptに型がある…といった程度ではなく、TypeScriptという言語においてどう実装するのが好ましいかを常に考えてコーディング、レビューするようにしています。
トレタ社では、運用中のフロントエンド・プロダクトのおよそ9割のコードがTypeScriptとなっています。ごく僅かに古いコードがJavaScriptのままですが、私がお仕事をお手伝いするより遥か前からTypeScript化が進んでおり、これはかなり意欲的だと感じます。
アプリケーション開発は基本的にAngularを採用していますが、LitElementを試したりgRPC-webを導入するなど、積極的に様々な技術選定をしています。来年も私は引き続きお世話になれると幸いですが、フロントエンドで一緒に働く仲間も募集されておりますので、ぜひTypeScriptを思う存分書きたい方はご確認ください。gotanda.tsという五反田のTypeScriptローカルコミュニティも私が個人的に運営していまして、その会場としてもトレタ社にお貸しいただいてます。こちらも次回ぜひご参加ください。
それでは、また。
Discussion