Giới thiệu
Trong phát triển ứng dụng, việc theo dõi trạng thái hiện tại của ứng dụng là rất quan trọng. Nhiều ứng dụng thường sử dụng các cờ lỏng lẻo như sau:
typescript
let isLoading = false;
let data: User | null = null;
let error: string | null = null;
Tuy nhiên, cách tiếp cận này có thể dẫn đến những tình huống khó xử: nếu isLoading là true và error cũng được thiết lập, trạng thái nào là thực sự? Đây chính là lúc liên minh phân biệt (discriminated unions) trong TypeScript phát huy tác dụng.
Liên Minh Phân Biệt Là Gì?
Liên minh phân biệt cho phép bạn xác định rằng: “ứng dụng chỉ ở trong một trạng thái tại một thời điểm, và mỗi trạng thái chỉ chứa dữ liệu cần thiết.”
typescript
type FetchState<T> =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'success'; data: T }
| { kind: 'error'; message: string };
- Trường chung (
kind) là đặc trưng phân biệt. - Các trường khác chỉ thuộc về trạng thái đơn lẻ đó.
Với cách này, những kết hợp không thể xảy ra (như loading + error + data) sẽ không còn tồn tại.
Tại Sao Liên Minh Phân Biệt Giúp Ích?
- Rõ Ràng: Chỉ cần đọc trường
kind, bạn ngay lập tức biết điều gì đang diễn ra. - An Toàn: TypeScript tự động thu hẹp kiểu cho bạn. Trong trạng thái
'success',datađược đảm bảo sẽ tồn tại. - Hướng Dẫn: Nếu bạn thêm một trạng thái mới sau này, biên dịch viên sẽ cho biết mọi nơi bạn cần cập nhật.
Cách Sử Dụng Liên Minh Phân Biệt
1) Định Nghĩa Các Trạng Thái
Chọn một tên thuộc tính duy nhất cho thẻ. Các lựa chọn phổ biến: kind hoặc type.
typescript
type AuthState =
| { kind: 'loggedOut' }
| { kind: 'loggingIn' }
| { kind: 'loggedIn'; user: { id: string; name: string } }
| { kind: 'loginFailed'; message: string };
2) Viết Mã Chuyển Đổi Theo Thẻ
typescript
function renderAuth(s: AuthState): string {
switch (s.kind) {
case 'loggedOut': return 'Vui lòng đăng nhập';
case 'loggingIn': return 'Đang đăng nhập…';
case 'loggedIn': return `Chào mừng, ${s.user.name}`;
case 'loginFailed':return `Lỗi: ${s.message}`;
default: return assertNever(s);
}
}
function assertNever(x: never): never {
throw new Error('Trạng thái không xử lý: ' + JSON.stringify(x));
}
TypeScript hiện đã thu hẹp kiểu trong mỗi trường hợp:
- Trong
'loggedIn',s.usercó sẵn (không cần?). - Trong
'loginFailed',s.messagecó sẵn.
3) Cập Nhật Trạng Thái Bằng Các Hàm Nhỏ
typescript
function startLogin(_s: AuthState): AuthState {
return { kind: 'loggingIn' };
}
function loginOk(_s: AuthState, user: { id: string; name: string }): AuthState {
return { kind: 'loggedIn', user };
}
function loginFail(_s: AuthState, message: string): AuthState {
return { kind: 'loginFailed', message };
}
Giữ các hàm này thuần khiết (không gọi mạng bên trong). Chúng dễ dàng kiểm tra.
Những Mẫu Thường Gặp Hàng Ngày
A) Lấy Dữ Liệu Asynchronous Mà Không Cần isLoading
typescript
type FetchState<T> =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'success'; data: T }
| { kind: 'error'; message: string; retryAfterMs?: number };
Sử dụng trong UI:
typescript
function renderUsers(s: FetchState<User[]>) {
switch (s.kind) {
case 'idle': return 'Nhấn để tải';
case 'loading': return 'Đang tải…';
case 'success': return `Đã tải ${s.data.length} người dùng`;
case 'error': return `Lỗi: ${s.message}`;
default: return assertNever(s);
}
}
B) Các Biểu Mẫu Như Những Quy Trình Nhỏ
typescript
type ProfileForm =
| { kind: 'editing'; values: { name: string; email: string }; errors?: Record<string, string> }
| { kind: 'submitting'; values: { name: string; email: string } }
| { kind: 'submitted'; id: string }
| { kind: 'failed'; values: { name: string; email: string }; message: string };
Giờ đây, bạn không cần isSubmitting, submitError, v.v. Tên trạng thái đã nói lên tất cả.
C) Cờ Tính Năng Không Thể Sử Dụng Sai
typescript
type Rollout =
| { kind: 'off' }
| { kind: 'percentage'; percent: number } // 0..100
| { kind: 'audience'; segments: Array<'beta'|'staff'|'pro'> }
| { kind: 'on' };
function isEnabled(flag: Rollout, ctx: { segment: string; rand: number }) {
switch (flag.kind) {
case 'off': return false;
case 'on': return true;
case 'percentage': return ctx.rand < flag.percent / 100;
case 'audience': return flag.segments.includes(ctx.segment as any);
default: return assertNever(flag);
}
}
D) Kết Quả và Tùy Chọn (lỗi mà không cần try/catch khắp nơi)
typescript
type Result<T, E> = { kind: 'ok'; value: T } | { kind: 'err'; error: E };
type Option<T> = { kind: 'some'; value: T } | { kind: 'none' };
const ok = <T, E=never>(value: T): Result<T, E> => ({ kind: 'ok', value });
const err = <E, T=never>(error: E): Result<T, E> => ({ kind: 'err', error });
const some = <T>(value: T): Option<T> => ({ kind: 'some', value });
const none = <T=never>(): Option<T> => ({ kind: 'none' });
function parseJson<T>(s: string): Result<T, string> {
try { return ok(JSON.parse(s) as T); }
catch (e) { return err('JSON không hợp lệ'); }
}
Các Tiện Ích Hữu Ích
Bảo Vệ Kiểu Nhỏ (tốt cho tính dễ đọc)
typescript
const isSuccess = <T>(s: FetchState<T>): s is { kind: 'success'; data: T } =>
s.kind === 'success';
if (isSuccess(state)) {
// state.data có sẵn và đã được kiểu hóa
}
Bộ So Sánh Nhỏ (cho các ánh xạ một dòng)
typescript
function match<T extends { kind: string }, R>(
v: T,
handlers: { [K in T['kind']]: (x: Extract<T, { kind: K }>) => R }
): R {
return handlers[v.kind](v as any);
}
const label = match(state, {
idle: () => 'Idle',
loading: () => 'Đang tải…',
success: s => `Đã nhận ${s.data.length}`,
error: s => `Lỗi: ${s.message}`,
});
Hướng Dẫn Di Chuyển Mã
- Liệt kê các cờ hiện tại mô tả chế độ:
isLoading,hasError,status, v.v. - Đặt tên cho các trạng thái phản ánh thực tế, ví dụ:
idle | loading | success | error. - Di chuyển các trường vào trạng thái của chúng (tin nhắn lỗi bên trong
'error', dữ liệu bên trong'success'). - Thay thế
ifbằngswitch (state.kind). - Thêm
assertNeverđể bắt các trường hợp thiếu sót bây giờ và trong tương lai.
Hãy làm điều này một mô-đun một lần. Bạn sẽ thấy lợi ích ngay lập tức.
Kiểm Tra Trở Nên Dễ Hơn
Bởi vì các hàm thay đổi trạng thái của bạn là thuần khiết (đầu vào → đầu ra, không có tác dụng phụ), các bài kiểm tra ngắn gọn và ổn định.
typescript
import { it, expect } from 'vitest';
it('chuyển từ loading -> success', () => {
const s0: FetchState<User> = { kind: 'idle' };
const s1: FetchState<User> = { kind: 'loading' };
const s2: FetchState<User> = { kind: 'success', data: { id: '1', name: 'Soumaya' } };
expect(s0.kind).toBe('idle');
expect(s1.kind).toBe('loading');
expect(s2.kind).toBe('success');
});
Những Sai Lầm Thường Gặp
- Tên nhãn khác nhau. Tất cả các biến thể phải chia sẻ cùng tên phân biệt (ví dụ, luôn là
kind). - Trường tùy chọn ở khắp mọi nơi. Nếu một trường chỉ quan trọng trong một tình huống, hãy biến nó thành trạng thái của riêng nó thay vì
field?: T. - Quá nhiều trạng thái nhỏ. Nếu hai trạng thái hoạt động giống nhau và chứa cùng dữ liệu, hãy kết hợp chúng lại.
- Quên tính đầy đủ. Giữ lại
assertNever(hoặc một trợ lýdefault: exhaustive(state)) để bắt các trường hợp còn thiếu.
Kết Luận
Liên minh phân biệt là một công cụ nhỏ nhưng mạnh mẽ:
- Chúng đặt tên cho các trạng thái của bạn.
- Chúng biến các trạng thái không khả thi thành không thể xảy ra.
- Chúng mang lại cho bạn mã rõ ràng và tự động hoàn thành tốt hơn.
- Chúng làm cho việc thay đổi an toàn (biên dịch viên cho biết điều gì cần cập nhật).
Hãy bắt đầu với một nơi hiện tại đang sử dụng isLoading + error + data?. Biến nó thành một liên minh với kind. Bạn sẽ cảm nhận được sự khác biệt ngay lập tức.