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 (0)