0
0
Lập trình
Admin Team
Admin Teamtechmely

Hiểu Biết Về Liên Minh Phân Biệt Trong TypeScript

Đăng vào 4 tháng trước

• 6 phút đọc

Chủ đề:

#typescript

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 Copy
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 isLoadingtrueerror 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 Copy
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 Copy
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 Copy
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.user có sẵn (không cần ?).
  • Trong 'loginFailed', s.message có sẵn.

3) Cập Nhật Trạng Thái Bằng Các Hàm Nhỏ

typescript Copy
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 Copy
type FetchState<T> =
  | { kind: 'idle' }
  | { kind: 'loading' }
  | { kind: 'success'; data: T }
  | { kind: 'error'; message: string; retryAfterMs?: number };

Sử dụng trong UI:

typescript Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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ã

  1. Liệt kê các cờ hiện tại mô tả chế độ: isLoading, hasError, status, v.v.
  2. Đặt tên cho các trạng thái phản ánh thực tế, ví dụ: idle | loading | success | error.
  3. 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').
  4. Thay thế if bằng switch (state.kind).
  5. 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 Copy
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àngtự độ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.

Gợi ý câu hỏi phỏng vấn
Không có dữ liệu

Không có dữ liệu

Bài viết được đề xuất
Bài viết cùng tác giả

Bình luận

Chưa có bình luận nào

Chưa có bình luận nào