Yesterday, as I was working on a CORS configuration, AI generated a block of code for me:
const allowedOrigins = [
process.env.FRONTEND_URL || "http://localhost:3000",
process.env.ADMIN_URL || "http://localhost:3001",
].filter(Boolean);
I was wondering... why use .filter(Boolean) here? 🤔 The fallbacks already guarantee strings.
So I hovered on the variable. The type definition read:
const allowedOrigins: string[]
Fine. Made sense. But then I got curious. What if I removed the hardcoded fallbacks?
const allowedOrigins = [
process.env.FRONTEND_URL,
process.env.ADMIN_URL,
].filter(Boolean);
My type definition changed to:
const allowedOrigins: (string | undefined)[]
I was shocked. I just filtered the array. How can TypeScript still think there's an undefined in there?
First: What Does .filter(Boolean) Even Do?
Boolean used as a filter function removes any falsy value from an array:
false
null
undefined
0
""
NaN
So:
["https://app.com", "", undefined].filter(Boolean)
// Result: ["https://app.com"]
At runtime, this works exactly as you'd expect. No undefined survives. So why does TypeScript disagree? 🤷♀️
The Real Answer: TypeScript Doesn't Run Your Code
TypeScript is a transpiler. It doesn't execute .filter(Boolean) -> it only looks at types.
When it sees this:
array.filter(Boolean)
It knows the callback returns a boolean. But it doesn't know what that means for the type of the elements that survive. It can't infer "if Boolean(x) is true, then x must be a string." So the undefined stays in the type — even though it'll never actually be there at runtime.
That's the gap: your runtime behavior is correct, but your types are lying.
The Fix: Type Predicates
TypeScript lets you close that gap with a type predicate, a way of explicitly telling the compiler what a filter function guarantees:
const allowedOrigins = [
process.env.FRONTEND_URL,
process.env.ADMIN_URL,
].filter((origin): origin is string => Boolean(origin));
// Type: string[] ✅
The origin is string part is the predicate. It's a promise to the compiler: "if this function returns true, the value is definitely a string." TypeScript trusts that and narrows the type accordingly.
The Reusable Helper
If you're doing this pattern often across a codebase, pull it into a small utility:
function isDefined<T>(value: T | undefined | null): value is T {
return value != null;
}
Then:
const allowedOrigins = [
process.env.FRONTEND_URL,
process.env.ADMIN_URL,
].filter(isDefined);
// Type: string[] ✅
Reusable, self-documenting, and sexy 😍. I personally prefer this.
Back to the Original Code
So why did the AI-generated version with the || fallbacks give string[] without needing a predicate?
const allowedOrigins = [
process.env.FRONTEND_URL || "http://localhost:3000",
process.env.ADMIN_URL || "http://localhost:3001",
].filter(Boolean);
Because process.env.X || "fallback" always evaluates to a string. The fallback string covers the undefined case, so TypeScript already knows every element is a string before the filter runs. The .filter(Boolean) there is just a defensive move... useful if someone later adds an entry without a fallback, but not needed for type correctness.
Quick Reference
-
.filter(Boolean)- type def:
(string | undefined)[] - Use when: You don't care about the resulting type.
- type def:
-
.filter((x): x is string => Boolean(x))- Type def:
string[] - Use when: Inline, one-off.
- Type def:
-
.filter(isDefined)- Type def:
string[] - Use when: Reusable across a codebase.
- Type def:
-
process.env.X || "fallback"- Type def:
string - Use when: You want a guaranteed default.
- Type def:
The lesson: filter(Boolean) is a runtime thing that TypeScript treats as a black box. When you need your types actually to reflect what's in the array, reach for a type predicate. Small change, honest types.
Thanks for reading 👍
Top comments (4)
Thanks for this...
It's actually a nice pattern i would be using from now on
the .filter(Boolean) gotcha gets everyone once — Boolean's type signature can't narrow, so the fix is a guard predicate: .filter((x): x is string => Boolean(x)). but for env vars specifically i'd go upstream and parse process.env once at boot through a schema, so everything downstream is typed string and you never filter undefined at all. failing fast on a missing var beats threading | undefined through the whole app. are you validating env at startup, or reading process.env at the point of use?
Cleannnnnnn!!!
the isDefined helper is underrated as a util. we have one sitting in shared utils and honestly it shows up everywhere once you start tracking how much undefined bleeds into config from process.env. the pattern I have leaned on more recently is parsing the whole env block with Zod at startup. you get typed output if it passes or a throw before anything runs so the predicate problem just disappears. sidestep the filter question entirely. curious if you have gone that route or if startup validation was impractical in the codebase you were working in?