안녕하세요! 👋
저는 @nyaomaru이고, Frontend Engineer로 일하면서 작은 TypeScript OSS 프로젝트도 만들고 있습니다. 😸
오늘은 아주 짧고 익숙한 JavaScript 코드 한 줄에 대해 이야기해 보려고 합니다.
values.filter(Boolean);
아마 한 번쯤은 보셨을 겁니다.
하지만 실제 의도가 다음이라면 어떨까요?
null과undefined만 제거하고 싶다
이 경우 filter(Boolean)은 생각보다 더 많은 값을 제거합니다.
왜 그런지 살펴보겠습니다. 👀

filter(Boolean)의 작은 함정다음과 같은 배열이 있다고 해봅시다.
const values = ["ready", "", 0, false, null, undefined];
이제 “없는 값”만 제거하고 싶다고 해보겠습니다.
const result = values.filter(Boolean);
runtime에서 남는 값은 무엇일까요?
["ready"];
잠깐만요.
우리가 제거하고 싶었던 것은:
null;
undefined;
뿐이었습니다.
하지만 이것들도 함께 사라졌습니다.
"";
0;
false;
왜일까요?
Boolean은 전달받은 값을 boolean으로 변환하고, 결과가 true인 값만 남기기 때문입니다.
즉, 다음을 검사하는 것이 아닙니다.
이 값이
null또는undefined인가?
JavaScript에서 다음 값들은 모두 falsy입니다.
false;
0;
"";
null;
undefined;
NaN;
하지만 아래 두 요구사항은 완전히 다릅니다.
모든 falsy 값을 제거한다
그리고:
null과undefined만 제거한다
실제 애플리케이션에서는 0, false, ""가 모두 정상적인 데이터일 수 있습니다.
예를 들어:
type Settings = {
retryCount: number;
notificationsEnabled: boolean;
nickname: string;
};
다음 값들은 모두 유효할 수 있습니다.
retryCount = 0;
notificationsEnabled = false;
nickname = "";
Falsy는 missing을 의미하지 않습니다.
filter(Boolean)은 runtime에서 falsy 값을 제거하지만, Boolean 자체는 type guard가 아닙니다.
그래서 TypeScript는 일반적으로 filter(Boolean)을 사용했다고 해서 null과 undefined가 제거되었다고 판단하지 못합니다.
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>
즉, runtime 규칙이 정말로:
truthy 값만 남긴다
라면 filter(Boolean)은 전혀 문제가 없습니다.
하지만 다음 의도를 표현하는 코드는 아닙니다.
nullish 값만 제거한다
이 차이는 코드를 읽는 사람에게도 중요하고, TypeScript의 type system에게도 중요합니다.
원하는 규칙이:
null과undefined를 제외한 모든 값을 남긴다
라면 그대로 작성하면 됩니다.
const values: Array<string | null | undefined> = [
"Ada",
null,
"Linus",
undefined,
];
const names = values.filter(
(value): value is string =>
value !== null && value !== undefined,
);
이제:
// names: string[]
이건 아주 평범한 TypeScript입니다.
이 check가 한 번만 필요하다면 굳이 library를 사용할 필요도 없습니다.
value != null은 주의더 짧은 형태도 자주 볼 수 있습니다.
values.filter(
(value): value is string => value != null,
);
일반적인 값만 생각하면 다음과 거의 같은 것처럼 보입니다.
value !== null && value !== undefined
하지만 browser에는 역사적인 호환성 예외가 하나 있습니다.
바로 document.all입니다.
document.all == null; // true
document.all != null; // false
document.all === null; // false
document.all === undefined; // false
document.all은 object입니다.
null도 아니고 undefined도 아닙니다.
하지만 loose equality (== / !=)는 과거 호환성 때문에 이 값에 대해 특별한 동작을 합니다.
그래서 contract가 정말로:
오직
null과undefined만 제거한다
라면 저는 strict comparison을 더 선호합니다.
(value): value is string =>
value !== null && value !== undefined;
일반적인 애플리케이션 코드에서 document.all을 만날 가능성은 거의 없습니다.
그래도 이 edge case는 정확한 nullish guard를 != null로 구현하지 않는 좋은 이유가 됩니다.
흥미로운 지점은 아래 로직이 여러 곳에서 반복되기 시작할 때입니다.
(value): value is string =>
value !== null && value !== undefined;
예를 들면:
이 시점에서 abstraction의 가치는 단순히 몇 글자를 줄이는 데 있지 않습니다.
더 중요한 것은 이 의미에 이름을 붙이는 것입니다.
이 값은 nullish가 아니다.
이럴 때 저는 named type guard를 사용하는 편입니다.
is-kit에서는 이렇게 쓸 수 있습니다.
import { isNotNil } from "is-kit";
const names = values.filter(isNotNil);
TypeScript narrowing도 그대로 유지됩니다.
// names: string[]
그리고 Boolean과 달리 유효한 falsy 값은 사라지지 않습니다.
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>
nullable value는 map 이후에 자주 등장합니다.
예를 들어:
type User = {
id: string;
nickname?: string | null;
};
const users: User[] = [
{ id: "1", nickname: "nyaomaru" },
{ id: "2", nickname: null },
{ id: "3" },
];
존재하는 nickname만 얻고 싶다면:
const nicknames = users
.map((user) => user.nickname)
.filter(isNotNil);
// string[]
이런 경우 reusable type guard가 꽤 자연스럽습니다.
배열 변환 자체는 여전히 평범한 JavaScript입니다.
동시에 TypeScript도 다음 사실을 알 수 있습니다.
여기부터는
null과undefined가 없다.
제 기준은 꽤 단순합니다.
정말 원하는 것이:
truthy 값만 남긴다
라면:
filter(Boolean);
을 사용하면 됩니다.
nullish check가 한 번만 등장한다면:
values.filter(
(value): value is string =>
value !== null && value !== undefined,
);
이런 inline predicate면 충분합니다.
반대로 같은 의미가 여러 곳에서 반복된다면:
values.filter(isNotNil);
같은 reusable guard를 고려할 수 있습니다.
모든 condition을 abstraction으로 만들 필요는 없습니다.
같은 “의미”가 반복될 때 이름을 붙이는 것이 더 중요합니다.
최근 eslint-plugin-is-kit을 공개했습니다.
TypeScript predicate를 위한 type-aware ESLint plugin입니다.
그중 한 rule은 의미가 애매한 filter(Boolean) 호출을 찾아냅니다.
예를 들어:
declare const values: Array<string | null>;
values.filter(Boolean);
여기 element type에는:
null""이 함께 존재합니다.
즉 다음 가능성이 있습니다.
Boolean이 missing value만 제거하는 것이 아니라 유효한 데이터까지 제거할 수 있다.
이 경우 rule이 warning을 낼 수 있습니다.
하지만 모든 filter(Boolean)을 무조건 금지하지는 않습니다.
예를 들어:
declare const values: number[];
values.filter(Boolean);
element type에는 null이나 undefined가 없습니다.
따라서 “nullish 값을 제거하려고 했다”는 근거가 없습니다.
이 rule은 의도적으로 보수적으로 동작합니다.
초기 버전 v0.1.0에는 다음 네 가지 type-aware rule이 있습니다.
filter(Boolean)is-kit predicateisNotNil로 표현할 수 있는 반복적인 inline nullish filter예를 들어:
values.filter(
(value) => typeof value === "string",
);
runtime behavior와 TypeScript narrowing이 동일하게 유지된다면:
values.filter(isString);
처럼 표현할 수 있습니다.
이 plugin은 아직 초기 단계이므로 false positive나 새로운 rule 아이디어가 있다면 피드백을 정말 환영합니다. 😸
사실 이 글에서 가장 중요한 것은 isNotNil 자체가 아닙니다.
핵심은 이것입니다.
Falsy value와 missing value는 같은 것이 아니다.
filter(Boolean)은 나쁜 코드가 아닙니다.
단지 다른 contract를 표현할 뿐입니다.
그래서 다음 코드를 쓰기 전에:
values.filter(Boolean);
한 번 이렇게 생각해볼 수 있습니다.
정말 모든 falsy 값을 제거하고 싶은가?
아니면 nullish 값만 제거하고 싶은가?
이 작은 차이만 의식해도 0, false, ""처럼 완전히 유효한 데이터가 조용히 사라지는 문제를 피할 수 있습니다. 😸
is-kit 문서에도 nullish filtering에 대한 더 자세한 guide를 작성해 두었습니다.
isNil, isNotNil, 그리고 여러 접근 방식을 함께 다룹니다.
https://is-kit.dev/guides/filter-null-and-undefined
작고 reusable한 TypeScript Type Guard에 관심이 있다면 is-kit도 open source로 공개되어 있습니다.
https://github.com/nyaomaru/is-kit
도움이 되었다면 GitHub에서 ⭐도 정말 환영합니다!
읽어주셔서 감사합니다! 🙌