TypeScript Production · Phần 3 — Narrowing, Exhaustiveness & State Machines
Dùng control-flow analysis, type predicate, assertion function và discriminated union để biến trạng thái bất hợp lệ thành không thể biểu diễn.
TypeScript mạnh nhất không phải khi type dài nhất, mà khi control flow của code cung cấp đủ bằng chứng để type tự thu hẹp.
Narrowing là luồng bằng chứng
function render(value: string | string[] | null) {
if (value === null) return 'empty';
if (Array.isArray(value)) return value.join(', ');
return value.toUpperCase();
}
Mỗi guard loại một tập giá trị. Compiler theo dõi assignment, return, throw, equality, typeof, instanceof, in và predicate để xây control-flow graph.
Đừng model ba boolean độc lập
type BadState<T> = {
loading: boolean;
data?: T;
error?: Error;
};
Type trên cho phép loading: true đồng thời có cả data và error. Tám tổ hợp boolean nhưng domain chỉ cần bốn trạng thái.
type RemoteData<T> =
| { status: 'idle' }
| { status: 'loading'; requestId: string }
| { status: 'success'; data: T; receivedAt: number }
| { status: 'failure'; error: AppError; retryable: boolean };
function view<T>(state: RemoteData<T>): string {
switch (state.status) {
case 'idle':
return 'Start';
case 'loading':
return `Loading ${state.requestId}`;
case 'success':
return `Received at ${state.receivedAt}`;
case 'failure':
return state.error.message;
default:
return assertNever(state);
}
}
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
Thêm variant mới sẽ làm mọi consumer chưa xử lý fail compile. Đó là thay đổi contract có chủ ý.
Predicate có thể nói dối
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'id' in value;
}
Compiler không kiểm chứng body có đủ mạnh để kết luận User. Predicate là một assertion có syntax đẹp hơn. Hãy:
- giữ guard nhỏ;
- test positive và negative cases;
- dùng schema validator cho object phức tạp;
- tránh generic predicate tuyên bố nhiều hơn runtime check.
Assertion function phù hợp cho invariant nội bộ:
function assertDefined<T>(
value: T,
label: string
): asserts value is NonNullable<T> {
if (value == null) throw new Error(`${label} is required`);
}
Correlation dễ mất khi destructure quá sớm
type Result = { ok: true; value: string } | { ok: false; error: Error };
function consume(result: Result) {
if (result.ok) console.log(result.value);
else console.error(result.error);
}
Giữ discriminant và payload chung một object qua boundary. Tách chúng thành hai biến hoặc hai state riêng có thể làm mất quan hệ mà type system cần.
State transition cũng cần type
type Event =
| { type: 'FETCH'; requestId: string }
| { type: 'RESOLVE'; data: User[] }
| { type: 'REJECT'; error: AppError }
| { type: 'RESET' };
function transition(
state: RemoteData<User[]>,
event: Event
): RemoteData<User[]> {
switch (event.type) {
case 'FETCH':
return { status: 'loading', requestId: event.requestId };
case 'RESOLVE':
return { status: 'success', data: event.data, receivedAt: Date.now() };
case 'REJECT':
return { status: 'failure', error: event.error, retryable: true };
case 'RESET':
return { status: 'idle' };
default:
return assertNever(event);
}
}
Type trên chưa cấm mọi transition logic sai (ví dụ RESOLVE từ idle). Khi rule đó quan trọng, dùng transition table theo state hoặc state-machine library và test transition matrix.
Lab
- Chọn một component có
isLoading,error,data; đổi sang discriminated union. - Thêm một variant
refreshinggiữ stale data; để compiler chỉ ra mọi chỗ cần sửa. - Viết negative tests cho một predicate hiện có.
- Vẽ transition table trước khi viết reducer.
Done khi: UI/business logic không còn non-null assertion để ghép lại các state đã bị tách sai.