Oteto Blogのロゴ

【TypeScript】Value Object(値オブジェクト)を実装する

TypeScriptでDDDのValue Objectを実装したくなった。

1. 抽象クラスの実装

declare const opaqueSymbol: unique symbol;

export abstract class BaseValueObject<T extends string, K> {
  private readonly [opaqueSymbol]: T;
  readonly value: K;

  constructor(value: K) {
    if (!this.isValid(value)) {
      throw new Error(this.getErrorMessage());
    }
    this.value = value;
  }

  equals(other: BaseValueObject<T, K>): boolean {
    return this === other || this.value === other.value;
  }

  protected abstract isValid(value: K): boolean;

  protected abstract getErrorMessage(): string;
}
  • symbolを利用して等価性を持たせる
  • コンストラクタ内で抽象メソッドisValidを呼び、初期化時点でその値オブジェクトの値がルールに沿っているかを判定

2. 値オブジェクトの実装

import { BaseValueObject } from './base-value-object';
import { isUUID } from 'class-validator';

export class Uuid extends BaseValueObject<'Uuid', string> {
  protected isValid(value: string): boolean {
    return isUUID(value);
  }

  protected getErrorMessage(): string {
    return 'UUIDの形式が不正です';
  }
}

先ほどの抽象クラスを継承し、例としてUUID用の値オブジェクトを実装してみた。

3. 値オブジェクトの利用

import { randomUUID } from 'crypto';

const uuid1 = new Uuid(randomUUID());
console.log(uuid1.value); // => xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

const uuid2 = new Uuid('foo'); // => Error: UUIDの形式が不正です

余談

zodで使う場合

const uuidSchema = z.string().transform((v) => new Uuid(v));

TypeORMで使う場合

TypeORMのエンティティクラスで値オブジェクトの型を持つカラムを定義したい場合。

import { ValueTransformer } from 'typeorm';
import { BaseValueObject } from './base-value-object';

interface NewableClass<T> {
  new (...args: any[]): T;
}

export function ValueObjectTransformer<T extends string, K>(
  ValueObjectClass: NewableClass<BaseValueObject<T, K>>,
): ValueTransformer {
  return {
    from: (value: K): BaseValueObject<T, K> => new ValueObjectClass(value),
    to: ({ value }: BaseValueObject<T, K>): K => value,
  };
}

@Columnデコレーターのtransformerオプションで実現できるので、それに指定するオブジェクトを共通化すべくValueObjectTransformer関数を実装。

import { Column, Entity } from 'typeorm';
import { BaseEntity } from '../../common/base.entity';
import { Token } from '../../token/token.value-object';
import { ValueObjectTransformer } from '../../common/value-objects/value-object-transformer';

@Entity('xxx_entities')
export class xxxEntity {
  @Column({
    type: 'uuid',
    transformer: ValueObjectTransformer(Uuid),
  })
  uuid: Uuid;
}

あとは対象カラムのtransformerに指定すればOK。これでSQLクエリの実行前後で素の値←→値オブジェクトの変換が行われる。

class-validatorで使う場合

NestJSにおけるDTOクラスなどでありがちな、リクエストとして受け取ったプリミティブ値を値オブジェクトに変換したい場合。

import { Allow } from 'class-validator';
import { Token } from '../../token/token.value-object';
import { Transform } from 'class-transformer';

export class xxxDTO {
  @Allow()
  @Transform(({ value }) => new Uuid(value), { toClassOnly: true })
  readonly uuid: Uuid;
}

対象のプロパティに@Transformデコレータを付与することで実現できる。

また今回のUuidクラスのようにドメインルールの判定をコンストラクタ内で行っている場合、class-validatorによるバリデーションは必要ないので@Allowデコレータを付与する。