Một boolean tên loading trông vô hại cho đến khi nó đi cùng data, error và hasLoadedOnce. Những tổ hợp nào thực sự có nghĩa? Dữ liệu và lỗi có thể cùng tồn tại không? loading: false nghĩa là chưa bắt đầu, đã thành công hay đã thất bại? Một nhóm nhỏ các trường độc lập có thể mô tả nhiều trạng thái hơn hẳn số trạng thái ứng dụng thực sự hỗ trợ, và TypeScript sẽ ngoan ngoãn cho phép tất cả.
Kiểu dữ liệu đại số cung cấp một bộ từ vựng tốt hơn. Product type kết hợp các giá trị cùng tồn tại; sum type chọn đúng một trong các khả năng. TypeScript không dùng những thuật ngữ này trong cú pháp, nhưng object type, union, literal type và never đã đủ để chuyển quy tắc nghiệp vụ vào hình dạng dữ liệu. Khi mỗi khả năng có một discriminant, control flow trở thành pattern matching thực dụng, còn compiler có thể chứng minh mọi trường hợp đều đã được xử lý.
Product và sum: Đại số phía sau kiểu dữ liệu
Product type chứa đồng thời tất cả các trường. Nếu một Point có x và y, một giá trị của nó được chọn từ tích Descartes của các giá trị có thể có:
type Point = {
x: number;
y: number;
};
type User = {
id: string;
displayName: string;
};
Số giá trị có thể có được nhân với nhau. Nếu kiểu A có phần tử và kiểu B có phần tử, số cặp là
Sum type chứa khả năng này hoặc khả năng kia. TypeScript biểu diễn nó bằng union:
type ContactMethod =
| { kind: 'email'; address: string }
| { kind: 'sms'; phoneNumber: string };
Số khả năng được cộng thay vì nhân: . Trường kind là discriminant. Nó đặt tên ổn định cho từng khả năng và cho phép TypeScript thu hẹp các trường còn lại. Một liên hệ email không thể vô tình chỉ chứa số điện thoại vì object đó không thỏa mãn thành viên nào của union.
Sự phân biệt này tạo ra một phép thử thiết kế hữu ích:
| Câu hỏi | Product type | Sum type |
|---|---|---|
| Quan hệ | Các giá trị cùng tồn tại | Chỉ một khả năng tồn tại |
| Dạng TypeScript | Object, tuple | Union, thường có discriminant |
| Từ khóa thường gặp | “và”, “có” | “hoặc”, “một trong” |
| Ví dụ | User có ID và tên | Liên hệ là email hoặc SMS |
| Lỗi thường gặp | Biến trường bắt buộc thành optional | Thay các khả năng bằng boolean |
Optional property trộn cả hai ý tưởng. { value?: T } gần như nói rằng object có hoặc không có giá trị, nhưng thường không đặt tên cho trường hợp vắng mặt. Khi sự vắng mặt có ý nghĩa nghiệp vụ, hãy biểu diễn sum một cách rõ ràng:
type Option<T> =
| { kind: 'some'; value: T }
| { kind: 'none' };
const findUser = (id: string): Option<User> =>
id === 'u_123'
? { kind: 'some', value: { id, displayName: 'Ada' } }
: { kind: 'none' };
Option<T> dài hơn T | undefined, nhưng tách bạch “không tìm thấy” khỏi đối số bị bỏ qua, biến chưa khởi tạo và lỗi lập trình.
Biến trạng thái bất hợp lệ thành không thể biểu diễn
Hãy xét một request được biểu diễn bằng các trường độc lập:
type FragileRequest<T> = {
loading: boolean;
data?: T;
error?: string;
};
Kiểu này cho phép vừa loading, vừa giữ dữ liệu cũ, vừa có lỗi; nó cũng cho phép không loading nhưng chẳng có kết quả nào, cùng mọi tổ hợp khác. Một vài tổ hợp có thể là chủ ý, nhưng kiểu dữ liệu không nói rõ tổ hợp nào. Discriminated union mã hóa state machine thực sự:
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading'; requestId: string }
| { status: 'success'; data: T; receivedAt: number }
| { status: 'failure'; error: AppError; retryable: boolean };
type AppError =
| { kind: 'network'; message: string }
| { kind: 'unauthorized' }
| { kind: 'invalid-response'; issues: string[] };
Mỗi trạng thái chỉ sở hữu dữ liệu hợp lệ tại đó. Request thành công bắt buộc có data và không thể có error. Trạng thái thất bại phải giải thích điều gì đã xảy ra. Error union giàu thông tin cũng ngăn caller phải phân tích chuỗi message để quyết định hiển thị giao diện đăng nhập hay nút thử lại.
Event cũng nên là một sum type. Khi đó reducer ghi lại các chuyển đổi hợp lệ dưới dạng code chạy được:
type RequestEvent<T> =
| { type: 'load'; requestId: string }
| { type: 'resolve'; requestId: string; data: T; receivedAt: number }
| { type: 'reject'; requestId: string; error: AppError };
function transition<T>(
state: RequestState<T>,
event: RequestEvent<T>,
): RequestState<T> {
switch (event.type) {
case 'load':
return { status: 'loading', requestId: event.requestId };
case 'resolve':
if (state.status !== 'loading' || state.requestId !== event.requestId) {
return state;
}
return {
status: 'success',
data: event.data,
receivedAt: event.receivedAt,
};
case 'reject':
if (state.status !== 'loading' || state.requestId !== event.requestId) {
return state;
}
return {
status: 'failure',
error: event.error,
retryable: event.error.kind === 'network',
};
}
}
requestId ngăn response cũ nhưng chậm ghi đè request mới. Kiểu dữ liệu không loại bỏ mọi temporal bug, nhưng bảo đảm thông tin cần thiết luôn hiện diện.
Note
“Không thể” ở đây nghĩa là không thể thông qua quá trình khởi tạo được kiểm tra kiểu. Type assertion, any, dữ liệu mạng sai và JavaScript có mutation vẫn có thể vi phạm mô hình. Bảo đảm tĩnh phải đi cùng validation runtime tại các biên không đáng tin cậy.
So khớp toàn diện biến thay đổi thành phản hồi
TypeScript thu hẹp discriminated union bên trong switch, if và biểu thức điều kiện. Một helper assertNever nhỏ biến khả năng thu hẹp đó thành phép kiểm tra tính toàn diện:
function assertNever(value: never): never {
throw new Error(`Unexpected variant: ${JSON.stringify(value)}`);
}
function requestLabel<T>(state: RequestState<T>): string {
switch (state.status) {
case 'idle':
return 'Chưa bắt đầu';
case 'loading':
return 'Đang tải';
case 'success':
return 'Sẵn sàng';
case 'failure':
return state.retryable ? 'Thử lại' : 'Thất bại';
default:
return assertNever(state);
}
}
Nếu sau này RequestState có thêm thành viên cancelled, state trong nhánh default không còn là never. Compilation sẽ thất bại tại mọi matcher toàn diện cần một policy cho việc hủy. Đây là khả năng khuếch đại thay đổi có ích: thay vì tìm kiếm những câu điều kiện có thể liên quan, type checker tạo thẳng một danh sách công việc.
Một matcher tái sử dụng được có thể làm các nhánh sinh giá trị gọn hơn mà vẫn giữ cùng bảo đảm:
type Result<T, E> =
| { kind: 'ok'; value: T }
| { kind: 'err'; error: E };
function matchResult<T, E, R>(
result: Result<T, E>,
cases: { ok: (value: T) => R; err: (error: E) => R },
): R {
switch (result.kind) {
case 'ok':
return cases.ok(result.value);
case 'err':
return cases.err(result.error);
default:
return assertNever(result);
}
}
const message = matchResult(saveUser(), {
ok: (user) => `Đã lưu ${user.displayName}`,
err: (error) => `Không thể lưu: ${formatError(error)}`,
});
Thư viện như ts-pattern bổ sung nested pattern, guard và .exhaustive() cho cấu trúc sâu. switch native vẫn là mặc định tốt vì không có dependency và cách narrowing minh bạch.
Warning
Tránh một nhánh default rộng chỉ trả về fallback. Nó làm compiler im tiếng khi xuất hiện variant mới. Chỉ dùng default cho assertNever, hoặc bỏ hẳn nhánh này và khai báo return type để compiler chứng minh mọi đường đi đều return.
Result đưa lỗi dự kiến vào contract
Exception phù hợp cho invariant bị phá vỡ và lỗi mà một tầng không thể phục hồi hợp lý. Nó yếu hơn đối với kết quả dự kiến như validation thất bại, email trùng hoặc xung đột optimistic lock vì function signature che giấu những khả năng ấy.
Result<T, E> làm các kết quả đó rõ ràng và có thể kết hợp:
type RegistrationError =
| { kind: 'invalid-email'; input: string }
| { kind: 'duplicate-email'; email: string }
| { kind: 'storage-unavailable'; cause: unknown };
function parseEmail(input: string): Result<string, RegistrationError> {
const email = input.trim().toLowerCase();
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)
? { kind: 'ok', value: email }
: { kind: 'err', error: { kind: 'invalid-email', input } };
}
function mapResult<T, E, U>(
result: Result<T, E>,
transform: (value: T) => U,
): Result<U, E> {
return result.kind === 'ok'
? { kind: 'ok', value: transform(result.value) }
: result;
}
Caller phải kiểm tra kind trước khi dùng value. Error variant mang ngữ cảnh có cấu trúc; matcher toàn diện có thể chuyển chúng thành HTTP 400, 409, 503 hoặc thông báo UI đã bản địa hóa.
Không cần thay mọi throw. Hãy dùng Result khi caller được kỳ vọng đưa ra quyết định dựa trên thất bại; invariant vỡ vẫn có thể là exception.
Serialization là một biên, không phải type assertion
Discriminated union serialize tự nhiên vì tag vẫn còn trong JSON. Điều đó không làm JSON đã parse trở nên đáng tin. JSON.parse trả về any, còn as RequestState<User> không kiểm tra gì ở runtime. Hãy decode dữ liệu unknown trước khi đưa nó vào phần lõi đã định kiểu:
type UserPayload = { id: string; displayName: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function decodeUser(value: unknown): Result<UserPayload, string[]> {
if (!isRecord(value)) {
return { kind: 'err', error: ['Cần một object'] };
}
const issues: string[] = [];
if (typeof value.id !== 'string') issues.push('id phải là string');
if (typeof value.displayName !== 'string') {
issues.push('displayName phải là string');
}
return issues.length > 0
? { kind: 'err', error: issues }
: {
kind: 'ok',
value: {
id: value.id as string,
displayName: value.displayName as string,
},
};
}
Thư viện runtime validation có thể giảm kiểm tra lặp lại và suy ra static type. Quy tắc cốt lõi vẫn là nhận unknown, validate ở rìa rồi truyền domain value đáng tin vào trong.
Tag cũng là quyết định về wire format. Đổi tên 'failure' thành 'error' sẽ phá persisted record, queued message, URL và client cũ ngay cả khi TypeScript cục bộ compile thành công. Hãy dùng chuỗi tag ổn định, đơn giản và xem chúng là một phần của protocol.
Hãy đặt version cho persisted union khi hình dạng của nó cần tiến hóa:
type StoredSettings =
| { version: 1; theme: 'light' | 'dark' }
| { version: 2; colorScheme: 'light' | 'dark' | 'system' };
type CurrentSettings = Extract<StoredSettings, { version: 2 }>;
function migrateSettings(value: StoredSettings): CurrentSettings {
switch (value.version) {
case 1:
return { version: 2, colorScheme: value.theme };
case 2:
return value;
default:
return assertNever(value);
}
}
Hãy validate hình dạng lịch sử, migrate nó và chỉ để ứng dụng thấy model hiện tại. Test migration cần fixture cho mọi version còn hỗ trợ.
Tiến hóa, đánh đổi và giới hạn thực tế
ADT làm thay đổi trở nên hữu hình, nhưng không làm nó miễn phí. Thêm một trường vào product buộc mọi constructor cung cấp trường đó. Thêm một thành viên vào sum buộc mọi consumer toàn diện chọn hành vi. Những lỗi compile ấy chính là bảng kê chi phí cho một thay đổi domain thực sự.
Có những trường hợp khả năng mở rộng quan trọng hơn tính toàn diện trong một thế giới đóng. Plugin registry không thể biết mọi plugin tương lai tại compile time; một map từ string key đến handler có thể trung thực hơn fixed union. Union lớn cũng có thể tạo error message nhiễu, còn matcher helper có generic quá sâu có thể làm type checker chậm hoặc che khuất control flow vốn đơn giản.
Hãy giữ domain union tập trung. Thay vì một ApplicationEvent chứa hàng trăm variant gây coupling, nên dùng các union có biên rõ như BillingEvent và EditorEvent.
Structural typing có một giới hạn khác: hai khái niệm cùng hình dạng có thể gán cho nhau. Nếu việc trộn UserId và OrderId nguy hiểm, hãy thêm branded type hoặc constructor có validation. ADT mô hình hóa lựa chọn và tổ hợp; nó không tự động cung cấp nominal identity, đơn vị đo, authorization hay runtime immutability.
Tip
Hãy bắt đầu ở nơi những tổ hợp sai đã gây ra nhánh điều kiện, bug hoặc defensive check. Thay ba boolean có tương quan bằng một discriminated union thường mang lại nhiều giá trị hơn việc đưa một functional toolkit tổng quát vào toàn codebase.
Test vẫn cần thiết. Hãy kiểm tra từng variant, transition, lỗi decoder và migration fixture. Tính toàn diện chứng minh code đã đề cập mọi khả năng được đặt tên; nó không chứng minh hành vi được chọn cho từng khả năng là đúng.
Những điều cần nhớ
- Product type mô hình hóa các giá trị cùng tồn tại; sum type mô hình hóa những khả năng loại trừ lẫn nhau.
- Một discriminant ổn định biến TypeScript union thành domain model chính xác và kích hoạt control-flow narrowing.
Optionđặt tên cho sự vắng mặt có ý nghĩa, cònResultđưa lỗi dự kiến vào contract của hàm.- Union cho state và event loại bỏ tổ hợp mâu thuẫn, đồng thời làm transition dễ review.
assertNevervà so khớp toàn diện biến variant mới thành công việc migration do compiler hướng dẫn.- Validate
unknowntại biên serialization, giữ tag ổn định và chủ động version hóa persisted shape. - Dùng ADT khi domain đủ đóng để liệt kê; ưu tiên registry mở cho hệ thống thực sự cần mở rộng.
Kiểu dữ liệu tốt không loại bỏ thay đổi hay thất bại. Chúng đặt cả hai vào nơi kỹ sư có thể nhìn thấy: constructor, return value, transition function, decoder và lỗi compiler. Khả năng quan sát ấy là lợi thế thực tiễn của kiểu dữ liệu đại số.