OrderId, địa chỉ email và khoảng cách đều có thể được biểu diễn bằng kiểu nguyên thủy. Điều đó không có nghĩa ta được phép thay thế chúng cho nhau. Truyền UserId vào loadOrder, hiển thị một email chưa kiểm tra, hay cộng mili giây với giây đều là lỗi nghiệp vụ dù JavaScript vẫn thực thi được.

TypeScript bắt được nhiều lỗi, nhưng hệ kiểu cấu trúc của nó hỏi hai giá trị có cùng hình dạng hay không. Hai alias của string có hình dạng hoàn toàn giống nhau, nên compiler không thể suy ra chúng mang ý nghĩa khác nhau. Branded type gắn một dấu hiệu chỉ tồn tại lúc biên dịch vào primitive hoặc object. Khi đi cùng smart constructor, dấu hiệu ấy trở thành bằng chứng: giá trị đã vượt qua một bước kiểm tra và đủ điều kiện tham gia thao tác miền cụ thể.

Brand không phải cơ chế bảo mật runtime và không tự làm dữ liệu xấu biến mất. Nó là cách nhẹ nhàng để thể hiện trạng thái tin cậy và chưa tin cậy ngay trong chữ ký hàm. Đặt đúng ở các ranh giới, brand ngăn được cả nhóm lỗi truyền nhầm giá trị mà không buộc mọi khái niệm phải trở thành class.

Structural typing cần bằng chứng của miền

TypeScript dùng structural typing: khả năng tương thích dựa trên các member thay vì tên khai báo. Cách này thuận tiện khi ghép object và thư viện, nhưng với alias của primitive thì khác biệt chỉ còn là tài liệu:

ts
type UserId = string;
type OrderId = string;

declare function loadOrder(id: OrderId): Promise<Order>;

const userId: UserId = 'usr_7f2a';
loadOrder(userId); // Vẫn biên dịch vì cả hai alias đều là string.

Brand giao kiểu gốc với một property có key là unique symbol. Code bên ngoài module định nghĩa không thể vô tình cung cấp property đó, còn hai giá trị mang brand khác nhau không thể gán cho nhau dù biểu diễn runtime giống hệt nhau.

ts
declare const userIdBrand: unique symbol;
declare const orderIdBrand: unique symbol;

export type UserId = string & {
  readonly [userIdBrand]: 'UserId';
};

export type OrderId = string & {
  readonly [orderIdBrand]: 'OrderId';
};

declare function loadOrder(id: OrderId): Promise<Order>;
declare const userId: UserId;

// @ts-expect-error UserId không chứng minh được đây là OrderId hợp lệ.
loadOrder(userId);

Các property dùng symbol chỉ tồn tại trong hệ kiểu vì symbol được khai báo chứ không được gắn lên object chuỗi. Ở runtime, UserId vẫn là string bình thường: phép so sánh, key của map, JSON và database driver đều hoạt động như cũ. Vì vậy branded primitive thường được gọi là giá trị opaque hoặc nominal-like. Bên sử dụng thấy và dùng được biểu diễn, nhưng không thể tạo kiểu đã tin cậy bằng phép gán thông thường.

Nên giữ brand symbol ở phạm vi private của module. Hãy export branded type và các constructor được chấp thuận, không export key để caller có thể tự chế object tương thích về cấu trúc. Tính opaque đến từ thiết kế module chứ không chỉ từ một mẹo của type system.

Note

Brand phân biệt ý nghĩa, nhưng bản thân nó không kiểm tra dữ liệu. string & Brand chỉ đáng tin khi mọi con đường hợp lệ để tạo kiểu ấy đều thực thi đúng invariant đã hứa.

Smart constructor biến kiểm tra thành bằng chứng

Phép cast cần thiết để tạo branded primitive nên nằm ngay cạnh logic validation. Một result type có discriminator giúp lỗi input dự kiến trở nên tường minh, đồng thời tránh ném exception trong luồng parse thông thường.

ts
type ValidationError = {
  code: 'required' | 'format' | 'range';
  message: string;
};

type Result<T> =
  | { ok: true; value: T }
  | { ok: false; errors: ValidationError[] };

declare const emailBrand: unique symbol;
declare const nonEmptyBrand: unique symbol;

export type EmailAddress = string & {
  readonly [emailBrand]: 'EmailAddress';
};

export type NonEmptyString = string & {
  readonly [nonEmptyBrand]: 'NonEmptyString';
};

export function emailAddress(input: unknown): Result<EmailAddress> {
  if (typeof input !== 'string') {
    return {
      ok: false,
      errors: [{ code: 'format', message: 'Email phải là chuỗi' }],
    };
  }

  const normalized = input.trim().toLowerCase();
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
    return {
      ok: false,
      errors: [{ code: 'format', message: 'Định dạng email không hợp lệ' }],
    };
  }

  return { ok: true, value: normalized as EmailAddress };
}

export function nonEmptyString(input: unknown): Result<NonEmptyString> {
  if (typeof input !== 'string' || input.trim().length === 0) {
    return {
      ok: false,
      errors: [{ code: 'required', message: 'Không được bỏ trống giá trị' }],
    };
  }

  return { ok: true, value: input.trim() as NonEmptyString };
}

Các assertion trong ví dụ là có chủ đích và nằm trong phạm vi hẹp. TypeScript không thể chứng minh một regular expression thiết lập EmailAddress, nên constructor khép khoảng trống đó sau khi đã kiểm tra ở runtime. Code ứng dụng không cần lặp lại regex hoặc assertion. Nếu chính sách email thay đổi, một constructor cùng bộ test của nó sẽ định nghĩa contract mới.

Factory của aggregate giờ có thể yêu cầu bằng chứng thay vì kiểm tra lại những primitive rời rạc:

ts
type Customer = Readonly<{
  id: UserId;
  displayName: NonEmptyString;
  email: EmailAddress;
}>;

function registerCustomer(
  id: UserId,
  displayName: NonEmptyString,
  email: EmailAddress,
): Customer {
  return { id, displayName, email };
}

Nhờ vậy model lõi khó rơi vào tổ hợp trạng thái bất khả thi hơn. Vị trí normalization cũng rõ ràng. Chẳng hạn, chuyển email thành chữ thường có thể phù hợp với policy định danh của sản phẩm nhưng sai với một external identifier phân biệt hoa thường. Smart constructor khiến policy ấy dễ tìm và đánh giá, thay vì phân tán trong nhiều controller.

Warning

Đừng export unsafeEmail(value: string): EmailAddress chỉ để tiện tay. Khi constructor không kiểm tra trở thành lối đi phổ biến, brand chỉ còn nghĩa “ai đó đã assertion”, còn mọi chữ ký phía sau đều phóng đại mức bảo đảm.

ID và đơn vị đo không được nối nhầm dây

Identifier là use case đơn giản nhưng đem lại giá trị cao. Format của chúng có thể trùng nhau, nhất là khi database dùng UUID cho mọi bảng. Brand riêng ngăn một UUID hợp lệ của entity này vô tình chọn entity khác.

ts
declare const userIdBrand: unique symbol;
declare const orderIdBrand: unique symbol;

type UserId = string & { readonly [userIdBrand]: true };
type OrderId = string & { readonly [orderIdBrand]: true };

const idPattern = /^(usr|ord)_[a-z0-9]{8}$/;

function userId(input: unknown): Result<UserId> {
  if (typeof input !== 'string' || !idPattern.test(input) || !input.startsWith('usr_')) {
    return {
      ok: false,
      errors: [{ code: 'format', message: 'User ID không hợp lệ' }],
    };
  }
  return { ok: true, value: input as UserId };
}

function orderId(input: unknown): Result<OrderId> {
  if (typeof input !== 'string' || !idPattern.test(input) || !input.startsWith('ord_')) {
    return {
      ok: false,
      errors: [{ code: 'format', message: 'Order ID không hợp lệ' }],
    };
  }
  return { ok: true, value: input as OrderId };
}

Đơn vị đo tạo ra cùng một vấn đề trên kiểu số. Một quantity brand dùng lại được sẽ ghi nhận đơn vị mà vẫn giữ trải nghiệm của number:

ts
declare const quantityBrand: unique symbol;

type Quantity<Unit extends string> = number & {
  readonly [quantityBrand]: Unit;
};

type Meters = Quantity<'meters'>;
type Kilometers = Quantity<'kilometers'>;
type Seconds = Quantity<'seconds'>;
type Milliseconds = Quantity<'milliseconds'>;

function meters(value: number): Result<Meters> {
  if (!Number.isFinite(value) || value < 0) {
    return {
      ok: false,
      errors: [{ code: 'range', message: 'Số mét phải hữu hạn và không âm' }],
    };
  }
  return { ok: true, value: value as Meters };
}

function kilometersToMeters(value: Kilometers): Meters {
  return (value * 1_000) as Meters;
}

function elapsedSeconds(start: Milliseconds, end: Milliseconds): Seconds {
  return ((end - start) / 1_000) as Seconds;
}

declare function scheduleRetry(delay: Milliseconds): void;
declare const timeout: Seconds;

// @ts-expect-error Phải đổi giây sang mili giây trước khi lên lịch.
scheduleRetry(timeout);

Phép toán là một góc sắc. Các operator trả về number thường vì TypeScript không biết phép nhân giữ nguyên, loại bỏ hay biến đổi đơn vị. Hãy gom số học vào hàm miền có tên rõ như kilometersToMeterselapsedSeconds; assertion nhỏ bên trong mô tả chính xác phép biến đổi thứ nguyên. Với code vật lý có nhiều đơn vị ghép, một thư viện chuyên về units phù hợp hơn việc mở rộng mãi tập cast tự viết.

Ranh giới tin cậy xóa và khôi phục brand

Payload từ mạng, biến môi trường, form, message queue, database row và kết quả JSON.parse đều chưa đáng tin. JSON không mang được TypeScript brand, còn annotation kiểu trên response của fetch không validation điều gì. Hãy coi serialization là hành động chủ động làm mất bằng chứng, sau đó phục hồi bằng chứng bằng decoder.

flowchart LR A[JSON bên ngoài] --> B[Parse thành unknown] B --> C{Kiểm tra cấu trúc} C -->|sai| D[Lỗi tại ranh giới] C -->|primitive hợp lệ| E[Smart constructor] E -->|vi phạm invariant| D E -->|giá trị có brand| F[Domain model] F --> G[Encode DTO thuần] G --> H[JSON hoặc database]

Transport type nên chỉ chứa giá trị tuần tự hóa được. Decoder xử lý từng field và có thể gom lỗi để phản hồi đầy đủ:

ts
type OrderDto = {
  id: string;
  customerId: string;
  distanceMeters: number;
};

type Order = Readonly<{
  id: OrderId;
  customerId: UserId;
  distance: Meters;
}>;

function decodeOrder(input: unknown): Result<Order> {
  if (typeof input !== 'object' || input === null) {
    return {
      ok: false,
      errors: [{ code: 'format', message: 'Order phải là object' }],
    };
  }

  const record = input as Record<string, unknown>;
  const parsedOrderId = orderId(record.id);
  const parsedCustomerId = userId(record.customerId);
  const parsedDistance =
    typeof record.distanceMeters === 'number'
      ? meters(record.distanceMeters)
      : {
          ok: false as const,
          errors: [{ code: 'format' as const, message: 'Khoảng cách phải là số' }],
        };

  if (!parsedOrderId.ok || !parsedCustomerId.ok || !parsedDistance.ok) {
    return {
      ok: false,
      errors: [
        ...(!parsedOrderId.ok ? parsedOrderId.errors : []),
        ...(!parsedCustomerId.ok ? parsedCustomerId.errors : []),
        ...(!parsedDistance.ok ? parsedDistance.errors : []),
      ],
    };
  }

  return {
    ok: true,
    value: {
      id: parsedOrderId.value,
      customerId: parsedCustomerId.value,
      distance: parsedDistance.value,
    },
  };
}

function encodeOrder(order: Order): OrderDto {
  return {
    id: order.id,
    customerId: order.customerId,
    distanceMeters: order.distance,
  };
}

Phép cast object trong decoder không tuyên bố dữ liệu hợp lệ về nghiệp vụ; nó chỉ cho phép truy cập theo key sau runtime check rằng input là object. Bằng chứng thật sự đến từ từng smart constructor. Chiều encode đơn giản hơn: branded primitive gán được cho base type, nên chuyển model đã tin cậy thành DTO không cần thủ thuật.

Tại repository boundary, đừng gõ kiểu trực tiếp kết quả driver thành Order. Hãy nhận nó dưới dạng unknown hoặc row DTO rồi decode. Database có thể giữ một số invariant, nhưng migration, chỉnh dữ liệu thủ công, row cũ và nullable join vẫn có thể phá giả định của application memory.

Tip

Parse một lần ở biên rồi truyền branded value vào trong. Validation lặp lại ở mọi hàm nội bộ tạo nhiễu; nhận primitive thô ở khắp nơi lại vứt bỏ bằng chứng mà ranh giới đã thiết lập.

Kiểm thử, tính thuận tiện và giới hạn của brand

Cần test cả runtime validation lẫn phân tách lúc biên dịch. Runtime test bao phủ giá trị được nhận, normalization, boundary value, input lỗi và round trip. Property-based test đặc biệt hữu ích cho constructor: mọi kết quả thành công phải thỏa invariant, còn encode rồi decode một domain value phải giữ nguyên ý nghĩa.

ts
describe('userId', () => {
  it('nhận một user ID hợp lệ', () => {
    expect(userId('usr_a1b2c3d4')).toEqual({
      ok: true,
      value: 'usr_a1b2c3d4',
    });
  });

  it.each(['', 'ord_a1b2c3d4', 'usr_TOO_SHORT'])('từ chối %s', (input) => {
    expect(userId(input).ok).toBe(false);
  });
});

declare const validUserId: UserId;
declare function findUser(id: UserId): Promise<Customer>;

findUser(validUserId);
// @ts-expect-error Order ID không được đi vào user repository.
findUser('ord_a1b2c3d4' as OrderId);

Hãy chạy type test trong quy trình build TypeScript bình thường của dự án để @ts-expect-error báo lỗi nếu phép gán không mong muốn bỗng biên dịch được. Đừng test brand chỉ bằng as: assertion có thể ép gần như mọi quan hệ nên sẽ đi vòng qua chính tính năng cần test.

Brand tạo ra chi phí về ergonomics. Ta phải có constructor, xử lý result, quản lý thông báo lỗi generic và tích hợp API bên thứ ba vốn nhận primitive. Có thể giảm chi phí bằng quy ước tên nhất quán (userId, emailAddress), combinator dùng chung cho Result, factory suy luận kiểu tốt và hàm chuyển đổi ở integration. Không cần brand mọi string; chỉ nên brand giá trị mà việc nhầm lẫn hoặc không hợp lệ gây hậu quả thật trong miền.

Cách tiếp cận Validation runtime Biểu diễn runtime Phù hợp nhất Đánh đổi chính
Type alias Không Primitive Chỉ tăng khả năng đọc Không tạo phân tách
Branded primitive Qua constructor Primitive ID, chuỗi đã kiểm tra, đơn vị đơn giản Phải kiểm soát assertion
Value-object class Constructor và method Class instance Giá trị nhiều hành vi và invariant Allocation, serialization, tích hợp framework
Schema library Theo parser Thường là dữ liệu thuần Payload ngoài phức tạp Dependency và đồng bộ schema/type

Class tốt hơn khi giá trị sở hữu nhiều hành vi, cần state private ở runtime, cần instanceof, hoặc phải bảo vệ invariant ngay cả với consumer JavaScript thuần. Một class Money có phép toán theo currency và quy tắc làm tròn thường rõ hơn hai primitive giao kiểu cùng hàng loạt free function.

Schema library như Zod, Valibot hoặc ArkType tốt hơn khi bài toán chủ yếu là payload lồng nhau, union, coercion, default, đường dẫn lỗi chi tiết hoặc schema sinh tự động. Chúng cũng có thể tạo output mang brand: dùng schema ở trust boundary rồi giữ chữ ký miền hẹp bên trong. Runtime schema và brand bổ trợ cho nhau, không phải đối thủ.

Lối thoát nguy hiểm nhất là value as UserId, đặc biệt double assertion như value as unknown as UserId. Chỉ nên dành assertion cho constructor nhỏ hoặc phép đổi đơn vị đã được chứng minh. Khi review, một domain-brand assertion xuất hiện ngoài các vị trí ấy đáng được xem xét nghiêm ngặt như việc tắt validation rule.

Điểm chính

  • Structural typing so sánh hình dạng, vì thế alias của primitive không thể ngăn các giá trị khác nghĩa bị tráo đổi.
  • Brand dùng unique symbol private tạo bằng chứng nominal-like mà không đổi primitive ở runtime.
  • Smart constructor giành quyền gắn brand bằng cách validation và normalization input chưa tin cậy tại một nơi dễ kiểm tra.
  • ID và đơn vị đo hưởng lợi ngay vì lỗi truyền nhầm vừa phổ biến vừa tốn kém.
  • Serialization làm mất bằng chứng lúc biên dịch; decoder phải kiểm tra DTO trước khi khôi phục domain value có brand.
  • Hãy test cả hành vi constructor lẫn phép gán bị cấm, đồng thời giới hạn assertion trong hàm tạo hoặc chuyển đổi đã review.
  • Chọn class cho value object giàu hành vi và schema library cho dữ liệu ngoài phức tạp.

Branded type hiệu quả nhất khi được xem là những tuyên bố nhỏ và tường minh. Nó không chứng minh cả hệ thống đúng; nó cho phép một ranh giới chứng minh một sự thật hữu ích đúng một lần, rồi phần còn lại của chương trình dựa vào sự thật ấy. Thay đổi nhỏ đó biến nhiều comment và quy ước đặt tên thành quy tắc miền do compiler thực thi.