Type Guards & Narrowing

Type Guards & Narrowing

Bạn có một function nhận string | number. Bên trong function, bạn muốn gọi .toUpperCase() trên string và cộng toán tử + trên number. Nhưng TypeScript không cho phép — vì giá trị lúc này vẫn là string | number.

Đó chính là lý do type guards tồn tại. Chúng ta cần "thu hẹp" (narrow) type từ union về một type cụ thể trước khi sử dụng.

#Tại sao narrowing quan trọng?

Nhìn đoạn code này:

function processValue(value: string | number) {
  // ❌ Error: Property 'toUpperCase' does not exist on type 'string | number'
  console.log(value.toUpperCase());
}

TypeScript không biết valuestring hay number ở thời điểm compile. Nếu bạn muốn an toàn, bạn phải kiểm tra runtime trước.

#1. typeof — cách đơn giản nhất

function processValue(value: string | number) {
  if (typeof value === "string") {
    // ✅ TypeScript narrow thành string ở đây
    console.log(value.toUpperCase());
  } else {
    // ✅ TypeScript narrow thành number
    console.log(value.toFixed(2));
  }
}

typeof hoạt động với các primitive types: string, number, boolean, symbol, bigint, undefined, function. Nó không hoạt động với null (typeof null === "object") và không phân biệt được các object types khác nhau.

function logValue(x: string | null | undefined) {
  if (typeof x === "string") {
    console.log(x.length); // ✅ string
  } else if (x === null) {
    console.log("null"); // ✅ null
  } else {
    console.log("undefined"); // ✅ undefined
  }
}

#2. Truthiness narrowing

JavaScript có khái niệm "falsy" — các giá trị như 0, "", null, undefined, NaN, false đều coerce thành false. TypeScript dùng điều này để narrow:

function printName(name: string | null | undefined) {
  if (name) {
    // ✅ name là string (loại bỏ null, undefined, và "")
    console.log(name.toUpperCase());
  }
}

Cẩn thận với số 0:

function printCount(count: number | null) {
  if (count) {
    // ❌ BUG: count = 0 bị bỏ qua vì 0 là falsy
    console.log(`Count: ${count}`);
  }
}
 
// ✅ Cách đúng cho số:
function printCountSafe(count: number | null) {
  if (count !== null) {
    console.log(`Count: ${count}`); // 0 vẫn được xử lý
  }
}

#3. Equality narrowing

So sánh strict equality (===, !==) cũng narrow type:

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // ✅ Cả hai đều là string (intersection của string|number và string|boolean)
    x.toUpperCase();
    y.toUpperCase();
  }
}

Kiểm tra !== null!== undefined là pattern phổ biến:

function doSomething(x: string | null) {
  if (x !== null) {
    // ✅ x là string
    x.toUpperCase();
  }
}

#4. in operator

in kiểm tra property có tồn tại trong object không:

type Fish = { swim: () => void };
type Bird = { fly: () => void };
 
function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    // ✅ animal là Fish
    animal.swim();
  } else {
    // ✅ animal là Bird
    animal.fly();
  }
}

Đây là cách rất tự nhiên khi bạn có hai type với method khác nhau. Không cần chia sẻ field nào — chỉ cần mỗi type có property riêng biệt.

#5. instanceof

instanceof kiểm tra prototype chain — hữu ích với class:

function logDate(value: Date | string) {
  if (value instanceof Date) {
    // ✅ value là Date
    console.log(value.toISOString());
  } else {
    // ✅ value là string
    console.log(value.toUpperCase());
  }
}

Lưu ý: instanceof chỉ hoạt động với class instances, không hoạt động với type aliases hay interfaces.

interface Cat { meow(): void }
interface Dog { bark(): void }
 
// ❌ Không thể dùng instanceof với interface
// if (animal instanceof Cat) { ... } // Error

#6. User-defined type guard: x is T

Đây là kiếm sắc nhất trong bộ công cụ type guard. Khi built-in guards không đủ, bạn tự viết logic kiểm tra và "hứa" với TypeScript rằng type đã được narrowed.

interface Fish {
  kind: "fish";
  swim: () => void;
}
 
interface Bird {
  kind: "bird";
  fly: () => void;
}
 
// Return type đặc biệt: pet is Fish
function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}
 
function move(pet: Fish | Bird) {
  if (isFish(pet)) {
    pet.swim(); // ✅ TypeScript tin bạn — pet là Fish
  } else {
    pet.fly(); // ✅ pet là Bird
  }
}

pet is Fishtype predicate. Nếu function return true, TypeScript narrow biến sang type Fish.

Với generic type guard:

function isArray<T>(value: unknown): value is T[] {
  return Array.isArray(value);
}
 
const data: unknown = [1, 2, 3];
if (isArray<number>(data)) {
  // ✅ data là number[]
  data.map((n) => n * 2);
}

#7. Assertion functions: asserts x is T

Assertion function không return boolean — nó throw error nếu điều kiện không đúng. Nếu không throw, TypeScript coi như type đã được narrowed.

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new Error(`Expected string, got ${typeof value}`);
  }
}
 
function process(input: unknown) {
  assertIsString(input);
  // ✅ Từ đây, input là string (nếu không bị throw)
  console.log(input.toUpperCase());
}

Khác biệt với type guard thông thường:

// Type guard — caller phải tự check
if (isString(input)) {
  input.toUpperCase(); // ✅ chỉ narrow trong if block
}
 
// Assertion function — tự narrow sau dòng gọi
assertIsString(input);
input.toUpperCase(); // ✅ narrow ngay tại đây

#8. Control flow analysis

TypeScript phân tích control flow để narrow type tự động:

function example() {
  let x: string | number | boolean;
 
  x = Math.random() > 0.5 ? "hello" : 42;
 
  if (typeof x === "string") {
    x.toUpperCase(); // ✅ string
  }
 
  x = true;
 
  x; // ✅ boolean — TypeScript biết x đã được gán lại
 
  if (typeof x === "boolean") {
    x.valueOf(); // ✅ boolean
  }
}

TypeScript theo dõi việc gán lại giá trị (assignment) và narrow chính xác sau mỗi lần gán.

Lưu ý với closures:

function example() {
  const x: string | number = Math.random() > 0.5 ? "hello" : 42;
 
  if (typeof x === "string") {
    const fn = () => {
      x.toUpperCase(); // ✅ Vẫn narrow — TypeScript biết x không thể thay đổi (const)
    };
  }
}

#Anti-pattern: Dùng as thay vì narrowing đúng cách

Đây là sai lầm phổ biến nhất:

// ❌ Anti-pattern: dùng as vì lười viết type guard
function getLength(value: string | string[]) {
  return (value as string).length; // 💀 Runtime error nếu value là string[]
}
 
// ❌ Anti-pattern: as any
function process(data: unknown) {
  (data as any).name.toUpperCase(); // 💀 Không type safety
}
 
// ✅ Cách đúng
function getLength(value: string | string[]) {
  if (typeof value === "string") {
    return value.length;
  }
  return value.length;
}
 
// ✅ Hoặc dùng type guard
function process(data: unknown) {
  assertIsString(data);
  data.toUpperCase();
}

as là cách bạn nói "tin tôi đi, tôi biết type". Nhưng bạn có thể sai. Type guard là cách bạn chứng minh type tại runtime.

Khi nào as chấp nhận được? Khi bạn narrow một type mà TypeScript không thể suy luận được — nhưng luôn viết comment giải thích tại sao.

// as chấp nhận được khi bạn biết nhiều hơn compiler
// và đã verify ở một nơi khác (ví dụ: sau JSON.parse với schema validation)
const config = JSON.parse(raw) as AppConfig; // OK nếu bạn validate ngay sau đó

#Tổng kết

  • typeof — narrow primitive types (string, number, boolean...)
  • Truthiness — loại bỏ null, undefined, "", 0, NaN; cẩn thận với falsy values
  • Equality===, !== narrow chính xác
  • in — kiểm tra property tồn tại, hữu ích cho object types khác nhau
  • instanceof — kiểm tra class instances
  • User-defined type guard (x is T) — viết logic kiểm tra tùy ý, return boolean
  • Assertion function (asserts x is T) — throw nếu không đúng, narrow sau dòng gọi
  • Control flow analysis — TypeScript tự narrow theo nhánh code
  • Anti-pattern lớn nhất: dùng as thay vì narrowing — bạn bypass type safety

Bài tiếp theo: Discriminated Unions — pattern nâng cao kết hợp literal types với exhaustive switch để tạo code an toàn tuyệt đối.