DEV Community

Cover image for Why filter(Boolean) Is Not a Nullish Filter in TypeScript ๐Ÿ”ง
nyaomaru
nyaomaru

Posted on Originally published at is-kit.dev AI-assisted

Why filter(Boolean) Is Not a Nullish Filter in TypeScript ๐Ÿ”ง

Hoi hoi! ๐Ÿ‘‹

I'm @nyaomaru, a frontend engineer building small TypeScript OSS with shoyu ramen. ๐Ÿ˜ธ๐Ÿœ

Today, let's look at a very small line of JavaScript.

values.filter(Boolean);
Enter fullscreen mode Exit fullscreen mode

You've probably seen it before.

  • It's short.
  • It's convenient.
  • And sometimes, it's exactly what you want.

But if your intention is

Remove only null and undefined

then filter(Boolean) is doing more than you asked.

Let's see why! ๐Ÿ‘€


๐Ÿ•ณ๏ธ The Small Trap in filter(Boolean)

Imagine we have this array.

const values = ["ready", "", 0, false, null, undefined];
Enter fullscreen mode Exit fullscreen mode

Suppose we try to remove the missing values like this.

const result = values.filter(Boolean);
Enter fullscreen mode Exit fullscreen mode

At runtime, what remains?

["ready"];
Enter fullscreen mode Exit fullscreen mode

Wait.

We only wanted to remove

null;
undefined;
Enter fullscreen mode Exit fullscreen mode

But we also lost

"";
0;
false;
Enter fullscreen mode Exit fullscreen mode

Why?

Because Boolean converts its argument to a boolean and keeps only values
whose result is true.

It isn't checking whether a value is nullish.


๐Ÿค” Falsy and Nullish Are Different

JavaScript considers all of these values falsy

false;
0;
("");
null;
undefined;
NaN;
Enter fullscreen mode Exit fullscreen mode

But these two requirements are different

Remove every falsy value

and

Remove null and undefined

In real applications, 0, false, and '' can all be completely valid data.

For example

type Settings = {
  retryCount: number;
  notificationsEnabled: boolean;
  nickname: string;
};
Enter fullscreen mode Exit fullscreen mode

These can all be valid

retryCount = 0;
notificationsEnabled = false;
nickname = "";
Enter fullscreen mode Exit fullscreen mode

Falsy does not mean missing.


๐Ÿง  There Is a TypeScript Difference Too

filter(Boolean) removes falsy values at runtime, but Boolean is not a type guard.

TypeScript therefore does not generally know that null and undefined are gone.

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>
Enter fullscreen mode Exit fullscreen mode

So filter(Boolean) can be right when truthiness is the runtime rule, but it doesn't express a nullish-removal rule to either JavaScript readers or the TypeScript type system. ๐Ÿ™€


โœ… Say What You Actually Mean

If the rule is

Keep everything except null and undefined

we can write exactly that

const values: Array<string | null | undefined> = [
  "Ada",
  null,
  "Linus",
  undefined,
];

const names = values.filter(
  (value): value is string => value !== null && value !== undefined,
);
Enter fullscreen mode Exit fullscreen mode

Now

// names: string[]
Enter fullscreen mode Exit fullscreen mode

This is perfectly good TypeScript.

You don't need a library for a one-off check.

โš ๏ธ A browser edge case: avoid value != null

You may also see this shorter version

values.filter((value): value is string => value != null);
Enter fullscreen mode Exit fullscreen mode

For ordinary values, it looks equivalent to checking both null and
undefined. In browsers, though, document.all is a historical compatibility exception.

document.all == null; // true
document.all != null; // false

document.all === null; // false
document.all === undefined; // false
Enter fullscreen mode Exit fullscreen mode

document.all is an object, not null or undefined, but loose equalityใ€€treats it as nullish. So if the contract is only โ€œremove null andใ€€undefinedโ€, use strict comparisons.

(value): value is string => value !== null && value !== undefined;
Enter fullscreen mode Exit fullscreen mode

You will rarely encounter this in application code, but the edge case is why a precise nullish guard should not be implemented with != null.

See MDN's equality operator reference for the compatibility rule behind this behavior.


๐Ÿ” What If You Keep Writing It?

The interesting part starts when the same meaning appears repeatedly.

(value): value is string => value !== null && value !== undefined;
Enter fullscreen mode Exit fullscreen mode

Maybe in

  • API adapters
  • selectors
  • UI helpers
  • mapped data
  • utility functions

At that point, the useful abstraction isn't really the syntax.

It's the meaning

This value is not nullish.

That's where I like using a named type guard.

With is-kit

import { isNotNil } from "is-kit";

const names = values.filter(isNotNil);
Enter fullscreen mode Exit fullscreen mode

The result still narrows naturally

// names: string[]
Enter fullscreen mode Exit fullscreen mode

And unlike Boolean, valid falsy values stay intact.

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>
Enter fullscreen mode Exit fullscreen mode

๐Ÿงฉ A Practical Example

Nullable values often appear after map.

type User = {
  id: string;
  nickname?: string | null;
};

const users: User[] = [
  { id: "1", nickname: "nyaomaru" },
  { id: "2", nickname: null },
  { id: "3" },
];
Enter fullscreen mode Exit fullscreen mode

We want only the existing nicknames.

const nicknames = users.map((user) => user.nickname).filter(isNotNil);

// string[]
Enter fullscreen mode Exit fullscreen mode

This is the kind of place where a reusable guard feels natural to me.

The array transformation stays ordinary JavaScript, while TypeScript knows
that the nullish values are gone.


โš–๏ธ Which One Should You Use?

I think the choice is pretty simple.

Use

filter(Boolean);
Enter fullscreen mode Exit fullscreen mode

when you genuinely want

Keep only truthy values.

Use an inline predicate when the nullish check appears once:

values.filter(
  (value): value is string => value !== null && value !== undefined,
);
Enter fullscreen mode Exit fullscreen mode

And use a reusable guard such as

values.filter(isNotNil);
Enter fullscreen mode Exit fullscreen mode

when that same meaning appears repeatedly.

There is no need to turn every condition into an abstraction.


๐Ÿ” Can ESLint Catch This?

I recently released eslint-plugin-is-kit, a type-aware ESLint plugin for TypeScript predicates.

One of its rules looks specifically for ambiguous filter(Boolean) calls.

declare const values: Array<string | null>;

values.filter(Boolean);
Enter fullscreen mode Exit fullscreen mode

Because the element type contains both null and a non-nullish falsy value (""), the plugin can warn that Boolean may remove more than just the missing value.

But it does not simply ban filter(Boolean).

declare const values: number[];

values.filter(Boolean);
Enter fullscreen mode Exit fullscreen mode

There is no null or undefined in the element type, so there is no evidence that nullish removal was intended.

The rule is deliberately conservative.

The initial v0.1.0 release includes four type-aware rules for:

  • ambiguous filter(Boolean) calls
  • redundant is-kit predicates
  • repeated inline nullish filters that could use isNotNil
  • inline predicates that can be expressed with reusable type guards

For example

values.filter((value) => typeof value === "string");
Enter fullscreen mode Exit fullscreen mode

can be expressed as

values.filter(isString);
Enter fullscreen mode Exit fullscreen mode

when doing so preserves the runtime behavior and useful TypeScript narrowing.

If this kind of check would be useful in your codebase:

It's still an early release, so false positives and ideas for useful rules are very welcome. ๐Ÿ˜ธ


๐ŸŽฏ The Important Part

The main point isn't really isNotNil.

It's this

Falsy values and missing values are not the same thing.

filter(Boolean) is not bad code.

It just expresses a different requirement.

So before writing

values.filter(Boolean);
Enter fullscreen mode Exit fullscreen mode

ask

Do I want to remove falsy values, or only nullish values?

That tiny distinction can prevent valid data like 0, false, and '' from disappearing unexpectedly. ๐Ÿ˜ธ

I also wrote a more complete guide about nullish filtering on the is-kit
documentation site
, including isNil, isNotNil, and the different
approaches.

If you like small reusable TypeScript type guards, is-kit is open source too! And don't forget to put a star! โญ

GitHub logo nyaomaru / is-kit

Build small guards. Compose them. Lightweight, zero-dependency TypeScript type guards for runtime validation and natural narrowing. Runtime-safe ๐Ÿ›ก๏ธ, composable ๐Ÿงฉ, and ergonomic โœจ.

is-kit

is-kit logo

npm version JSR npm downloads License

Build small guards. Compose them.

is-kit is a lightweight, zero-dependency toolkit for building reusable TypeScript type guards.

It helps you write small isFoo functions, compose them into richer runtime checks, and keep TypeScript narrowing natural inside regular control flow.

Runtime-safe ๐Ÿ›ก๏ธ, composable ๐Ÿงฉ, and ergonomic โœจ without asking you to adopt a heavy schema workflow.

  • Build and reuse typed guards
  • Compose guards with and, or, not, oneOf
  • Validate object shapes and collections
  • Parse or assert unknown values without a large schema framework

๐Ÿ“š Documentation Site ยท ๐Ÿงญ Practical Guides

Best for app-internal narrowing, filtering, and reusable guards.

๐Ÿค” Why use is-kit?

Tired of rewriting the same isFoo checks again and again?

is-kit is a good fit when you want to:

  • write reusable isX functions instead of one-off inline checks
  • keep runtime validation lightweight and dependency-free
  • narrow values directly in if, filterโ€ฆ

Thanks for reading! ๐Ÿ™Œ

Top comments (26)

Collapse
 
mudassirworks profile image
Mudassir Khan •

the 'falsy does not mean missing' line is the one that makes this click. the footgun version of this we hit was in form state: retryCount = 0 was valid, but our normalization layer ran filter(Boolean) to strip empty fields before sending. form had 'off by default, 0 is off too' semantics โ€” same as your notificationsEnabled = false case. the type guard version caught it because it made the programmer state the actual rule.

the TypeScript type not narrowing is the part i'd add to the footgun warning: if the TS compiler doesn't narrow, your reviewers won't either, and the next person to read that filter call will assume it's nullish only because that's the common mental model. does your OSS do anything to surface that distinction at the call site, like a lint rule or a named utility?

Collapse
 
nyaomaru profile image
nyaomaru •

Exactly! ๐Ÿ˜ธ
This form-state example is a perfect footgun. It looks harmless until 0 or false is actually meaningful data.

And I agree with your TypeScript point too: if the compiler canโ€™t express the distinction, the intent is much easier for reviewers to miss as well.

For is-kit, I currently try to surface that in two ways: a guide that explains the falsy !== nullish distinction, and an ESLint rule that can warn on ambiguous filter(Boolean) usage when the type suggests valid falsy values may be removed.

Thatโ€™s exactly the kind of bug I wanted the rule to catch. ๐Ÿ˜ธ

Collapse
 
mudassirworks profile image
Mudassir Khan •

the ESLint rule approach is exactly right โ€” linter coverage is the only thing that scales past "read the guide once and forget it six months later." we've got a custom rule for this too but the false positives were brutal until we scoped it down to fields typed number | null | undefined specifically. that's where the 0 is valid data scenario actually bites you.

is is-kit published yet? would genuinely add it to the stack.

Thread Thread
 
nyaomaru profile image
nyaomaru •

Yeah, exactly! ๐Ÿ˜ธ
lint rules are tricky because what looks safe in theory can get noisy very quickly in a real codebase.

Your number | null | undefined example makes a lot of sense too. Thatโ€™s a very concrete case where 0 is valid data and filter(Boolean) can quietly change the meaning.

And yes, is-kit is already published! Itโ€™s also being used in production applications now. ๐Ÿ˜ธ

Really happy to hear youโ€™d consider adding it to your stack!

Thread Thread
 
mudassirworks profile image
Mudassir Khan •

the is-kit publish is the part that changes the math here. a zero dep utility that solves one real type contract is almost always worth the install over reaching for a custom rule that no one outside your repo can reuse.

shipping your own custom eslint rule works until another team joins the codebase and has to trust it. a published package has a changelog.

how are you handling the Maybe<T> case โ€” where the value is valid but intentionally absent vs truly unset?

Thread Thread
 
nyaomaru profile image
nyaomaru •

Thatโ€™s a great question. ๐Ÿฑ

I try not to collapse those states when they carry different meaning.

With a guard-first approach

const isUser = struct({
  nickname: optionalKey(nullable(isString))
});
Enter fullscreen mode Exit fullscreen mode

Here, a missing nickname key is valid, and a present nickname: null is also valid.
But a present nickname: undefined is not. ๐Ÿ˜ธ

So optionalKey(...) models key-level absence, while nullable(...) models an explicitly present null value.

If the TypeScript type already exists, the same contract can be tied to it with typedStruct

type User = {
  nickname?: string | null;
};

const isUser = typedStruct<User>()({
  nickname: optionalKey(nullable(isString))
});
Enter fullscreen mode Exit fullscreen mode

The difference is mainly the source of truth.
struct is guard-first, while typedStruct is type-first and helps keep the runtime guard in sync with an existing TypeScript type.

If undefined, null, and a missing key all mean different things in the domain, I prefer to keep those states explicit rather than flattening them into one Maybe<T> rule. ๐Ÿ‘

Thread Thread
 
mudassirworks profile image
Mudassir Khan •

the optionalKey / nullable composition is exactly where most custom validators collapse. we've been using a similar "missing vs null carry different intent" split in zod โ€” optional().nullable() is the shape, but the tricky part is whether the parser preserves the distinction at the output type level.

curious what is-kit does here. if the result type is string | null | undefined, the absent key vs explicit null is indistinguishable at the output side. does the library track the field's presence separately, or is the intent only preserved in the input schema?

Thread Thread
 
nyaomaru profile image
nyaomaru •

is-kit doesnโ€™t parse or transform the object into a separate output shape, so it doesnโ€™t track presence in an additional tagged state.

With

optionalKey(nullable(isString))
Enter fullscreen mode Exit fullscreen mode

the inferred object shape is

{ nickname?: string | null }
Enter fullscreen mode Exit fullscreen mode

So the key-level optionality is still preserved separately from the value type.

At runtime:

  • missing key โ†’ valid
  • nickname: null โ†’ valid
  • nickname: "nya" โ†’ valid
  • nickname: undefined โ†’ invalid

If you access value.nickname, TypeScript naturally gives string | null | undefined because reading an optional property also has to account for absence.

But the original object is not transformed, so key presence can still be checked separately at runtime. safeParse also returns the same accepted value rather than producing a normalized output.

So the distinction is preserved in the object shape and runtime contract, but not as a separate presence-tracking output representation. ๐Ÿ˜ธ

Collapse
 
johnnylemonny profile image
๐—๐—ผ๐—ต๐—ป •

Great breakdown!

Iโ€™ve seen filter(Boolean) used everywhere, and this article is a perfect reminder that โ€œfalsyโ€ isnโ€™t the same as โ€œnullish.โ€ The TypeScript angle makes it even clearer. Thanks for highlighting the nuance - super useful!

Collapse
 
nyaomaru profile image
nyaomaru •

Thanks! Iโ€™m really glad it was useful. ๐Ÿ˜ธ

Itโ€™s such a small thing, but definitely an easy trap to miss. Especially because filter(Boolean) looks so clean at first glance!

Collapse
 
mihai_leanzero profile image
Mihai Perdum •

nyaomaru, the eslint-plugin-is-kit angle is the right fix here, since Boolean used as a predicate doesn't narrow a type to NonNullable in the type system, it just happens to be correct in the trivial case where no other falsy values exist. Curious whether the rule reaches generic call sites, something like a helper function compact<T>(xs: T[]) { return xs.filter(Boolean); } where the caller instantiates T as string | null -- that feels like the one place I'd expect a false negative, since the union isn't visible at the filter call itself, only after the generic gets instantiated at the call site.

Collapse
 
nyaomaru profile image
nyaomaru •

Thatโ€™s a really good edge case, and yes.
The current rule intentionally misses it. ๐Ÿฑ

At the filter(Boolean) call site, the element type is only T, so the rule doesnโ€™t try to follow later generic instantiations such as T = string | null.

Doing that would require analysis beyond the local filter call, which Iโ€™ve avoided so far to keep the rule conservative.

One thing I may explore, though, is inspecting generic constraints when they already expose the risk, e.g. T extends string | null.

Thanks for pointing this out! ๐Ÿ‘€
github.com/nyaomaru/eslint-plugin-...

Collapse
 
mudassirworks profile image
Mudassir Khan •

the settings type example is the right way to make this click. retryCount: 0 and notificationsEnabled: false are completely valid, so filtering them out is a bug that passes code review every time because the test data is usually "ada", "linus", never an empty string or a zero.

the document.all gotcha is one i had not seen written up this clearly before. good reason to always use strict comparisons for the nullish check even when the loose version looks equivalent.

do you see any case where you'd still reach for filter(Boolean) over isNotNil even when working in a TypeScript file? i'm trying to think of one and can't.

Collapse
 
nyaomaru profile image
nyaomaru •

Thanks๐Ÿ˜ธ
Iโ€™m glad the Settings example made the distinction clear!

I can still think of practical cases where Iโ€™d use filter(Boolean), but only when I genuinely want truthy filtering.

For example, in an e-commerce app I might build active search labels like this

const filters = [
  category?.trim(),
  brand?.trim(),
  searchQuery.trim(),
].filter(Boolean);
Enter fullscreen mode Exit fullscreen mode

Here, an empty string is intentionally not meaningful, so removing it is exactly what I want.

Another common example is conditional class names

const className = [
  "product-card",
  isSelected && "selected",
  isDisabled && "disabled",
]
  .filter(Boolean)
  .join(" ");
Enter fullscreen mode Exit fullscreen mode

In that case, removing false is also intentional.

So Iโ€™d still use filter(Boolean) when falsy itself means โ€œdiscard thisโ€.
I just wouldnโ€™t use it as shorthand for โ€œremove only null and undefined.โ€ ๐Ÿ˜ธ

Collapse
 
mihai_leanzero profile image
Mihai Perdum •

This is exactly the shape of bug that hides in a Forge post-function validator. Config goes through filter(Boolean) before validation. A retryCount of 0 or a notificationsEnabled of false just vanishes, nobody notices until something downstream defaults wrong. The type stays Array after the filter. TS never complains. I write the predicate every time now, even for a one-off check, since the narrowing has to be intentional or it doesnt happen at all.

Collapse
 
nyaomaru profile image
nyaomaru •

Thanks for sharing that example! ๐Ÿ˜ธ

I think itโ€™s really important to understand the exact runtime behavior here. When the narrowing actually matters, using an explicit type guard makes the intent much clearer, both for TypeScript and for the next person reading the code.

That 0 / false case is exactly where filter(Boolean) can become a very subtle bug. ๐Ÿ‘€

Collapse
 
technogamerz profile image
๐“๐ก๐ž ๐‹๐š๐ณ๐ฒ ๐†๐ข๐ซ๐ฅ •

Hi

Collapse
 
nyaomaru profile image
nyaomaru •

Hi ๐Ÿ˜ธ

Collapse
 
technogamerz profile image
๐“๐ก๐ž ๐‹๐š๐ณ๐ฒ ๐†๐ข๐ซ๐ฅ •

Hello ๐Ÿ™ƒ how are you ๐Ÿ‘‹๐Ÿป

Thread Thread
 
nyaomaru profile image
nyaomaru •

I'm doing well! How about you? ๐Ÿฑ

Thread Thread
 
technogamerz profile image
๐“๐ก๐ž ๐‹๐š๐ณ๐ฒ ๐†๐ข๐ซ๐ฅ •

I'm doing well too! BTW nice write-up :D

Thread Thread
 
nyaomaru profile image
nyaomaru •

Thx! Glad you liked it ๐Ÿ˜ธ

Collapse
 
merbayerp profile image
Mustafa ERBAY •

Nice breakdown. I especially liked that you didnโ€™t frame filter(Boolean) as โ€œbadโ€, but as expressing a different contract.

That distinction matters more than the syntax itself. 0, false, and "" disappearing isnโ€™t a JavaScript bug โ€” itโ€™s usually a requirements bug hiding behind a convenient idiom. ๐Ÿ˜„

The document.all edge case was a nice touch too. Most discussions stop at value != null and call it a day.

And turning the repeated pattern into a type-aware ESLint rule is probably the most interesting part here. Once intent can be checked mechanically, it stops being just a style preference and starts becoming an enforceable contract.

Nice work. ๐Ÿ‘

Collapse
 
nyaomaru profile image
nyaomaru •

Thank you! ๐Ÿฑ
Exactly, I donโ€™t think filter(Boolean) itself is bad.
Itโ€™s convenient way, but the contract it expresses can be easy to misread.

If TypeScript can help us make that intent explicit and catch mismatches statically, I think that makes the pattern much safer to use.

And Iโ€™m glad you liked the ESLint angle too. ๐Ÿ˜ธ

Collapse
 
mihai_leanzero profile image
Mihai Perdum •

filter(Boolean) not narrowing null/undefined out is right, but the "TypeScript doesn't generally know" part is a little dated now. Since TS 5.5 the compiler infers a type predicate for an inline filter(v => v !== null && v !== undefined) (or v != null) on its own โ€” no explicit value is T annotation needed. It's basically the same map().filter(undefined-check) example from the 5.5 release notes. The neat part is the same feature explicitly does NOT extend to truthiness checks: filter(v => !!v) and filter(Boolean) still infer a plain boolean, not a predicate, because "if false, x is not T" fails for those โ€” which is exactly your falsy-vs-nullish point in type-checker terms. isNotNil is still worth having as a name for the repeated case, but for a one-off nullish filter on 5.5+ you can drop the explicit guard signature and TypeScript will narrow it for you.

Collapse
 
nyaomaru profile image
nyaomaru •

Great point! ๐Ÿฑ
Youโ€™re right that since TypeScript 5.5, an inline nullish check can infer the type predicate automatically, so the explicit value is T annotation isnโ€™t required.

Iโ€™m still keeping the explicit predicate in the article examples mainly for readability, because it makes the intended narrowed type obvious at the call site.

So Iโ€™ll clarify that itโ€™s optional on 5.5+, rather than implying itโ€™s necessary.

And yes, the truthiness distinction is exactly the interesting part here, filter(Boolean) / !!value still donโ€™t give the same guarantee.

Thanks for pointing it out! ๐Ÿ˜ธ