⛄️

TypeScriptで単一の値を持つ型安全なバリューオブジェクトを作りたい

2021/12/12に公開

概要

ビルトインされている抽象的な型をそのまま使うのではなく、ラップして具体的な型を作りたいことがしばしばあります。
ここではそのような具体的な型をバリューオブジェクトと呼びます。

例えば本の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)に基づいています。

https://www.typescriptlang.org/docs/handbook/type-compatibility.html

そのため次の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クラスのvalueIsbnクラス由来、BookTitleクラスのvalueBookTitleクラス由来なため互換性のある型だとは判断されません。

class Isbn {
    constructor(private readonly value: string) { }
}

class BookTitle {
    constructor(private readonly value: string) { }
}

// コンパイルエラーになる
const mistake: Isbn = new BookTitle("ワールドトリガー(24)");

このルールはprotectedメンバーにも適用されます。

これで私が求めるバリューオブジェクトを定義できることがわかりました。

バリューオブジェクトに基底クラスを導入する際の注意点

バリューオブジェクトにおける一般的な属性や振る舞いをまとめた基底クラスの導入を考えたとします(個人的には今はもうやらなくなりましたが昔はやっていたこともありました)。

その際、次のようにしてしまうとvalueValueObjectBase由来になってしまうので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