每次接手别人的 TypeScript 项目,最让我崩溃的就是满屏的 type User = Record<string, any> 或者直接用 interface 裸奔的“贫血模型”。所有业务逻辑都堆在所谓的 Service 层,实体对象纯粹沦为数据的搬运工。更恶心的是,由于缺乏约束,代码里随处可见对属性的直接修改,导致运行时的状态变更完全成了黑盒,排查个 Bug 像是在做侦探推理。

在领域驱动设计(DDD)中,值对象(Value Object)是构建强约束业务实体不可或缺的基石。值对象没有唯一标识(Identity),它完全由其属性定义,并且必须是不可变的(Immutable)。

如果在 TypeScript 里只用 interface 定义数据,是没办法封装校验逻辑和业务行为的。要解决这个问题,最干脆的做法就是利用 TypeScript 的 class,结合 readonly 关键字与 private constructor,强行约束对象的创建和修改行为。

下面是我们项目里实际使用的一个通用 ValueObject 基类以及具体的 Email 值对象实现:

interface ValueObjectProps {
  [index: string]: any;
}

export abstract class ValueObject<T extends ValueObjectProps> {
  protected readonly props: T;

  protected constructor(props: T) {
    this.props = Object.freeze(props);
  }

  public equals(vo?: ValueObject<T>): boolean {
    if (vo === null || vo === undefined) {
      return false;
    }
    if (vo.props === undefined) {
      return false;
    }
    return JSON.stringify(this.props) === JSON.stringify(vo.props);
  }
}

interface EmailProps {
  value: string;
}

export class Email extends ValueObject<EmailProps> {
  // 私有构造函数,防止外部直接 new Email() 绕过校验
  private constructor(props: EmailProps) {
    super(props);
  }

  get value(): string {
    return this.props.value;
  }

  private static isValidEmail(email: string): boolean {
    const re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
    return re.test(String(email).toLowerCase());
  }

  // 统一的工厂方法,强行保证只有合法的 Email 才能被创建出来
  public static create(email: string): Email {
    if (!email) {
      throw new Error("Email 不能为空");
    }
    const cleanEmail = email.trim();
    if (!this.isValidEmail(cleanEmail)) {
      throw new Error("非法的 Email 格式");
    }
    return new Email({ value: cleanEmail });
  }

  // 值对象自带的业务行为
  public getDomain(): string {
    return this.props.value.split('@')[1];
  }
}

有了这种约束后,外部代码再也没办法写出类似 user.email = "invalid-email" 这种破坏状态一致性的烂代码。任何属性的变更都必须通过创建新的值对象来显式表达。虽然写起来稍微折腾了一点,但它极大地降低了系统运行时的心智负担。

不要为了图一时方便,就把所有对象都降级成无状态的 DTO。在 TypeScript 项目里,你们是怎么处理复杂业务实体的约束和校验的?