TypeScriptで単一の値を持つ型安全なバリューオブジェクトを作りたい
概要
ビルトインされている抽象的な型をそのまま使うのではなく、ラップして具体的な型を作りたいことがしばしばあります。
ここではそのような具体的な型をバリューオブジェクトと呼びます。
例えば本のISBNを表すバリューオブジェクトはJavaだと次のように書けます。
public class Isbn {
private final String value;
public Isbn(String value) {
this.value = value;
}
}
これの何が嬉しいのかというと、Isbn
クラスと同じように単一のvalue
を持つBookTitle
というクラスがあったとして、次のように誤った代入をしようとするとコンパイルエラーになってバグを早期に検出できます。
Isbn isbn = new BookTitle("ワールドトリガー(24)");
$ javac *.java
App.java:3: エラー: 不適合な型: BookTitleをIsbnに変換できません:
Isbn isbn = new BookTitle("ワールドトリガー(24)");
^
エラー1個
抽象的なString
型をそのまま使っているとコンパイルエラーにはなりません。
// コンパイルが通ってしまう
String isbn = "ワールドトリガー(24)";
この記事では前述のような単一の値を持つシンプルなバリューオブジェクトをTypeScriptではどのように定義すると型安全になるのかを考えます。
結論
まず結論を述べるとバリューオブジェクトが保持する単一の値をprivate
メンバーとして定義するのが良さそうです。
class Isbn {
constructor(private readonly value: string) { }
}
class BookTitle {
constructor(private readonly value: string) { }
}
const isbn: Isbn = new Isbn("978-4-08-882770-4");
const bookTitle: BookTitle = new BookTitle("ワールドトリガー(24)");
// コンパイルエラーになってくれる
const mistake: Isbn = new BookTitle("ワールドトリガー(24)");
期待通り誤った代入がコンパイルエラーになってくれます。
$ tsc demo.ts
demo.ts:12:7 - error TS2322: Type 'BookTitle' is not assignable to type 'Isbn'.
Types have separate declarations of a private property 'value'.
12 const mistake: Isbn = new BookTitle("ワールドトリガー(24)");
~~~~~~~
Found 1 error.
結論に至る道
次のドキュメントに書かれているようにTypeScriptにおける型の互換性は構造的部分型(structural subtyping)に基づいています。
そのため次のTypeScriptコードはコンパイルが通ります(通ってほしくない)。
class Isbn {
constructor(public readonly value: string) { }
}
class BookTitle {
constructor(public readonly value: string) { }
}
// コンパイルが通ってしまう
const mistake: Isbn = new BookTitle("ワールドトリガー(24)");
この結果を受けてTypeScriptでは私が求めるバリューオブジェクトは作れないのではと危惧しましたが、そうではありませんでした。
先程紹介したドキュメントを読み進めていくと「Private and protected members in classes」というセクションに次のように書かれていました。
Private and protected members in a class affect their compatibility. When an instance of a class is checked for compatibility, if the target type contains a private member, then the source type must also contain a private member that originated from the same class. Likewise, the same applies for an instance with a protected member. This allows a class to be assignment compatible with its super class, but not with classes from a different inheritance hierarchy which otherwise have the same shape.
クラスがprivate
メンバーを持つ場合、互換性のある型だと判断するためには同じクラスに由来するprivate
メンバーを持つ必要があるとのことです。
次のTypeScriptコードはIsbn
クラスとBookTitle
クラスの両方ともがvalue
というprivate
メンバーを持っていますが、Isbn
クラスのvalue
はIsbn
クラス由来、BookTitle
クラスのvalue
はBookTitle
クラス由来なため互換性のある型だとは判断されません。
class Isbn {
constructor(private readonly value: string) { }
}
class BookTitle {
constructor(private readonly value: string) { }
}
// コンパイルエラーになる
const mistake: Isbn = new BookTitle("ワールドトリガー(24)");
このルールはprotected
メンバーにも適用されます。
これで私が求めるバリューオブジェクトを定義できることがわかりました。
バリューオブジェクトに基底クラスを導入する際の注意点
バリューオブジェクトにおける一般的な属性や振る舞いをまとめた基底クラスの導入を考えたとします(個人的には今はもうやらなくなりましたが昔はやっていたこともありました)。
その際、次のようにしてしまうとvalue
がValueObjectBase
由来になってしまうのでIsbn
型の変数へBookTitle
クラスの値を代入できてしまいます。
abstract class ValueObjectBase {
constructor(private readonly value: string) { }
}
class Isbn extends ValueObjectBase {
constructor(value: string) { super(value); }
}
class BookTitle extends ValueObjectBase {
constructor(value: string) { super(value); }
}
const isbn: Isbn = new Isbn("978-4-08-882770-4");
const bookTitle: BookTitle = new BookTitle("ワールドトリガー(24)");
// コンパイルエラーになってくれない
const mistake: Isbn = new BookTitle("ワールドトリガー(24)");
これをコンパイルエラーにしたい場合、Isbn
クラスとBookTitle
クラスそれぞれにprivate
メンバーを定義すれば良いです。
定義するpriavte
メンバーの型や値はなんでも良いです。
abstract class ValueObjectBase {
constructor(private readonly value: string) { }
}
class Isbn extends ValueObjectBase {
// なんでも良いのでとりあえずprivateメンバーを定義する
private readonly unused = 0;
constructor(private readonly _value: string) { super(_value); }
}
class BookTitle extends ValueObjectBase {
// なんでも良いのでとりあえずprivateメンバーを定義する
private readonly unused = 0;
constructor(value: string) { super(value); }
}
const isbn: Isbn = new Isbn("978-4-08-882770-4");
const bookTitle: BookTitle = new BookTitle("ワールドトリガー(24)");
// コンパイルエラーになる
const mistake: Isbn = new BookTitle("ワールドトリガー(24)");
これで期待通り誤った代入がコンパイルエラーにはなりますが、使いもしないメンバーの存在はとても気持ち悪いのでオススメはできません。
以上です。
Discussion