TypeScript で値オブジェクト(Value Object)を実装する方法をご紹介したいと思います。
こんにちは、h.kitachi です!
値オブジェクトは、有名なソフトウェア設計方法であるドメイン駆動設計(DDD)の重要な構成要素のひとつです。
本稿では DDD の難しい話は割愛して、値オブジェクトだけに注目して、導入メリットと実装方法について紹介したいと思います。
使用言語は TypeScript です。
静的型付けによる安全性と、フロントエンドとバックエンドをひとつの言語でまかなえることなどが特徴で、値オブジェクトが有用となる比較的大規模なウェブアプリケーションにも向いています。
本稿での値オブジェクトの定義
「値オブジェクトとはどんなものか」については諸説いろいろあるようです。
それを踏まえて、最初に「"今回扱う" 値オブジェクトとはこういうものです」ということを提示したいと思います。
みなさんの知っている値オブジェクトとは違うかもしれませんが、そこはご容赦ください!
- オブジェクトの種類ごとに型を持つ
- 値(データ)を持っている
- データの数は 1 ~ 数個程度のごく小規模なもの
- データは不変 (immutable) である
- 振る舞い(メソッド)はあってもなくてもよい
- 比較機能を持つ場合、2 つのオブジェクトの型とデータが一致すれば同一とみなす
- 生成と同時に初期化が完了している状態となる (初期化失敗は生成失敗と同じ)
値オブジェクトのメリット
まず、不変性がいろいろな利点を生みます。
並列実行において安全です。
オブジェクトの状態が並列処理により変更されてしまう恐れがありませんので、オブジェクトの同期に注意を払う必要がありません。
状態が変わらないということは、いちど生成した値をいくら再利用しても問題ないということです。
同じ値オブジェクトが欲しければ、ただ参照をコピーするだけで問題ありません。
状態遷移が起きないため、単体テストが非常にシンプルになります。
内部状態をうまくコントロールして、テストパスを通すような努力は必要ありません。
値オブジェクトは基本的に小さなオブジェクトなので、
不変性を実現するためのコスト(値の変更が必要な時は、新しいオブジェクトを生成して置き換える)が少なくて済みます。
不変性以外にも以下の利点があります。
型が異なることでオブジェクトが明確に区別され、誤った代入が起きなくなります。
例えば、ID とパスワードを引数に取る関数 auth
があったとして、値オブジェクトがないバージョン auth(id: string, password: string)
では引数の順番を間違えたとしても実行時までエラーになりません。
テスト環境で ID とパスワードを同じにしていたというような場合では、実行時でさえエラーにならない可能性すらあります。
値オブジェクトを導入したパターン connect(id: UserId, password: UserPassword)
では、引数の順番を間違えればコンパイルエラーになります。
生成と同時に初期化、すなわち値の検証などが完了しているので、値オブジェクトが存在している以上、その正常性は保証されます。
例えば、string などのプリミティブ型を引数にとる関数では、呼び出し元と関数のどちらかが値の正常性を担保しなければいけませんが、この責任が明確ではありません。
呼び出し元と関数側で二重にチェックしていたり、悪いケース(そして非常によくあるケース)ではどちらでもチェックしていません。
値オブジェクトはこの問題を理想的に解決します。値のチェックは最も検証ロジックに詳しい値オブジェクト自身によって行われているため、呼び出し元も関数も何もせずに安心して使用できます。
値オブジェクトには「なんとなくプリミティブ型で取り扱っていた値を、名前の付いたクラスにする」という作用があります。
上述の検証ロジックが好例ですが、「その場その場で書き散らかされていたり、構造化してあってもグローバルだったり見当違いのクラスに実装されていた処理が、値オブジェクトのメソッドに実装される」という、いわゆる「凝集度が上がる」効果が生まれます。
また多くの場合、単体テストはクラスをターゲットにして計画・実施されるため、クラス化されるということは単体テストの対象として明確に認識されるようになるということで、テストの抜け漏れ防止に役立ちます。
実装してみる
重さを表す値オブジェクト Weight
を作ってみました。
詳しい説明は後述しますが、まずはクラスの全体をご覧ください。
export class Weight {
private readonly _grams: number // g 単位で保持する
public get grams(): number {
return this._grams
}
public get kilograms(): number {
return this._grams / 1000
}
private constructor(grams: number) {
this._grams = grams
}
public static createFromGrams(grams: number): Weight {
if (grams < 0) {
throw new Error("Weight cannot be negative")
}
return new Weight(grams)
}
public static createFromKilograms(kilograms: number): Weight {
return this.createFromGrams(kilograms * 1000)
}
public toString(): string {
return this._grams.toString()
}
public toJson(): number {
return this._grams
}
public add(weight: Weight): Weight {
return new Weight(this._grams + weight._grams)
}
public addGrams(weight: number): Weight {
return new Weight(this._grams + weight)
}
public subtract(weight: Weight): Weight {
return new Weight(this._grams - weight._grams)
}
public subtractGrams(weight: number): Weight {
return new Weight(this._grams - weight)
}
public isEqual(weight: Weight): boolean {
return this._grams === weight._grams
}
}
const w1 = Weight.createFromGrams(1000)
console.log(w1.grams) // 1000
const w2 = w1.add(Weight.createFromGrams(500))
console.log(w2.grams) // 1500
const w3 = w2.addGrams(700)
console.log(w3.grams) // 2200
const w4 = w3.subtractGrams(700)
console.log(w4.grams) // 1500
console.log(w1.isEqual(w2)) // false
console.log(w2.isEqual(w4)) // true
実装内容の解説
以下、順番に説明していきます。
まず、値を保持するフィールドを private readonly
で宣言し、さらに getter を定義しています。
private readonly _grams: number // g 単位で保持する
public get grams(): number {
return this._grams
}
public get kilograms(): number {
return this._grams / 1000
}
いきなりですが、この部分が値オブジェクト実装の最大のポイントです。
不変性が重要ですので、readonly として宣言しているのは、すんなりとご理解いただけると思います。
ですが、なぜ public なフィールドを使わずに、private フィールド + getter という回りくどいことをしているのか?
値を加工する必要のある kilograms
はともかく、grams
はストレートに public フィールドで良いのではないか?
そんな疑問を抱かれた人もいるのではないしょうか。
これにはちゃんと理由があります。
それは TypeScript(JavaScript) の構造的型付けによって「想定しないクラスの同一視」が起きることを回避するためです。
「想定しないクラスの同一視」とは、具体的には以下のようなことです。
class Dog {
public constructor(
public readonly name: string,
) {}
}
class Cat {
public constructor(
public readonly name: string,
) {}
}
let a_dog: Dog = new Dog("Goofy")
let a_cat: Cat = new Cat("Kitty")
a_dog = a_cat // 想定では誤った代入、でも言語仕様的には正当でエラーではない!
「string 型の name
フィールドを持っている」という「形(shape)」が同じであるため、Dog
と Cat
は互換性があるとみなされてしまいます。
これが構造的型付けです。
構造的型付けの柔軟さがメリットになる局面も多いのですが、値オブジェクトを実装する際は障害となってしまいます。
この構造的型付けを無効化するのが private フィールドです。
TypeScript の仕様では、2 つの型の互換性をチェックする際、private または protected メンバー(同じスーパークラスから継承したものは除く)がある場合、たとえその形が一致していても、構造的型付けは適用されません。
その効果を狙って、あえて private なフィールドを宣言しているのです。
class Dog {
public constructor(
private readonly _name: string,
) {}
public get name(): string {
return this._name
}
}
class Cat {
public constructor(
private readonly _name: string,
) {}
public get name(): string {
return this._name
}
}
let a_dog: Dog = new Dog("Goofy")
let a_cat: Cat = new Cat("Kitty")
a_dog = a_cat // エラー!
// Type 'Cat' is not assignable to type 'Dog'.
// Types have separate declarations of a private
// property '_name'.ts(2322)
Weight
クラスの解説に戻ります。
続いて、コンストラクタとファクトリーメソッドを定義しています。
private constructor(grams: number) {
this._grams = grams
}
public static createFromGrams(grams: number): Weight {
if (grams < 0) {
throw new Error("Weight cannot be negative")
}
return new Weight(grams)
}
public static createFromKilograms(kilograms: number): Weight {
return this.createFromGrams(kilograms * 1000)
}
バリデーション処理を行って、必ず正常な値でオブジェクトが生成されるようにしています。
ここでも回りくどく、コンストラクタを private にして、わざわざ static なファクトリーメソッドでオブジェクトを生成するようにしています。
これに関しては言語仕様的な制限ではなく、単純に個人的にこのほうが便利と思っているからです。
コンストラクタに比べて、ファクトリーメソッドには以下の利点があります。
- 分かりやすい名前を付けることができます。
Weight
の例でも、グラムの値からオブジェクトを生成する(createFromGrams
)のか、それともキログラムなのか(createFromKilograms
)、メソッド名から読み取ることが可能です。 - 生成コストが高いオブジェクトについては、生成のたびに
new
するのではなく、キャッシュからインスタンスを返すことができます。 - キャッシュの応用として、値のバリエーションの上限が判明してるのであれば、「同じ値については常に同じインスタンスを返す」ことで、比較演算子
===
による等値判定ができるようになります。例えば、値オブジェクトをMap
の key として使えます。 - 状況に応じて異なるサブクラスのインスタンスを返すなどの、柔軟な生成戦略を取ることができます。例えば、ストラテジーパターンを適用できます。
文字列への埋め込みや JSON.stringify
のために toString
と toJson
メソッドを定義しています。
public toString(): string {
return this._grams.toString()
}
public toJson(): number {
return this._grams
}
これらのメソッドは必ず定義しておくことをお勧めします。
でないと、デバッグログの出力が「[object Object]
」となってしまって何の役にも立たなかったり、ユーザーに提示すべきでない内部的な情報がレスポンスに含まれてしまったりする可能性があります。
値の操作をする例として、加減算を行うメソッドを実装してみました。
public add(weight: Weight): Weight {
return new Weight(this._grams + weight._grams)
}
public addGrams(weight: number): Weight {
return new Weight(this._grams + weight)
}
public subtract(weight: Weight): Weight {
return new Weight(this._grams - weight._grams)
}
public subtractGrams(weight: number): Weight {
return new Weight(this._grams - weight)
}
自身の状態を変更することなく、新しいオブジェクトを生成して返すのがポイントです。
いちおう一致比較メソッドも作成してみました。
public isEqual(weight: Weight): boolean {
return this._grams === weight._grams
}
実用には大小比較するメソッドも必要になってくると思いますし、他のメソッドもまったく不足していることでしょう。
ですが、値オブジェクトのクラスを新たに作成するたびに、予め "使いそうな" メソッドを想定して一通り揃えておくというのは作業が大変ですし、頑張って用意したのに結局使われなかったということにもなりかねません。
getter、toString
、toJson
のみから始めて、必要になったそのときにメソッドを追加していくのが良いと思います。
値オブジェクトのデメリット
メリットが多い値オブジェクトですが、最後にデメリットにも少し触れておきたいと思います。
ひとつはプリミティブ型と比べた場合のパフォーマンスです。
小さいとはいえクラスインスタンスを作るので、どうしてもメモリ格納効率やアクセス速度は落ちてしまいます。
とはいえ、値オブジェクトのパフォーマンスが問題になるような処理は、そんなに多くはないと思います。
例えば、何万回とループする可能性のある処理の中では値オブジェクトを生成せずにプリミティブ型を使うなど、局所的な最適化で対応することもできるでしょう。
もうひとつのデメリットは、コーディングが面倒なことです。
私の経験でも「この型はほとんど他では使わないし、値オブジェクトにせずプリミティブで……」という妥協をしてしまうことがありました。
ただし、最近はエディターに統合された AI がボイラープレートな部分を予想してどんどん補完してくれるので、かなり面倒さは減ってきたように思います。
今回のサンプルの Weight
クラスを作る際も、結構な部分を AI のサジェストで省力化することができました。
まとめ
いかがでしたでしょうか。
値オブジェクトの魅力が少しでも伝われば幸いです。
ライブラリやツールのような小規模なプロジェクトに用いるのは逆効果かもしれませんが、ある程度の規模のアプリケーションであれば、値オブジェクトにできそうなものは値オブジェクトにしておくと、メンテナンス性が大きく向上するはずです!