Vì sao `filter(Boolean)` không phải là bộ lọc Nullish trong TypeScript 🔧
Xin chào! 👋
Mình là @nyaomaru, một Frontend Engineer và cũng đang xây dựng một vài dự án OSS nhỏ bằng TypeScript. 😸
Hôm nay mình muốn nói về một dòng JavaScript rất ngắn và rất quen thuộc:
values.filter(Boolean);
Có lẽ bạn đã thấy cách viết này rất nhiều lần.
- Ngắn gọn
- Tiện
- Và đôi khi, nó đúng chính xác với điều bạn muốn
Nhưng nếu ý định thực sự của bạn là:
Chỉ loại bỏ
nullvàundefined
thì filter(Boolean) đang làm nhiều hơn thế.
Hãy xem vì sao nhé! 👀

🕳️ Cái bẫy nhỏ của filter(Boolean)
Giả sử chúng ta có một array như sau:
const values = ["ready", "", 0, false, null, undefined];
Bây giờ ta muốn loại bỏ những giá trị “không có”:
const result = values.filter(Boolean);
Ở runtime, kết quả còn lại là gì?
["ready"];
Khoan đã.
Ta chỉ muốn loại bỏ:
null;
undefined;
Nhưng đồng thời ta cũng mất luôn:
"";
0;
false;
Tại sao?
Vì Boolean sẽ chuyển giá trị được truyền vào thành boolean và chỉ giữ lại những giá trị có kết quả là true.
Nó không hề kiểm tra:
Giá trị này có phải là
nullhoặcundefinedkhông?
🤔 Falsy và Nullish không phải là một
JavaScript coi tất cả những giá trị dưới đây là falsy:
false;
0;
"";
null;
undefined;
NaN;
Nhưng hai yêu cầu sau hoàn toàn khác nhau:
Loại bỏ tất cả giá trị falsy
và:
Chỉ loại bỏ
nullvàundefined
Trong ứng dụng thực tế, 0, false và "" hoàn toàn có thể là dữ liệu hợp lệ.
Ví dụ:
type Settings = {
retryCount: number;
notificationsEnabled: boolean;
nickname: string;
};
Tất cả những giá trị sau đều có thể hợp lệ:
retryCount = 0;
notificationsEnabled = false;
nickname = "";
Falsy không đồng nghĩa với “missing”.
🧠 Trong TypeScript cũng có một khác biệt
filter(Boolean) loại bỏ các giá trị falsy ở runtime, nhưng Boolean không phải là một type guard.
Vì vậy, TypeScript thường không thể kết luận rằng null và undefined đã được loại bỏ chỉ vì bạn dùng filter(Boolean).
const values: Array<
string | number | boolean | null | undefined
> = [
"ready",
"",
0,
false,
null,
undefined,
];
const result = values.filter(Boolean);
// result:
// Array<string | number | boolean | null | undefined>
Vì thế, nếu rule ở runtime của bạn thực sự là:
Chỉ giữ lại các giá trị truthy
thì filter(Boolean) hoàn toàn ổn.
Nhưng nó không thể hiện rõ ý định:
Chỉ loại bỏ các giá trị nullish
Điều này đúng cả với người đọc code lẫn type system của TypeScript.
✅ Hãy viết đúng rule mà bạn thực sự muốn
Nếu rule của bạn là:
Giữ lại mọi thứ ngoại trừ
nullvàundefined
thì hãy viết đúng như vậy:
const values: Array<string | null | undefined> = [
"Ada",
null,
"Linus",
undefined,
];
const names = values.filter(
(value): value is string =>
value !== null && value !== undefined,
);
Bây giờ:
// names: string[]
Đây là TypeScript hoàn toàn bình thường.
Nếu check này chỉ xuất hiện một lần, bạn không cần thêm library nào cả.
⚠️ Một edge case của browser: cẩn thận với value != null
Bạn cũng có thể bắt gặp phiên bản ngắn hơn:
values.filter(
(value): value is string => value != null,
);
Với các giá trị thông thường, cách này trông gần như tương đương với:
value !== null && value !== undefined
Tuy nhiên, browser có một ngoại lệ tương thích lịch sử: document.all.
document.all == null; // true
document.all != null; // false
document.all === null; // false
document.all === undefined; // false
document.all là một object.
Nó không phải null, cũng không phải undefined.
Nhưng loose equality (== / !=) có hành vi đặc biệt với trường hợp này vì lý do tương thích lịch sử.
Vì vậy, nếu contract của bạn thực sự là:
Chỉ loại bỏ
nullvàundefined
thì mình thích dùng strict comparison hơn:
(value): value is string =>
value !== null && value !== undefined;
Trong code ứng dụng thông thường, bạn gần như sẽ không gặp document.all.
Nhưng edge case này vẫn là một lý do tốt để một nullish guard chính xác không được implement bằng != null.
🔁 Nếu bạn bắt đầu viết check này nhiều lần thì sao?
Phần thú vị bắt đầu khi logic này xuất hiện lặp đi lặp lại:
(value): value is string =>
value !== null && value !== undefined;
Có thể ở:
- API adapters
- selectors
- UI helpers
- data transformations
- utility functions
Lúc này, giá trị của abstraction không chỉ là tiết kiệm vài ký tự.
Quan trọng hơn là đặt tên cho ý nghĩa:
Giá trị này không phải nullish.
Đó là lúc mình thích dùng một named type guard.
Với is-kit:
import { isNotNil } from "is-kit";
const names = values.filter(isNotNil);
TypeScript vẫn narrow chính xác:
// names: string[]
Và khác với Boolean, các giá trị falsy hợp lệ vẫn được giữ lại.
const values: Array<
string | number | boolean | null | undefined
> = [
"ready",
"",
0,
false,
null,
undefined,
];
const result = values.filter(isNotNil);
// ["ready", "", 0, false]
// result:
// Array<string | number | boolean>
🧩 Một ví dụ gần với ứng dụng thực tế hơn
Các giá trị nullable thường xuất hiện sau map.
Ví dụ:
type User = {
id: string;
nickname?: string | null;
};
const users: User[] = [
{ id: "1", nickname: "nyaomaru" },
{ id: "2", nickname: null },
{ id: "3" },
];
Ta chỉ muốn lấy những nickname thực sự tồn tại:
const nicknames = users
.map((user) => user.nickname)
.filter(isNotNil);
// string[]
Đây là kiểu tình huống mà reusable type guard khá tự nhiên.
Phần biến đổi array vẫn chỉ là JavaScript bình thường.
Đồng thời TypeScript cũng biết rằng:
Từ đây trở đi,
nullvàundefinedđã được loại bỏ.
⚖️ Vậy nên dùng cách nào?
Với mình, tiêu chí khá đơn giản.
Nếu bạn thực sự muốn:
Chỉ giữ lại những giá trị truthy
thì dùng:
filter(Boolean);
Nếu nullish check chỉ xuất hiện một lần:
values.filter(
(value): value is string =>
value !== null && value !== undefined,
);
thì inline predicate là hoàn toàn đủ.
Còn nếu cùng một ý nghĩa bắt đầu lặp đi lặp lại, bạn có thể dùng:
values.filter(isNotNil);
một reusable guard.
Không phải mọi condition đều cần abstraction.
Chỉ khi cùng một “ý nghĩa” bắt đầu lặp lại thì việc đặt tên cho nó mới thực sự có giá trị.
🔍 ESLint có thể bắt được vấn đề này không?
Gần đây mình đã phát hành eslint-plugin-is-kit, một type-aware ESLint plugin dành cho TypeScript predicates.
Một trong các rule của nó tìm những trường hợp filter(Boolean) có thể gây hiểu nhầm.
Ví dụ:
declare const values: Array<string | null>;
values.filter(Boolean);
Element type ở đây chứa cả:
null- và một giá trị falsy nhưng không nullish:
""
Điều đó cho ta một tín hiệu:
Booleancó thể đang loại bỏ nhiều hơn chỉ missing value.
Trong trường hợp đó, rule có thể phát warning.
Nhưng nó không cấm mọi filter(Boolean) một cách máy móc.
Ví dụ:
declare const values: number[];
values.filter(Boolean);
Element type không chứa null hoặc undefined.
Vì vậy, không có bằng chứng rằng ý định của bạn là “loại bỏ nullish”.
Rule này được thiết kế khá bảo thủ.
Phiên bản đầu tiên v0.1.0 có bốn type-aware rules để phát hiện:
- các
filter(Boolean)có thể gây hiểu nhầm - các predicate dư thừa của
is-kit - các inline nullish filter lặp lại có thể dùng
isNotNil - các inline predicate có thể biểu diễn bằng reusable type guard
Ví dụ:
values.filter(
(value) => typeof value === "string",
);
có thể được viết thành:
values.filter(isString);
khi runtime behavior và TypeScript narrowing vẫn được giữ nguyên.
Plugin này vẫn còn khá mới, nên nếu bạn gặp false positive hoặc có ý tưởng cho rule mới, mình rất hoan nghênh feedback. 😸
🎯 Đ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à isNotNil.
Mà là:
Falsy value và missing value không phải là một.
filter(Boolean) không phải là code xấu.
Nó chỉ đang biểu đạt một contract khác.
Vì vậy, trước khi viết:
values.filter(Boolean);
hãy thử tự hỏi:
Mình thực sự muốn loại bỏ tất cả giá trị falsy, hay chỉ muốn loại bỏ các giá trị nullish?
Sự khác biệt nhỏ này có thể giúp bạn tránh việc những dữ liệu hoàn toàn hợp lệ như 0, false và "" biến mất mà không để ý. 😸
Mình cũng đã viết một guide đầy đủ hơn về nullish filtering trong tài liệu của is-kit, bao gồm isNil, isNotNil và một vài cách tiếp cận khác nhau:
https://is-kit.dev/guides/filter-null-and-undefined
Nếu bạn thích các Type Guard nhỏ, reusable và type-safe trong TypeScript, is-kit cũng là open source:
https://github.com/nyaomaru/is-kit
Nếu bài viết hữu ích, một ⭐ trên GitHub luôn rất được hoan nghênh!
Cảm ơn bạn đã đọc! 🙌
All rights reserved