Enum hay union string literal trong TypeScript?

1. Tổng quan

Khi cần biểu diễn một tập giá trị cố định như trạng thái đơn hàng hay phương thức thanh toán, TypeScript cho bạn hai lựa chọn phổ biến: enum và union string literal ('PENDING' | 'PAID'). Hai cách trông giống nhau trong editor nhưng khác nhau ở runtime, ở cách type checker xử lý và ở mức tương thích với tooling hiện đại.

Bài này phân tích từng cách bằng ví dụ cụ thể, chỉ ra các lỗi hay gặp và giới thiệu pattern as const kết hợp ưu điểm của cả hai. Đọc xong, bạn sẽ có tiêu chí rõ ràng để chọn cho dự án của mình.

2. Enum trong TypeScript

2.1. String enum

enum OrderStatus {
  Pending = 'PENDING',
  Paid = 'PAID',
  Shipped = 'SHIPPED',
  Cancelled = 'CANCELLED',
}

function canCancel(status: OrderStatus): boolean {
  return status === OrderStatus.Pending || status === OrderStatus.Paid;
}

console.log(canCancel(OrderStatus.Paid));
console.log(Object.values(OrderStatus));

Chạy bằng npx tsx order.ts, bạn nhận được:

true
[ 'PENDING', 'PAID', 'SHIPPED', 'CANCELLED' ]

Điểm quan trọng: enum không chỉ là type. Compiler sinh ra một object thật ở runtime. Đó là lý do bạn gọi được Object.values(OrderStatus). Code JavaScript được sinh ra trông như sau:

var OrderStatus;
(function (OrderStatus) {
  OrderStatus["Pending"] = "PENDING";
  OrderStatus["Paid"] = "PAID";
  OrderStatus["Shipped"] = "SHIPPED";
  OrderStatus["Cancelled"] = "CANCELLED";
})(OrderStatus || (OrderStatus = {}));

2.2. Numeric enum và reverse mapping

Nếu không gán giá trị, enum sẽ đánh số từ 0:

enum Priority {
  Low,
  Medium,
  High,
}

console.log(Priority.High);
console.log(Priority[2]);
console.log(Object.keys(Priority));
2
High
[ '0', '1', '2', 'Low', 'Medium', 'High' ]

Numeric enum có reverse mapping: Priority[2] trả về 'High'. Hệ quả là Object.keys trả về cả số lẫn tên. Đây là lỗi kinh điển khi bạn render dropdown từ enum và thấy danh sách bị nhân đôi.

Lưu ý: Numeric enum vẫn nhận một biến kiểu number bất kỳ. Ví dụ const p: Priority = someNumber; compile bình thường, kể cả khi someNumber là 99. Từ TypeScript 5.0, gán literal ngoài phạm vi như const p: Priority = 99; mới báo lỗi. Biến number thì vẫn lọt.

2.3. Enum có tính “nominal”

String enum không chấp nhận chuỗi literal tương ứng:

canCancel(OrderStatus.Paid); // OK
canCancel('PAID');           // Lỗi: Argument of type '"PAID"' is not assignable to parameter of type 'OrderStatus'.

Bạn có thể coi đây là ưu điểm: bắt buộc mọi nơi phải đi qua OrderStatus. Nhưng khi dữ liệu đến từ JSON của API, bạn phải ép kiểu (as OrderStatus) hoặc viết hàm chuyển đổi.

3. Union string literal

type PaymentMethod = 'CARD' | 'BANK_TRANSFER' | 'COD';

interface Payment {
  orderId: string;
  method: PaymentMethod;
  amount: number;
}

const payment: Payment = { orderId: 'ORD-001', method: 'COD', amount: 250_000 };
console.log(payment.method);

// const wrong: Payment = { orderId: 'ORD-002', method: 'PAYPAL', amount: 1 };
// Lỗi: Type '"PAYPAL"' is not assignable to type 'PaymentMethod'.
COD

Union type bị xóa hoàn toàn khi compile. Kết quả JavaScript chỉ còn object payment với chuỗi 'COD', không có code thừa nào. Editor vẫn gợi ý đủ ba giá trị khi bạn gõ method: '.

Điểm yếu: vì không tồn tại ở runtime, bạn không lặp được qua các giá trị của PaymentMethod. Bạn cũng không validate được input nếu không khai báo thêm một danh sách riêng.

3.1. Lấy danh sách giá trị bằng as const

Cách giải quyết là khai báo mảng trước rồi suy ra type từ mảng:

export const PAYMENT_METHODS = ['CARD', 'BANK_TRANSFER', 'COD'] as const;
export type PaymentMethod = (typeof PAYMENT_METHODS)[number];

export function isPaymentMethod(value: string): value is PaymentMethod {
  return (PAYMENT_METHODS as readonly string[]).includes(value);
}

console.log(PAYMENT_METHODS);
console.log(isPaymentMethod('COD'), isPaymentMethod('PAYPAL'));
[ 'CARD', 'BANK_TRANSFER', 'COD' ]
true false

Giải thích:

  • as const giữ nguyên kiểu literal của từng phần tử. Mảng có kiểu readonly ['CARD', 'BANK_TRANSFER', 'COD'] chứ không phải string[].
  • (typeof PAYMENT_METHODS)[number] lấy union của mọi phần tử, tức là 'CARD' | 'BANK_TRANSFER' | 'COD'.
  • isPaymentMethod là type guard. Sau khi kiểm tra, TypeScript thu hẹp value về PaymentMethod.
  • Ép kiểu sang readonly string[] là cần thiết vì includes trên tuple literal chỉ nhận đúng các literal đó.

Từ giờ, danh sách và type luôn đồng bộ. Thêm một phần tử vào mảng là type tự cập nhật theo.

4. Pattern as const object: lựa chọn ở giữa

Nhiều team thích cú pháp OrderStatus.Paid của enum nhưng không muốn các nhược điểm của nó. Bạn có thể dùng object as const:

export const OrderStatus = {
  Pending: 'PENDING',
  Paid: 'PAID',
  Shipped: 'SHIPPED',
  Cancelled: 'CANCELLED',
} as const;

export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];

function canCancel(status: OrderStatus): boolean {
  return status === OrderStatus.Pending || status === OrderStatus.Paid;
}

console.log(canCancel(OrderStatus.Paid));
console.log(canCancel('SHIPPED'));
true
false

Ở đây:

  • Cùng một tên OrderStatus được dùng cho cả value (object) lẫn type. TypeScript cho phép điều này vì value và type nằm ở hai namespace khác nhau.
  • keyof typeof OrderStatus là 'Pending' | 'Paid' | .... Indexed access trả về union các giá trị 'PENDING' | 'PAID' | ....
  • Bạn vẫn viết được OrderStatus.Paid, đồng thời truyền thẳng 'SHIPPED' từ JSON mà không cần ép kiểu.
  • Code sinh ra chỉ là một object literal bình thường, không có IIFE, không có reverse mapping.

5. Exhaustive checking với never

Cả enum lẫn union đều hỗ trợ kiểm tra “đã xử lý đủ mọi trường hợp chưa”. Đây là lý do chính để dùng một tập giá trị cố định thay vì string.

import { OrderStatus } from './order-status';

export function statusLabel(status: OrderStatus): string {
  switch (status) {
    case 'PENDING':
      return 'Chờ thanh toán';
    case 'PAID':
      return 'Đã thanh toán';
    case 'SHIPPED':
      return 'Đang giao';
    case 'CANCELLED':
      return 'Đã hủy';
    default: {
      const unreachable: never = status;
      throw new Error(`Trạng thái không hợp lệ: ${unreachable}`);
    }
  }
}

Nếu sau này bạn thêm Refunded: 'REFUNDED' vào OrderStatus mà quên thêm case, dòng const unreachable: never = status; sẽ báo lỗi:

Type 'string' is not assignable to type 'never'.

Chính xác hơn, thông báo sẽ nói '"REFUNDED"' is not assignable to type 'never'. Lỗi xuất hiện ngay lúc compile, trước khi code lên production. Nhánh throw vẫn hữu ích ở runtime, khi dữ liệu lạ lọt vào từ bên ngoài.

6. Viết test cho hành vi chính

Cài Vitest:

npm install -D vitest

Test cho type guard và hàm statusLabel:

import { describe, expect, it } from 'vitest';
import { isPaymentMethod, PAYMENT_METHODS } from './payment-method';
import { statusLabel } from './status-label';
import { OrderStatus } from './order-status';

describe('PaymentMethod', () => {
  it('chấp nhận mọi giá trị trong danh sách', () => {
    for (const method of PAYMENT_METHODS) {
      expect(isPaymentMethod(method)).toBe(true);
    }
  });

  it('từ chối giá trị lạ', () => {
    expect(isPaymentMethod('PAYPAL')).toBe(false);
    expect(isPaymentMethod('cod')).toBe(false);
  });
});

describe('statusLabel', () => {
  it('trả về nhãn cho mọi trạng thái', () => {
    const labels = Object.values(OrderStatus).map(statusLabel);
    expect(labels).toEqual(['Chờ thanh toán', 'Đã thanh toán', 'Đang giao', 'Đã hủy']);
  });

  it('ném lỗi khi dữ liệu runtime không hợp lệ', () => {
    expect(() => statusLabel('REFUNDED' as OrderStatus)).toThrow('Trạng thái không hợp lệ: REFUNDED');
  });
});

Chạy npx vitest run:

 ✓ PaymentMethod > chấp nhận mọi giá trị trong danh sách
 ✓ PaymentMethod > từ chối giá trị lạ
 ✓ statusLabel > trả về nhãn cho mọi trạng thái
 ✓ statusLabel > ném lỗi khi dữ liệu runtime không hợp lệ

 Test Files  1 passed (1)
      Tests  4 passed (4)

Test thứ hai chứng minh type guard phân biệt chữ hoa, chữ thường. Đây là chi tiết dễ bỏ sót khi parse input từ form.

7. Tooling hiện đại và const enum

Ngày càng nhiều công cụ chỉ xóa type thay vì biên dịch TypeScript đầy đủ. Ví dụ: chế độ type stripping của Node.js, hay esbuild và SWC khi xử lý từng file riêng lẻ. Các công cụ này không xử lý được cú pháp sinh ra code runtime như enum.

TypeScript 5.8 có flag erasableSyntaxOnly để cấm loại cú pháp này:

{
  "compilerOptions": {
    "erasableSyntaxOnly": true,
    "isolatedModules": true
  }
}

Khi bật flag, mọi khai báo enum sẽ báo lỗi. Union literal và object as const thì vẫn hợp lệ vì chúng là JavaScript thuần cộng với type annotation.

const enum từng được quảng bá là enum “không tốn chi phí”, vì compiler inline giá trị thay vì sinh object. Tuy nhiên, nó gây nhiều vấn đề:

  • Không dùng được an toàn với isolatedModules, vì công cụ biên dịch từng file không biết giá trị của enum ở file khác.
  • Khi publish thư viện, const enum trong file .d.ts có thể làm hỏng code của người dùng nếu họ không dùng tsc.

Lưu ý: Tránh const enum trong code mới, đặc biệt là trong thư viện. Nếu muốn không tốn chi phí runtime, union literal đã làm được điều đó mà không có rủi ro.

8. Khi nào dùng cách nào?

Tiêu chí enum Union literal Object as const
Có giá trị ở runtime Có Không (cần mảng riêng) Có
Nhận chuỗi thô từ JSON Phải ép kiểu Có Có
Tương thích type stripping Không Có Có
Cú pháp X.Value Có Không Có
Exhaustive check Có Có Có

Gợi ý thực tế:

  • Union literal: mặc định cho hầu hết trường hợp, nhất là type của props, DTO và tham số hàm. Gọn nhất, không có code runtime.
  • Mảng as const + union: khi bạn cần lặp qua giá trị (dropdown, validate input) hoặc dùng với thư viện validation.
  • Object as const: khi team muốn cú pháp giống enum, hoặc tên hiển thị trong code khác giá trị thật (Pending so với 'PENDING').
  • enum: khi codebase đã dùng nhất quán và build bằng tsc đầy đủ, hoặc khi bạn thật sự muốn tính nominal, tức là cấm truyền chuỗi thô.

Các lỗi hay gặp:

  • Dùng Object.keys trên numeric enum và nhận danh sách bị nhân đôi do reverse mapping.
  • Để numeric enum nhận number tùy ý, rồi dữ liệu lưu trong DB bị lệch khi ai đó chèn thêm member vào giữa enum. Thứ tự đổi kéo theo giá trị đổi.
  • Ép kiểu as OrderStatus ngay khi nhận dữ liệu từ API thay vì kiểm tra bằng type guard. Type checker sẽ tin bạn, còn dữ liệu sai sẽ đi tiếp.
  • Quên as const, khiến type bị nới rộng thành string và mất toàn bộ tác dụng.

9. Kết luận

Enum và union string literal đều giải quyết bài toán “tập giá trị cố định”. Điểm khác nhau là enum sinh code runtime và có tính nominal. Union literal chỉ tồn tại ở tầng type, nên nhẹ hơn và thân thiện với tooling hiện đại.

Với code TypeScript mới, bạn nên bắt đầu bằng union literal. Khi cần danh sách ở runtime, kết hợp thêm mảng hoặc object as const. Dù chọn cách nào, hãy dùng exhaustive check với never và type guard ở biên hệ thống. Đó mới là phần giúp code an toàn thực sự.

typescriptenumunion typetype safetybest practices