白猫のメモ帳

C#とかJavaとかJavaScriptとかHTMLとか機械学習とか。

構造的型付けとValueObject

こんにちは。
急に春を通り越して夏なんですが、いったいどうしたんでしょうか。
桜はいつ見ればいいですか?

さて、ちょっとコードを書いてて「ん?」ってなったので。

ValueObjectを作ろう

オブジェクト指向ではValueObjectという概念がときどき使われます。DDDの文脈だと特によく出てきやすいです。
ざっくりいうと参照ではなく値で等価性を判断するオブジェクトです。値オブジェクトとも言いますね。

class Address {
  constructor(public prefecture: string, public city: string, public street: string) {}
  equals(other: Address) {
    return this.prefecture === other.prefecture && this.city === other.city && this.street === other.street;
  }
}

const address1 = new Address('Tokyo', 'Shinjuku', '1-1-1');
const address2 = new Address('Tokyo', 'Shinjuku', '1-1-1');
console.log(address1.equals(address2));  // true

コードとかに便利だよね

例えばユーザCDと商品CDというどちらも数値型のコード体系があって、それを単にnumber型で扱ってしまうとちょっと危ないよねとかいうときにこんな風に使うわけですね。

type UserCD = { value : number };
type ItemCD = { value : number };
type User = { cd : UserCD };

const userCd = { value: 1 } as UserCD;
const itemCd = { value: 2 } as ItemCD;

function getUser(userCd: UserCD) {
  return { cd: userCd } as User;
}

const user1 = getUser(userCd);
const user2 = getUser(itemCd);  // コンパイルエラーにならない
console.log(user2, user2);  // { cd: { value: 2 } } { cd: { value: 2 } }

あれ、想像に反してなんかコンパイルエラーが出ません。
あ、もしかしてtypeってエイリアスだから同じ定義だとダメなのかなと思ってclassにしてみます。

class Doller {
  constructor(public value: number) {}
}
class Yen {
  constructor(public value: number) {}
}

const doller = new Doller(1);
const yen = new Yen(150);

function add(a: Doller, b: Doller) {
    return new Doller(a.value + b.value);
}

const sum = add(doller, yen);  // コンパイルエラーにならない
console.log(sum);  // Doller { value: 151 }

全然ダメです。ValueObjectの意味はどこへ…。

TypeScriptは構造的型付け

ここまでのコードをJavaC#に書き直してみると、想定通りにコンパイルエラーになります。
TypeScriptでそうならないのは型付けの仕方が違うからです。

JavaC#などは「名前的型付け(nominal typing)」というスタイルをとっています。
これはその名の通り名前に基づいて型が区別されるという方式です。型の名前が異なれば互換性はないという、宣言的な方式です。

class Dog {}
class Cat {}

Dog dog = new Dog();
Cat cat = new Cat();
dog = cat; // コンパイルエラーになる

TypeScriptやGoなどは「構造的型付け(structural typing)」というスタイルをとっています。
これは名前的型付けとは異なり、実際の構造を解析して型が区別されるという方式です。つまり名前が異なっていたり、継承関係などがなくても互換性を持つことができます。

class Dog {}
class Cat {}

let dog = new Dog();
let cat = new Cat();
dog = cat; // コンパイルエラーにならない

先ほどの例だと、UserCDとItemCD、DollerとYenはそれぞれ同じ構造なので型として区別されていないということになります。
なんなら全部がnumber型のvalueなので、こんなことすらできてしまいます。これはひどい

const sum = add(doller, userCd);  // コンパイルエラーにならない
console.log(sum);  // Doller { value: 2 }

回避方法

不透明型(opaque type)もしくは幽霊型(phantom type)という手段を使うと良さそうです。
こんな感じでリテラル型を定義すると型が一致しないとみなされて区別ができます。

class Doller {
  readonly opaqueSymbol: "Doller";
  constructor(public value: number) {}
}
class Yen {
  readonly opaqueSymbol: "Yen";
  constructor(public value: number) {}
}

const doller = new Doller(1);
const yen = new Yen(150);

function add(a: Doller, b: Doller) {
    return new Doller(a.value + b.value);
}

const sum = add(doller, yen);  // ちゃんとコンパイルエラーになる
console.log(sum);

typeでやるならこういうIntersection型でも良いみたい。
Github Copilotさんが_brandって名前でサジェストしてきたので、この名前の方がいいのかな。

type UserCD = { value : number } & { readonly _brand: unique symbol };
type ItemCD = { value : number } & { readonly _brand: unique symbol };

型定義難しい

これ実はオーバーロードでやろうとすると、内部的にinstanceofとか使ってうまいこと区別できちゃうのでまたちょっと混乱します。
こうするとコンパイルエラーにはならないけど、実行時にエラーが出ます。

function add(a: Doller, b: Doller) : Doller;
function add(a: Yen, b: Yen) : Yen;
function add(a: Doller | Yen, b: Doller | Yen) {
  if (a instanceof Doller && b instanceof Doller) {
    return new Doller(a.value + b.value);
  } else if (a instanceof Yen && b instanceof Yen) {
    return new Yen(a.value + b.value);
  } else {
    throw new Error('Different currency');
  }
}

名前的型付けと構造的型付けの比較は今度改めて確認しようかなー。