Type Guard của bạn có thể âm thầm lệch khỏi kiểu TypeScript 🔧
Hoi hoi! 👋
Mình là @nyaomaru, một frontend engineer, và mình vừa trở về sau một chuyến đi ngắn đến Texel, một hòn đảo ở Hà Lan. 😸🏝️
Hôm nay mình muốn nói về một Type Guard trông có vẻ hoàn toàn an toàn.
const isUser = (value: unknown): value is User => {
// runtime checks...
};
Trông ổn, đúng không?
TypeScript hiểu rằng khi isUser(value) trả về true, thì giá trị đó là một User.
Nhưng có một vấn đề nhỏ:
TypeScript tin vào lời hứa đó.
Nó không chứng minh rằng các runtime check của bạn thực sự kiểm tra đầy đủ mọi field của User.
Và đó chính là nơi một Type Guard có thể âm thầm lệch khỏi kiểu mà nó được cho là đang bảo vệ.
Cùng xem nhé! 👀

🕳️ Type Guard có thể trở nên lỗi thời mà không tạo ra lỗi compile
Giả sử ban đầu chúng ta có kiểu sau:
type User = {
id: string;
name: string;
};
Và một Type Guard được viết thủ công:
const isUser = (value: unknown): value is User => {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "string" &&
typeof candidate.name === "string"
);
};
Đến đây thì mọi thứ vẫn khớp nhau.
Sau đó, chúng ta cập nhật User:
type User = {
id: string;
name: string;
role: "admin" | "member";
};
Nhưng lại quên cập nhật guard.
const isUser = (value: unknown): value is User => {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "string" &&
typeof candidate.name === "string"
);
};
Không hề có check nào cho role.
Nhưng code vẫn compile bình thường. 😿
🧠 Tại sao TypeScript không phát hiện ra?
Vì đoạn này:
(value: unknown): value is User
là một user-defined type predicate.
Thực chất, bạn đang nói với TypeScript:
Hãy tin tôi. Nếu function này trả về
true, giá trị đó làUser.
TypeScript có thể kiểm tra xem predicate type bạn khai báo có hợp lệ hay không.
Nhưng nhìn chung, nó không thể chứng minh rằng một đoạn runtime logic tùy ý thực sự kiểm tra đầy đủ mọi phần của type đó.
Vì vậy, code như thế này vẫn hợp lệ:
const isUser = (_value: unknown): _value is User => true;
Đây là một guard cực kỳ tệ.
Nhưng với TypeScript thì hoàn toàn hợp lệ. 😹
Return type ở đây là một contract do chúng ta tự viết ra, không phải là một proof được suy ra từ phần implementation.
🔄 Điều này trở thành một vấn đề bảo trì
Phần khó chịu không phải là viết guard một lần.
Mà là giữ cho hai thứ này luôn đồng bộ theo thời gian:
TypeScript type
↕
Runtime validation
Type sẽ thay đổi.
Các property có thể được:
- thêm mới
- xóa đi
- đổi tên
- chuyển thành optional
- đổi sang type khác
Và mỗi khi điều đó xảy ra, chúng ta phải nhớ rằng đâu đó trong codebase có thể tồn tại một runtime guard cũng cần được cập nhật.
Nếu quên, compiler có thể không cảnh báo gì cả.
Đây chính là loại bug mà mình không muốn phải dựa vào trí nhớ để tránh.
✅ Nếu chính type có thể trở thành contract thì sao?
Đó là một trong những lý do mình thêm typedStruct vào is-kit.
Giả sử application type đã tồn tại:
type User = {
id: string;
name: string;
age?: number;
};
Chúng ta có thể xây dựng guard dựa trên type hiện có đó:
import {
isNumber,
isString,
optionalKey,
typedStruct,
} from "is-kit";
const isUser = typedStruct<User>()({
id: isString,
name: isString,
age: optionalKey(isNumber),
});
Bây giờ field map này có một mối quan hệ ở type level với User.
Ở runtime, nó vẫn chỉ thực hiện object validation thông thường.
Nhưng ở compile time, TypeScript có thể kiểm tra xem các guard bạn khai báo có phù hợp với object type mà chúng phải theo dõi hay không.
💥 Bây giờ drift trở nên nhìn thấy được
Hãy thêm một field nữa:
type User = {
id: string;
name: string;
role: "admin" | "member";
age?: number;
};
Nhưng lại quên cập nhật guard:
typedStruct<User>()({
id: isString,
name: isString,
age: optionalKey(isNumber),
// TypeScript error:
// role is missing
});
Tốt.
Một bug có thể chỉ xuất hiện ở runtime giờ đã trở thành vấn đề ở compile time.
Điều tương tự cũng xảy ra nếu bạn dùng một guard không tương thích cho một field:
import {
isNumber,
isString,
oneOfValues,
optionalKey,
typedStruct,
} from "is-kit";
typedStruct<User>()({
id: isString,
name: isNumber,
// TypeScript error:
// User["name"] is string
role: oneOfValues("admin", "member"),
age: optionalKey(isNumber),
});
Đây là phần mình quan tâm nhất.
typedStruct không xóa bỏ công việc bảo trì.
Nó khiến việc quên bảo trì trở nên nhìn thấy được.
🧩 Optional và Nullable là hai khái niệm khác nhau
Một nơi khác rất dễ gây nhầm lẫn khi viết object guard là optional property.
Ví dụ:
type User = {
id: string;
nickname?: string | null;
};
Ở đây thực ra có hai ý tưởng khác nhau:
nickname có thể không tồn tại
và:
nickname có thể tồn tại với giá trị null
Đó là hai runtime contract khác nhau.
Với typedStruct:
import {
isString,
nullable,
optionalKey,
typedStruct,
} from "is-kit";
const isUser = typedStruct<User>()({
id: isString,
nickname: optionalKey(nullable(isString)),
});
Khi đó:
isUser({ id: "user-1" });
// true
isUser({
id: "user-1",
nickname: null,
});
// true
isUser({
id: "user-1",
nickname: "Neko",
});
// true
isUser({
id: "user-1",
nickname: 42,
});
// false
Mình thích giữ hai quyết định này thật rõ ràng:
optionalKey(...)→ property có thể không tồn tạinullable(...)→ value có thể lànull
Nhìn qua thì chúng có vẻ giống nhau, nhưng thực tế chúng mô tả hai thứ khác nhau.
🌳 Nested type cũng không cần phải được khai báo lại
Hãy xem một type lớn hơn:
type Account = {
readonly id: string;
readonly profile: {
readonly displayName: string;
readonly bio: string | null;
} | null;
readonly tags: readonly string[];
};
Chúng ta có thể copy thủ công shape của profile sang một type khác.
Nhưng như vậy lại tạo thêm một thứ có thể bị drift.
Thay vào đó, ta có thể tham chiếu trực tiếp đến type đã tồn tại:
import {
arrayOf,
isString,
nullable,
typedStruct,
} from "is-kit";
const isProfile = typedStruct<
NonNullable<Account["profile"]>
>()({
displayName: isString,
bio: nullable(isString),
});
const isAccount = typedStruct<Account>()({
id: isString,
profile: nullable(isProfile),
tags: arrayOf(isString),
});
Đây là model mình thích:
Tái sử dụng type hiện có ở compile time. Compose các guard nhỏ ở runtime.
Application type vẫn là source of truth mà guard cần theo dõi.
🔒 Còn extra property ở runtime thì sao?
Có một điểm khác cũng rất quan trọng cần phân biệt.
Đây thực ra là hai câu hỏi khác nhau:
- Guard definition của tôi có khớp với TypeScript type không?
- Runtime object có được phép chứa thêm property hay không?
Mặc định, object vẫn có thể có extra key.
Nếu bạn cũng muốn đóng shape của object ở runtime, có thể bật chế độ exact:
import { isString, typedStruct } from "is-kit";
type User = {
id: string;
name: string;
};
const isExactUser = typedStruct<User>()(
{
id: isString,
name: isString,
},
{
exact: true,
},
);
Khi đó:
isExactUser({
id: "user-1",
name: "Ada",
});
// true
isExactUser({
id: "user-1",
name: "Ada",
debug: true,
});
// false
Việc có reject extra property hay không là một runtime policy.
Nó không nên bị nhầm lẫn với việc giữ cho guard definition đồng bộ với TypeScript type.
⚖️ Thứ gì nên là Source of Truth?
Mình không nghĩ có một phong cách validation duy nhất đúng cho mọi project.
Câu hỏi quan trọng hơn là:
Thứ gì đang thực sự sở hữu shape của dữ liệu này?
Manual predicate
const isSomething = (
value: unknown,
): value is Something => {
// custom logic
};
Phù hợp khi validation logic khá đặc biệt hoặc vấn đề không chủ yếu nằm ở object structure.
Guard-first
const isUser = struct({
id: isString,
name: isString,
});
Hữu ích khi chính guard nên định nghĩa resulting type.
Type-first
const isUser = typedStruct<User>()({
id: isString,
name: isString,
});
Hữu ích khi User đã tồn tại và runtime guard cần luôn đồng bộ với nó.
Schema-first
Schema library hoặc code generation có thể là source of truth tốt hơn khi bạn cần những thứ như:
- structured validation errors
- coercion
- transforms
- default values
- generated artifacts
Những approach này giải quyết các vấn đề khác nhau.
Mình không nghĩ mọi boolean check đều cần phải biến thành một schema. 😸
🚫 typedStruct không làm gì?
Có một vài giới hạn quan trọng.
typedStruct không generate runtime validation từ TypeScript type.
TypeScript type bị xóa ở runtime, vì vậy bạn vẫn phải tự khai báo các guard mà mình thực sự muốn chạy.
Nó cũng không:
- chứng minh rằng mọi custom predicate đều đúng
- coercion giá trị
- trả về structured validation errors chi tiết
- thay thế schema-first workflow
- validate numeric property hoặc
symbolproperty như một phần của string-keyed object contract
Nó cố ý nhỏ hơn thế.
Mục tiêu đơn giản chỉ là tạo ra một cây cầu type-safe giữa:
object type mà bạn đã có
và:
runtime guards mà bạn thực sự chọn để chạy
🎯 Điều quan trọng nhất
Điểm chính của bài viết này thực ra không phải là typedStruct.
Mà là:
Type predicate là một lời hứa, không phải là một proof.
Đoạn này:
(value): value is User
không có nghĩa là TypeScript đã kiểm tra implementation và chứng minh rằng mọi field của User đều đã được validate.
Chính chúng ta là người đưa ra lời hứa đó.
Vì vậy, khi một TypeScript type hiện có là source of truth, mình thấy sẽ hữu ích hơn nếu để runtime guard phụ thuộc về mặt cấu trúc vào type đó, thay vì phụ thuộc vào việc developer có nhớ mọi thay đổi trong tương lai hay không.
Đó là điều mình muốn typedStruct giúp giải quyết. 😸
Nếu guard định nghĩa type, hãy dùng guard-first.
Nếu một TypeScript type hiện có nên định nghĩa contract, hãy liên kết guard với type đó.
Và nếu bạn cần parsing phong phú hơn, transform, coercion hoặc error chi tiết, đó là lúc schema bắt đầu đáng với trọng lượng của nó.
Mình cũng đã viết một guide đầy đủ hơn về chủ đề này trong tài liệu của is-kit:
Keep Type Guards in Sync with TypeScript Types | is-kit
Và nếu bạn thích các Type Guard nhỏ, có thể tái sử dụng trong TypeScript, is-kit cũng là open source:
Nếu bài viết này hữu ích với bạn, một ⭐ trên GitHub luôn rất được hoan nghênh!
Cảm ơn bạn đã đọc! 🙌
All rights reserved