TypeScript での値オブジェクト実装
ドメイン駆動で中心になる要素の1つ、値オブジェクト(Value Object)についてどう実装したら良いか考えてみました。
値オブジェクトの特徴
値オブジェクト主な特徴は以下のようになります。
- 状態を不変に保つことができる(不変性)
- 値オブジェクト同士で比較できる(等価性)
- その値だけに関心がある
- 他の影響を受けない/与えない
値オブジェクトはそれぞれを説明的に表現でき、例えば「名前値オブジェクト」であれば名前を表すという単なる文字列とは区別して扱えるようになります。
TypeScript におけるクラス
TypeScript は型やクラスなどが定義されているものの、かなり緩い言語です。“定義的に互換性があるならいけるでしょ”というダックタイピングの側面があります。
例として以下のように定義したとして
class RealNameValueObject {
readonly value: string;
constructor(value: string) {
this.value = value;
}
}
class NickNameValueObject {
readonly value: string;
constructor(value: string) {
this.value = value;
}
}
function say1(name: NickNameValueObject) {
console.log(`こんにちは ${name.value} さん!`);
}
/** クラスではなく type 定義 */
function say2(name: { value: string }) {
console.log(`こんにちは ${name.value} さん!`);
}
実際に使うと
const realName = new RealNameValueObject("太郎");
// 代入できてしまう
const nickName: NickNameValueObject = realName;
// そっくりなクラスのインスタンスを引数に指定できてしまう
say1(realName);
say2(realName);
のように期待してないであろうインスタンスも指定できてしまいます。
これは RealNameValueObject と NickNameValueObject どちらのクラスも「文字列型の value プロパティがある」という点で一致しているからです。type 定義したパターンでも通るように、クラスの継承関係はみられていません。
普段の実装においてはガチガチな型に縛られずにコーディングできる分、手抜き 簡素に記述できて大変便利な言語ですが、値オブジェクトを定義する上では一工夫要りそうです。
対策
値オブジェクトの種類ごとに固有の定義をすることで個々を区別できるようにします。
さらに symbol型 が役に立ちます。
実装
拡張しやすいようにベースとなるジェネリック型の ValueObject クラスを用意して、それを継承して使います。
共通クラス
import { InvalidValueError } from "./invalid-value-error";
declare const opaqueSymbol: unique symbol;
export abstract class ValueObject<TSymbol extends string> {
readonly [opaqueSymbol]: TSymbol;
readonly value: string;
constructor(value: string) {
this.validation(value);
this.value = value;
}
protected validation(value?: string): void {
if (typeof value !== "string" || value === "") {
throw new InvalidValueError(value);
}
}
equals(other: ValueObject<TSymbol>): boolean {
return this === other || this.value === other.value;
}
toJSON(): string {
return this.value;
}
}
toJSON()
を実装しておくと JSON.stringify() を利用するときに単純な文字列として JSON 形式に変換できるので便利です。
検証用のエラークラスも用意します。
export class InvalidValueError extends Error {
constructor(public readonly value: any, message?: string) {
super(message ?? "無効な値です。");
this.name = "InvalidValueError";
}
}
個々の実装
シンプルな実装
本名やニックネームを表す値オブジェクトは基本と同じルールでOKとして最もシンプルな実装例です。
import { ValueObject } from "./value-object";
export class RealName extends ValueObject<"RealName"> {}
import { ValueObject } from "./value-object";
export class NickName extends ValueObject<"NickName"> {}
NOTE: TSymbol の値
ValueObject<TSymbol extends string>
で指定できる TSymbol は全体でユニークになる文字列なら何でもよいです。実装例では分かりやすくクラス名と同名にしていますが ValueObject<"user/RealName">
や ValueObject<"27b8ccdc-289a-4cab-8337-8a54f5314b5f">
のように意図性が無い文字列を利用して区別しても成立します。後者は CLSID チックですね。
ビジネスルールの実装
個別のビジネスルールを適用するときは検証ロジックをオーバーライドして対応します。ログイン名は20文字以内の半角英数字とした実装例です。
import { InvalidValueError } from "./invalid-value-error";
import { ValueObject } from "./value-object";
const MaxLength = 20;
const ValuePattern = /^[a-zA-Z0-9]+$/;
export class LoginName extends ValueObject<"LoginName"> {
protected validation(value?: string): void {
if (typeof value !== "string") {
throw new InvalidValueError(value);
}
if (value.length > MaxLength) {
throw new InvalidValueError(value, `${MaxLength}文字以内`);
}
if (!ValuePattern.test(value)) {
throw new InvalidValueError(value, "不正な値");
}
}
}
InvalidValueError の代わりに独自のエラークラス、例えば InvalidLoginNameError を作成してより細かく区別するのも良いと思います。
IDの実装
UUID を採用しているなど共通化できるのであれば ValueObject クラスを継承した ID 用のベースクラスを作ると便利そうです。
import { isUUID } from "class-validator";
import { InvalidValueError } from "./invalid-value-error";
import { ValueObject } from "./value-object";
export abstract class EntityUUID<TSymbol extends string> extends ValueObject<TSymbol> {
protected validation(value?: string): void {
if (!isUUID(value, "4")) {
throw new InvalidValueError(value, "不正なID");
}
}
}
あとは UUID を採用している ID たちの値オブジェクトをクラス定義します。
import { EntityUUID } from "./entity-uuid";
export class UserId extends EntityUUID<"UserId"> {}
export class LoginId extends EntityUUID<"LoginId"> {}
export class TenantId extends EntityUUID<"TenantId"> {}
// etc...
値オブジェクトを使う
特徴への対処
不変性
実際の文字列としての値は .value
プロパティを通して取得できますが、readonly をつけているので変更はできません。インスタンス化したら、固有の値オブジェクトとして使えそうです。
const realName = new RealName("おそ松");
console.log(realName.value); // 取得はできる
realName.value = "おそまつ"; // 代入はできない
不変性の破壊
Object.assign() を使うと、強引に value 値を書き換えられる点は注意したいです。
TypeScript というよりはベース言語の JavaScript に備わる組み込み機能なので止む無しですが、対策はコーディングルールで縛る、コードレビューで見定める等でしょうか。
等価性
値オブジェクトとしての等価性は ValueObject で実装した equals()
メソッドを通して行います。
const real1 = new RealName("カラ松");
const real2 = new RealName("カラ松");
const real3 = new RealName("チョロ松");
console.log(real1 === real2); // false - インスタンスとしては別物
console.log(real1.equals(real2)); // true - 値オブジェクトとしては等価
console.log(real1.equals(real3)); // false - カラ松くんとチョロ松くんは別人よ
ビジネスルールの順守
値オブジェクトをインスタンス化する時点でチェックして不正な値は弾いているので、インスタンス化できているということは定義したルールに則った値オブジェクト=特徴を説明できる値であるといえそうです。
// インスタンス化できる = UUIDな値である
const userId1 = new UserId("fc42bf16-cee4-4cc0-905e-24a025502c37");
// InvalidValueError 例外で生成できない
const userId2 = new UserId("12345");
// インスタンス化できる
const login1 = new LoginName("u0524");
// value プロパティは前提(1~20文字の半角小文字英数字)が成立している
console.log(login1.value);
// InvalidValueError 例外で生成できない
const login2 = new LoginName("foo bar");
値オブジェクトの違いを認識できるか
違う値オブジェクトに代入できるか
冒頭でサンプルに出したようにダックタイピングな抜け道に対処できているか試してみます。
const real = new RealName("十四松");
const nick: NickName = real;
console.log(nick);
メッセージ 型 'RealName' を型 'NickName' に割り当てることはできません。 ts(2322)
が出てちゃんと代入を拒否してくれました。
as
をつけて大着をしようとしても安易には納得してくれません。ガードは固めでしょう。
const real = new RealName("十四松");
const nick: NickName = real as NickName;
unknown 経由による強硬突破
エラーの説明にありますが、 real as unknown as NickName
のように一度 unknown 型を経由すれば強引に突破できてしまいます。ただし、ここまで来ると実装の意味不明さが目立つのでコードレビュー等で引っかけやすくなるのではと思います。
違う値オブジェクトを渡すと…
メソッド引数などへ渡すときについても試してみます。
const real = new RealName("十四松");
function say(name: NickName) {
console.log(`こんにちは ${name.value} さん!`);
}
say(real);
引数への渡しでも違いを認識できています。
誤代入や引数ミスに耐性がありつつ、短いコードで横展開しやすいクラスに仕上がりました。
おわりに
数値を扱う場合はどうなのかとかはありますが、値オブジェクトらしい基本的な振る舞いを実装へ落とし込めれたように思います。
実際に使うとなるとエンティティに組み込むことになりますが、長くなるので別の記事にします。
それではまた。
Discussion