unknown, any và never trong TypeScript: dùng cái nào

1. Tổng quan

TypeScript có ba kiểu đặc biệt dễ gây nhầm lẫn: any, unknown và never. Cả ba đều xuất hiện ở các “biên” của hệ thống kiểu. any tắt kiểm tra kiểu, unknown buộc bạn kiểm tra trước khi dùng, còn never biểu diễn trường hợp không thể xảy ra.

Đọc xong bài này, bạn sẽ biết chọn kiểu nào cho dữ liệu chưa rõ nguồn gốc. Bạn cũng sẽ biết cách dùng never để compiler báo lỗi khi bạn quên xử lý một nhánh.

Tất cả ví dụ chạy với TypeScript 5.x và "strict": true trong tsconfig.json.

2. any: tắt type checker

any cho phép một giá trị làm bất cứ thứ gì. Compiler không kiểm tra gì cả:

let payload: any = "xin chào";

payload.toUpperCase();   // OK
payload.foo.bar();       // OK lúc compile, lỗi lúc chạy
payload = 42;
const total: number = payload * 2; // OK

Dòng thứ ba biên dịch bình thường. Khi chạy, nó ném lỗi:

TypeError: Cannot read properties of undefined (reading 'bar')

Với any, TypeScript coi như bạn đã tự chịu trách nhiệm. Bạn mất autocomplete, mất kiểm tra tên thuộc tính, và mất lợi ích của refactor an toàn.

2.1. any lây lan

Vấn đề lớn nhất của any là nó lây sang mọi thứ chạm vào nó:

function loadConfig(): any {
  return JSON.parse('{"port": "8080"}');
}

const config = loadConfig();
const port = config.port;      // port: any
const next = port + 1;         // next: any
console.log(next);
80801

port là chuỗi "8080", nên port + 1 cho ra "80801" thay vì 8081. Compiler không cảnh báo, vì any đã truyền qua config, port rồi next. Một chữ any ở đầu có thể làm mất kiểm tra kiểu ở cả một chuỗi lời gọi hàm.

Lưu ý: JSON.parse, response.json() trong fetch API và nhiều thư viện cũ trả về any. Đây là nguồn rò rỉ any phổ biến nhất trong code thực tế.

3. unknown: an toàn khi chưa biết kiểu

unknown cũng nhận được mọi giá trị như any. Điểm khác là bạn không được dùng giá trị đó cho tới khi đã thu hẹp kiểu (narrowing):

let payload: unknown = "xin chào";

payload.toUpperCase();
error TS18046: 'payload' is of type 'unknown'.

Compiler chặn ngay. Bạn phải chứng minh payload là chuỗi trước:

if (typeof payload === "string") {
  console.log(payload.toUpperCase()); // payload: string
}
XIN CHÀO

Bên trong khối if, TypeScript biết payload là string, nên toUpperCase() hợp lệ.

3.1. Các cách thu hẹp unknown

Có bốn công cụ chính:

class PaymentError extends Error {
  constructor(message: string, public readonly code: string) {
    super(message);
  }
}

function describe(value: unknown): string {
  if (typeof value === "number") {
    return `Số: ${value.toFixed(2)}`;
  }
  if (value instanceof PaymentError) {
    return `Lỗi thanh toán ${value.code}`;
  }
  if (typeof value === "object" && value !== null && "id" in value) {
    return `Object có id: ${String(value.id)}`;
  }
  return "Không xác định";
}

console.log(describe(12.5));
console.log(describe(new PaymentError("Hết tiền", "INSUFFICIENT")));
console.log(describe({ id: 7 }));
console.log(describe(null));
Số: 12.50
Lỗi thanh toán INSUFFICIENT
Object có id: 7
Không xác định
  • typeof dùng cho kiểu nguyên thủy: string, number, boolean…
  • instanceof dùng cho class.
  • in kiểm tra thuộc tính tồn tại. Từ TypeScript 4.9, sau "id" in value thì value có kiểu object & Record<"id", unknown>.
  • Phải kiểm tra value !== null vì typeof null === "object".

3.2. Type guard cho dữ liệu từ bên ngoài

Với object phức tạp, bạn nên viết một type guard, tức là hàm trả về value is T:

interface Order {
  id: string;
  customerName: string;
  total: number;
}

export function isOrder(value: unknown): value is Order {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  return (
    typeof v.id === "string" &&
    typeof v.customerName === "string" &&
    typeof v.total === "number"
  );
}

export function parseOrder(json: string): Order {
  const data: unknown = JSON.parse(json);
  if (!isOrder(data)) {
    throw new Error("Dữ liệu đơn hàng không hợp lệ");
  }
  return data; // data: Order
}

console.log(parseOrder('{"id":"A1","customerName":"Lan","total":250000}'));
{ id: 'A1', customerName: 'Lan', total: 250000 }

Điểm quan trọng là dòng const data: unknown = JSON.parse(json). Việc gán kiểu unknown một cách tường minh chặn any từ JSON.parse lan ra ngoài. Từ đây, compiler buộc bạn kiểm tra trước khi dùng data.

Trong dự án lớn, bạn có thể thay type guard viết tay bằng thư viện validate schema như Zod. Nguyên tắc vẫn giữ nguyên: dữ liệu vào là unknown, sau khi validate mới thành kiểu cụ thể.

3.3. unknown trong catch

Với strict: true (cụ thể là cờ useUnknownInCatchVariables), biến trong catch có kiểu unknown:

try {
  parseOrder("{}");
} catch (err) {
  // err: unknown
  const message = err instanceof Error ? err.message : String(err);
  console.log(message);
}
Dữ liệu đơn hàng không hợp lệ

Điều này đúng với thực tế. JavaScript cho phép throw bất cứ thứ gì, kể cả chuỗi hay số, nên bạn không thể giả định err luôn là Error.

4. never: kiểu không có giá trị

never là kiểu rỗng. Không có giá trị nào thuộc kiểu never. Nó xuất hiện ở ba tình huống chính.

4.1. Hàm không bao giờ trả về

Một hàm luôn ném lỗi, hoặc chạy vòng lặp vô hạn, có kiểu trả về never:

function fail(message: string): never {
  throw new Error(message);
}

function getDiscount(code: string): number {
  if (code === "TET2026") return 0.2;
  return fail(`Mã giảm giá không tồn tại: ${code}`);
}

Vì never gán được cho mọi kiểu, return fail(...) hợp lệ trong hàm trả về number. Compiler cũng hiểu rằng code phía sau lời gọi fail() là không thể chạy tới.

4.2. Kiểm tra exhaustive

Đây là cách dùng never hữu ích nhất. Giả sử bạn có union các phương thức thanh toán:

type PaymentMethod =
  | { kind: "card"; last4: string }
  | { kind: "cash" }
  | { kind: "momo"; phone: string };

export function assertNever(value: never): never {
  throw new Error(`Trường hợp chưa xử lý: ${JSON.stringify(value)}`);
}

export function paymentLabel(method: PaymentMethod): string {
  switch (method.kind) {
    case "card":
      return `Thẻ **** ${method.last4}`;
    case "cash":
      return "Tiền mặt";
    case "momo":
      return `MoMo ${method.phone}`;
    default:
      return assertNever(method);
  }
}

Sau khi xử lý đủ ba case, trong nhánh default thì method bị thu hẹp thành never. Lời gọi assertNever(method) vì thế hợp lệ.

Bây giờ, giả sử đồng nghiệp thêm { kind: "zalopay"; phone: string } vào union nhưng quên sửa switch. Compiler báo ngay:

error TS2345: Argument of type '{ kind: "zalopay"; phone: string; }' is not assignable to parameter of type 'never'.

Lỗi được phát hiện lúc compile, thay vì để người dùng gặp nhãn undefined trên màn hình thanh toán. Nếu dữ liệu lạ vẫn lọt vào lúc chạy, ví dụ từ API cũ, assertNever sẽ ném lỗi rõ ràng.

4.3. never trong conditional type

Trong conditional type, never dùng để loại một thành viên khỏi union, vì A | never chính là A:

type OnlyStrings<T> = T extends string ? T : never;

type Mixed = "vnd" | "usd" | 404 | null;
type Currencies = OnlyStrings<Mixed>; // "vnd" | "usd"

TypeScript phân phối conditional type lên từng thành viên của union. Các thành viên 404 và null biến thành never rồi biến mất. Các utility type có sẵn như Exclude, Extract và NonNullable đều hoạt động theo cơ chế này.

5. So sánh nhanh

Khác biệt cốt lõi nằm ở quan hệ gán (assignability):

Gán giá trị khác vào kiểu này Gán kiểu này sang kiểu khác Dùng trực tiếp (gọi method, truy cập thuộc tính)
any Được Được (trừ never) Được, không kiểm tra
unknown Được Chỉ sang unknown, any Không, phải narrowing
never Không (trừ never) Được, sang mọi kiểu Không có giá trị để dùng

Có thể tóm lại như sau:

  • unknown là top type an toàn. Mọi thứ đều là unknown.
  • never là bottom type. never là con của mọi kiểu.
  • any là lối thoát. Nó vừa đóng vai top type vừa đóng vai bottom type, và vì thế phá vỡ tính đúng đắn của hệ thống kiểu.

6. Kiểm thử hành vi

Bạn có thể kiểm tra cả hành vi lúc chạy lẫn kiểu lúc compile bằng Vitest. Cài đặt:

npm install -D vitest typescript

Giả sử các hàm ở mục 3.2 và 4.2 nằm trong order.ts và payment.ts:

// payment.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { isOrder, parseOrder } from "./order";
import { paymentLabel, assertNever } from "./payment";

describe("unknown và type guard", () => {
  it("chấp nhận đơn hàng hợp lệ", () => {
    const order = parseOrder('{"id":"A1","customerName":"Lan","total":1000}');
    expect(order.total).toBe(1000);
    expectTypeOf(order.total).toEqualTypeOf<number>();
  });

  it("từ chối dữ liệu sai kiểu", () => {
    expect(isOrder({ id: "A1", customerName: "Lan", total: "1000" })).toBe(false);
    expect(() => parseOrder("null")).toThrow("Dữ liệu đơn hàng không hợp lệ");
  });
});

describe("never và exhaustive check", () => {
  it("trả về nhãn đúng cho từng phương thức", () => {
    expect(paymentLabel({ kind: "card", last4: "4242" })).toBe("Thẻ **** 4242");
    expect(paymentLabel({ kind: "cash" })).toBe("Tiền mặt");
  });

  it("ném lỗi khi gặp dữ liệu ngoài union lúc chạy", () => {
    const legacy = { kind: "paypal" } as unknown as never;
    expect(() => assertNever(legacy)).toThrow("Trường hợp chưa xử lý");
  });
});
npx vitest run
 ✓ payment.test.ts (4 tests)
 Test Files  1 passed (1)
      Tests  4 passed (4)

expectTypeOf kiểm tra kiểu ở compile time. Nếu một thay đổi nào đó làm order.total trở thành any, test sẽ thất bại khi chạy type check (vitest --typecheck). Test cuối mô phỏng dữ liệu “lạ” từ hệ thống cũ, để chắc chắn lưới an toàn lúc chạy vẫn hoạt động.

7. Lỗi hay gặp và khi nào không nên dùng

Dùng any để “cho qua” lỗi compile. Đây là thói quen tệ nhất. Nếu bạn thật sự chưa biết kiểu, hãy dùng unknown. Hãy bật rule @typescript-eslint/no-explicit-any để bắt các trường hợp này.

Ép kiểu unknown bằng as thay vì narrowing. data as Order chỉ trấn an compiler mà không kiểm tra gì lúc chạy. Về bản chất, nó nguy hiểm không kém any.

const data: unknown = JSON.parse('{"id": 1}');
const order = data as Order; // compile OK
order.customerName.trim();   // TypeError lúc chạy

Dùng unknown cho mọi thứ. unknown dành cho biên hệ thống: input người dùng, response API, catch, dữ liệu deserialize. Bên trong domain, hãy dùng kiểu cụ thể. Một hàm calculateTotal(items: unknown) là thiết kế kém.

Nhầm never với void. void nghĩa là hàm trả về nhưng không có giá trị hữu ích. never nghĩa là hàm không bao giờ trả về. Một hàm log() bình thường phải là void, không phải never.

Quên default trong switch. Thiếu assertNever, TypeScript chỉ báo lỗi nếu hàm có kiểu trả về tường minh và bật noImplicitReturns. Kể cả khi đó, bạn vẫn mất lưới an toàn lúc chạy.

Lưu ý: any vẫn có chỗ dùng hợp lý. Đó là khi migrate dần một codebase JavaScript lớn, hoặc khi viết declaration cho thư viện mà kiểu thực sự quá động. Hãy khoanh vùng nó thật hẹp và để lại comment giải thích lý do.

8. Kết luận

Ba kiểu này có ba vai trò rõ ràng:

  • Dùng unknown cho dữ liệu chưa rõ kiểu ở biên hệ thống, rồi thu hẹp bằng typeof, instanceof, in hoặc type guard.
  • Dùng never cho hàm luôn ném lỗi, cho kiểm tra exhaustive với assertNever, và để lọc union trong conditional type.
  • Tránh any, trừ khi bạn có lý do cụ thể và đã khoanh vùng nó.

Quy tắc thực tế rất đơn giản. Khi định gõ any, hãy thử unknown trước. Nếu compiler báo lỗi, đó chính là những chỗ bạn cần kiểm tra dữ liệu.

typescripttype-safetyunknownneverany