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
numberbất kỳ. Ví dụconst p: Priority = someNumber;compile bình thường, kể cả khisomeNumberlà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ếnnumberthì 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 constgiữ nguyên kiểu literal của từng phần tử. Mảng có kiểureadonly ['CARD', 'BANK_TRANSFER', 'COD']chứ không phảistring[].(typeof PAYMENT_METHODS)[number]lấy union của mọi phần tử, tức là'CARD' | 'BANK_TRANSFER' | 'COD'.isPaymentMethodlà type guard. Sau khi kiểm tra, TypeScript thu hẹpvaluevềPaymentMethod.- Ép kiểu sang
readonly string[]là cần thiết vìincludestrê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 OrderStatuslà'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 enumtrong file.d.tscó thể làm hỏng code của người dùng nếu họ không dùngtsc.
Lưu ý: Tránh
const enumtrong 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 (Pendingso với'PENDING'). enum: khi codebase đã dùng nhất quán và build bằngtscđầ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.keystrên numeric enum và nhận danh sách bị nhân đôi do reverse mapping. - Để numeric enum nhận
numbertù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 OrderStatusngay 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ànhstringvà 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ự.