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. On Node, Bun, and Deno it reads process.env and your .env files; off Node the source is pluggable, so the same reads work on Cloudflare Workers and in the browser.
The typed read
Here's the same port, read so the fallback only fires when the value is actually missing:
const port = Envapter.getNumber('PORT', 3000); // number, not string | undefined
const debug = Envapter.getBoolean('DEBUG', false); // 1/yes/true/on -> true
getNumber 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 = Envapter.getNumber('PORT', 3000);
// withFallback: number
const noFallback = Envapter.getNumber('PORT');
// noFallback: number | undefined
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 = Envapter.getUsing('API_URL', Converters.Url); // URL | undefined
const origins = Envapter.getUsing('ALLOWED_ORIGINS', Converters.array({ of: Converters.Url })); // URL[] | undefined
const ttl = Envapter.getUsing('CACHE_TTL', Converters.Time, '5m'); // "5m" -> 300000 (ms); number
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 toBytes = (raw: string | undefined) => {
const n = Number.parseInt(raw ?? '0', 10);
if (/gb$/i.test(raw ?? '')) return n * 1e9;
if (/mb$/i.test(raw ?? '')) return n * 1e6;
if (/kb$/i.test(raw ?? '')) return n * 1e3;
return n;
};
// MAX_BODY=10mb
const maxBody = Envapter.getWith('MAX_BODY', toBytes); // number | undefined
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 = Envapter.parse('PORT', z.coerce.number().int().min(1024)); // number
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 = Envapter.parse('TIMEOUT_MS', z.coerce.number(), 30000); // number
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 Config {
@Envapt('PORT', { converter: Converters.Number, fallback: 3000 })
declare static readonly port: number;
@Envapt('LOG_LEVEL', { schema: z.enum(['debug', 'info', 'warn']) })
declare static readonly logLevel: '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 Config {
@EnvNum('PORT', 3000) declare static readonly port: number;
@EnvUrl('APP_URL', new URL('http://localhost:3000')) declare static readonly appUrl: URL;
@EnvTime('CACHE_TTL', '5m') declare static readonly ttl: 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 validateDiscordToken(raw: string | undefined): string {
if (!raw?.trim()) throw new Error('DISCORD_BOT_TOKEN is missing');
if (!/^[\w-]{24,}\.[\w-]{6,}\.[\w-]{27,}$/.test(raw)) {
throw new Error('DISCORD_BOT_TOKEN is malformed');
}
return raw;
}
class Bot {
@Envapt('DISCORD_BOT_TOKEN', { converter: (raw) => validateDiscordToken(raw) })
declare public readonly botToken: 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.
The same reads, off Node
On Node, Bun, and Deno, envapt reads process.env and the .env cascade with no setup. On Cloudflare Workers and in the browser there is no filesystem, so you bind the source yourself, and once it's bound the typed reads, converters, and validators are identical.
In a Worker, env is readable at module scope and constant for a deployment, so bind it once in a config module and export the typed values. That way binding happens once per isolate, not on every request:
// config.ts
import { env } from 'cloudflare:workers';
import { Envapter, WorkerEnvSource, Converters } from 'envapt/workerd';
Envapter.useSource(new WorkerEnvSource(env));
Envapter.require('ORIGIN_URL', 'API_TOKEN');
export const config = {
origin: Envapter.getUsing('ORIGIN_URL', Converters.Url)!,
token: Envapter.get('API_TOKEN')!
};
// index.ts
import { config } from './config';
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
return fetch(new URL(url.pathname + url.search, config.origin), request);
}
} satisfies ExportedHandler;
In the browser, bind whatever your bundler injects:
import { Envapter, ManualEnvSource, Converters } from 'envapt/browser';
// Vite replaces import.meta.env at build time; or a webpack DefinePlugin object
Envapter.useSource(new ManualEnvSource(import.meta.env));
const apiBase = Envapter.getUsing('API_BASE_URL', Converters.Url);
Import from envapt/workerd or envapt/browser, not bare envapt: those entries pull in no node: modules and drop the file-only APIs from the types, so a stray file call is a compile error instead of a runtime surprise. The .env cascade and the file APIs stay on Node, Bun, and Deno.
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.
Top comments (0)