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?
.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? π€·βοΈ
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.
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.
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.
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.
.filter(Boolean)
(string | undefined)[]
.filter((x): x is string => Boolean(x))
string[]
.filter(isDefined)
string[]
process.env.X || "fallback"
string
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 π