Reading typed config from .env in TypeScript
June 1, 2026 · Dhruv
Number(process.env.PORT) || 3000
That line shows up in a lot of config files, and it has two bugs hiding in it.
Misspell PORT in your .env and you don't get an error, you get 3000, and you find out in production. Want to set PORT=0 on purpose? You can't: Number('0') || 3000 is 3000 too. The || fallback can't tell "missing" from "zero," and process.env hands you string | undefined regardless, so every read sits one coercion away from a quiet bug.
I got tired of writing that coercion by hand and keeping it correct, so I built envapt.
The typed read
Here's the same port, read so the fallback only fires when the value is actually missing:
const = .('PORT', 3000); // number, not string | undefined
const = .('DEBUG', false); // 1/yes/true/on -> truegetNumber returns number. PORT=0 reads as 0. A typo in PORT is unparseable, so it takes the fallback, but 0 is a perfectly good number and keeps it. The Number(x) || default trap is gone because there's no || deciding what counts as empty.
The fallback also does something to the type. Passing one strips undefined from the return, so nothing downstream needs a ?? 3000:
const withFallback = .('PORT', 3000);const noFallback = .('PORT');Leave the fallback off and you get number | undefined. The type makes you handle the missing case instead of letting you forget. And getBoolean reads 1, yes, true, on as true and 0, no, false, off as false, case-insensitively, so DEBUG=1 is true and not the silent false that === 'true' gives you.
The shapes that aren't primitives
Most config isn't a bare number. It's a URL, a comma list, a JSON blob, a timeout. getUsing takes a converter token and hands back the matching type:
const apiUrl = .('API_URL', .);const origins = .('ALLOWED_ORIGINS', .({ : . }));const ttl = .('CACHE_TTL', ., '5m'); // "5m" -> 300000 (ms)Converters.Url runs the value through new URL, so a non-absolute URL takes the fallback instead of slipping through as a string. Converters.array({ of: Converters.Url }) splits on the delimiter and converts each element, so the type is URL[], not the string[] you'd .split(',') your way to. Converters.Time reads 5m and returns 300000, which means a timeout in your .env reads the way you'd write it in code rather than as a pile of zeroes you have to count.
The built-in tokens cover number, integer, float, boolean, bigint, symbol, JSON, URL, RegExp, Date, duration, and arrays. When none of them fit, getWith takes a plain (raw, fallback) => T:
const = (: string | undefined) => {
const [, ] = ( ?? 'localhost:80').(':');
return { , : () };
};
const pair = .('HOST_PORT', );Most typed-env libraries ask you to learn their schema. envapt doesn't have one.
If your code already validates things with zod (or valibot, or arktype), hand that schema to envapt and it uses it:
const port = .('PORT', ..().().(1024));t3-env and znv do this job too, and do it well, but they're built on zod: the validator is the dependency. envapt uses the Standard Schema interface instead, so which validator you bring is your call, and envapt itself adds nothing to your lockfile.
parse runs the value through the schema and returns whatever the schema outputs, typed. A bad PORT throws on the parse line with the validator's own message, not a NaN that some later arithmetic turns into garbage. A third argument is an optional fallback, returned as-is on a missing value, so it has to already satisfy the type because the schema never sees it:
const timeout = .('TIMEOUT_MS', ..(), 30000);If you'd rather have a config class
The same reads bind to a class field with a decorator. The field is declared with declare and no initializer, and you need experimentalDecorators in your tsconfig.json:
class {
@('PORT', { : ., : 3000 })
declare static readonly : number;
@('LOG_LEVEL', { : .(['debug', 'info', 'warn']) })
declare static readonly : 'debug' | 'info' | 'warn';
}The declare and the missing initializer are load-bearing. A real field declaration under useDefineForClassFields emits a constructor assignment that overwrites the decorator's getter with undefined, so the property reads back as nothing. declare tells the compiler the field exists without emitting that assignment. For the common types there's sugar that takes the key and an optional fallback:
class {
@('PORT', 3000) declare static readonly : number;
@('APP_URL', new ('http://localhost:3000')) declare static readonly : URL;
@('CACHE_TTL', '5m') declare static readonly : number; // ms
}How I actually use it
In my bot framework, seedcord, which already leans on decorators, the Discord token binds to the Bot class that uses it, validated by a converter right at the binding:
function (: string | undefined): string {
if (!?.()) throw new ('DISCORD_BOT_TOKEN is missing');
if (!/^[\w-]{24,}\.[\w-]{6,}\.[\w-]{27,}$/.()) {
throw new ('DISCORD_BOT_TOKEN is malformed');
}
return ;
}
class {
@('DISCORD_BOT_TOKEN', { : () => () })
declare public readonly : string; // string; a malformed token throws here
}t3-env centralizes: you declare every variable up front in one createEnv call, validated once at startup. envapt puts the check on the field that uses the value. Centralizing is the right call when one app owns all its env. seedcord's config is spread across independently-built pieces (the Bot owns the token, a webhook subscriber owns its URL), so each piece validates its own variable where it lives, and a malformed token throws the moment that field resolves instead of in a central config module three files away.
envapt reads from process.env (and .env files when they exist), so it runs on Node, Bun, and Deno. I haven't tested it on Workers, and if your platform doesn't put config in process.env, use something that validates the object you hand it instead.
Loading the .env file
For local development you still want a .env file. On Node that's usually dotenv. envapt parses .env itself, so it loads the file without adding a dependency:
import 'envapt/config';That import is a drop-in for dotenv/config. It reads the cascade and mirrors the result into process.env. The cascade is per-environment and the most-specific file wins:
.env.production.local
.env.production
.env.local
.env${VAR} references expand while the file is parsed, so DATABASE_URL=postgres://${DB_HOST}/${DB_NAME} resolves on its own. You don't strictly need that import, either. Drop it and the typed reads still work; they read the cascade directly. The import is only there for the libraries that read process.env straight, before envapt does.
So, finally
What envapt gives you is the combination: a fallback that narrows the type and tells missing from zero, converters for the shapes that aren't strings, and validation through the schema you already wrote rather than the one a helper chose for you. If that's the shape of your config problem, the docs and the v4-to-v5 migration are at envapt.materwelon.dev, it's on npm and JSR, and the source is on GitHub. Please feel free to ask questions, report bugs, or suggest features in the repo! I'm always open to feedback and contributions.