TypeScriptでも値オブジェクトが書きたい

普段Scalaを書いていますが、業務ではTypeScriptもフロントエンドで利用されています。※私はCSSが非常に苦手なのでほとんどフロントを触らないのですが。 バックエンドAPIは業務的なロジックに集中できて、DDD的な実装パターンなども適用しやすく結構綺麗に書けるのですがフロントエンドは要件が複雑になりがちで、その分コードも複雑になりがちです。また、バックエンドと異なり、UI/UXが中心になりコロコロ要件が変わるフロントエンドはバックエンドに比べてもリファクタリングする余裕がないことも多いです。

そうはいっても、少しでも綺麗に書きたいわけです。アンクル・ボブも言っています。

崩壊したコードを書くほうがクリーンなコードを書くよりも常に遅い

—Robert C. Martin, Clean Architecture 達人に学ぶソフトウェアの構造と設計

と。

そこで今回は私がDDDを学んできて最も簡単で、最も強力な実装パターン、「値オブジェクト」をTypeScript でどう書くかを見ていこうと思います。

値オブジェクト

値オブジェクトについておさらいです。(自分の観測範囲だけかもしれませんが)最近はアプリケーション設計関連の書籍も増えてきてあちこちで見るように思います。まずはDDDの原著である、Eric Evansのものを確認します。

あるモデル要素について、その属性しか関心の対象とならないのであれば、その要素を値オブジェクトとして分類すること。値オブジェクトに、自分が伝える属性の意味を表現させ、関係した機能を与えること。値オブジェクトを不変なものとして扱うこと。同一性を与えず、エンティティを維持するために必要となる複雑な設計を避けること。値オブジェクトを構成する属性は、概念的な統一体を形成すべきである。

例えば、街区、都市、郵便番号は人オブジェクトの別々な属性であってはならない。それらはある住所全体の一部であり、それによって人オブジェクトはよりシンプルになり、より凝集度の高い値オブジェクトができる。

『エリック・エヴァンスのドメイン駆動設計』Eric Evans著

つまりは、(細かい話になるとドメインによって異なったり色々議論がありますが)メールアドレスとか、住所、お金など、同一性(田中太郎さんと田中太郎さんは同姓同名同年齢同身長同体重同じ顔だとしても別人である)ではなく、同値性(太郎さんが持っている100円と花子さんが持っている100円は交換しても問題ない)によって、二つが等しいかが決まるようなものです。最近だと不変条件(invariant)を足してドメイン・プリミティブと呼ばれたりします。※自分は、値オブジェクトは不変条件ありきだと思っていたのでそもそも「え、これのどこが新しいの?」って感じでしたが。。

Scalaだと簡単に書くときは次のような感じになります。

case class ZipCode(value: String) extends ValueObject {
  // クラス不変表明を入れて、その値の仕様を示すと良い
  require("^[0-9]{3}-[0-9]{4}$".r.matches(value), "ZipCode must be ...(some error message)")
  def someMethod(...) = {
    // このクラスが行うに相応しい振る舞いを書く
  }
}

TypeScript で値オブジェクトを書く場合の課題

DDD関連の実装パターンは基本Java前提なものが多いです。別に言語が違ってもパラダイムが同じならば多少書き方が異なるだけで問題ないのですが、(JavaScriptは論外として)TypeScript はDDDとは相性が悪いです。

DDDの実装パターン例の多くはJava前提で書かれていてサンプルコードが少ないなどの問題がありますが、それ以上に次の2つの違いが非常に困ります。

  • Nominal Typing(名前的型付) ・・・Javaとか
  • Structural Typing (構造的型付) TypeScriptとかGoとか

細かい説明はできないので以下とかを見てください。

Structural Typing であるTypeScriptの場合、その構造だけに注目し、そのクラスの名前では区別できないのでJavaScalaのように、同じ構造のクラスだけど意味が違うから別ものというようなことがすごくやりにくいです。例えば、ID型などは際たる例でしょうか。

Scalaならば以下の2つは別物です。

case class UserId(value: Long) extends EntityId[Long]

case class CompanyId(value: Long) extends EntityId[Long]

そのため例えば

def findByUserId(userId: UserId) = {
    println(userId)
}
findByUserId(UserId(1))
// コンパイルエラー
findByUserId(CompanyId(1))

しかし、TypeScriptの場合上と同じようにクラスを分けても構造が一緒であれば同じ型と見做してしまいます。

class UserId {
  constructor(readonly value: number) {}
}

class CompanyId {
  constructor(readonly value: number) {}
}

なので

function findByUserId(userId: UserId) {
    console.log(userId)
}

const userId: UserId = new UserId(1)
const companyId: CompanyId = new CompanyId(1)

// どっちも通る
findByUserId(userId) 
findByUserId(companyId)

となってしまいます。両者は別物ですが、typescriptでは区別できないんですね。

TypeScriptで値オブジェクトを書く方法

どうしたらいいのか色々調べていたら、クラスにゴリゴリ振る舞いを描く自分には下記の記事が参考になりそうでした。

zenn.dev

構造が異なれば別の型と判断してくれるので、構造をちょっと変えてあげればいいわけですね。 プログラミング TypeScript では、型のブランド化として簡易版が紹介されています。

書き方としては次のようになります。

// それぞれに合成的な型のブランドを作成する
type UserId = string & { readonly: brand: unique symbol }
type CompanyId = string & { readonly: brand: unique symbol }

// コンパニオンオブジェクトパターン
function UserId(id: string) {
  return id as UserId
}

function CompanyId(id: string) {
  return id as UserId
}

これは値オブジェクトというよりは、Scalaでいうところの値クラスって感じですね。これで、同じ構造でも別物として扱ってくれるようになります。

しかし、値オブジェクトとして不変条件を定義したい自分にとってはただ string が区別される程度では物足りません。そこで最初のzennの記事を参考にして値オブジェクトを書いてみました。

PreferNominal は下記のようになっています。

export type PreferNominal = never | undefined;

gist.github.com

普段interfaceで型づけするくらいの人には面倒に思うかもしれませんが、凝集度が高い実装に慣れてしまっている自分にはこれくらいの情報量が安心感ありますね。 不正な値はconstructorでバリってるので存在できず、エラーになります。

最初にも言いましたがフロントは複雑になりがちで、複雑になったものを綺麗にするのもなかなか大変です。意識としてコンポーネントに向きがちなのであまりロジック的な綺麗さまでは意識が向かれない印象です。結果としてロジックが散財して、あっちではちゃんとバリデーションしてるけど、こっちではできてないとか。そもそもこの値ってなんだっけ?とバックエンドとか仕様書見に行かなきゃならないとか起こりがちです。

値オブジェクトに知識を持たせることは小さい粒度で大きな改善が見込めます。