cd /news/developer-tools/typescript-patterns-for-environment-… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-28779] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

TypeScript Patterns for Environment Variables

A developer discovered that TypeScript's type inference does not narrow types after using .filter(Boolean) on an array containing undefined values, leaving the type as (string | undefined)[] despite runtime correctness. The developer explains that using a type predicate like .filter((origin): origin is string => Boolean(origin)) or a utility function isDefined correctly narrows the type to string[]. The post highlights the gap between runtime behavior and TypeScript's static analysis.

read3 min views1 publishedJun 16, 2026

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 πŸ‘

── more in #developer-tools 4 stories Β· sorted by recency
── more on @typescript 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/typescript-patterns-…] indexed:0 read:3min 2026-06-16 Β· β€”