← Blog

Reading typed config in TypeScript

June 1, 2026 · Dhruv · Updated July 31, 2026

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 get 3000 with no error, and you find out in production. Want to set PORT=0 on purpose? You can't, because Number('0') || 3000 is 3000 too. The || can't tell "missing" from "zero". And process.env types every value as string | undefined anyway, so you end up parsing each read yourself.

I got tired of writing that coercion by hand and keeping it correct, so I built envapt. It reads from whatever source you bind. On Node, Bun, and Deno that is process.env and your .env files, bound on import. Off Node the source is pluggable, so the same reads work on Cloudflare Workers, in the browser, or over a secrets object you fetched at boot.

The typed read

Here's the same port, read so the fallback only fires when the value is actually missing:

const  = .('PORT', ., 3000); // number, 0-65535 checked
const  = .('DEBUG', ., false); // 1/yes/true/on -> true

Converters.Port returns number. PORT=0 reads as 0, the ephemeral-bind wildcard. A typo in PORT is unparseable, so it takes the fallback, and so does PORT=99999, which is outside the valid range. 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 withFallback: number
const noFallback = .('PORT', .);
const noFallback: number | undefined

Leave the fallback off and you get number | undefined. The type makes you handle the missing case. And Converters.Boolean 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. getAs takes a converter token and hands back the matching type:

const apiUrl = .('API_URL', .);
const apiUrl: URL | undefined
const origins = .('ALLOWED_ORIGINS', .({ : . }));
const origins: URL[] | undefined
const ttl = .('CACHE_TTL', ., '5m'); // "5m" -> 300000 (ms)
const ttl: number

Converters.Url runs the value through new URL, so a non-absolute URL takes the fallback and never slips through as a string. Converters.array({ of: Converters.Url }) splits on the delimiter and converts each element, so the type is URL[]. A hand-rolled .split(',') leaves you at string[]. Converters.Time reads 5m and returns 300000, so a timeout in config reads the way you'd write it in code, with no pile of zeroes to count.

The built-in tokens cover number, integer, float, boolean, bigint, JSON, URL, RegExp, Date, duration, port, email, and arrays. When none of them fit, getAs also takes a plain (raw: string) => T, called only when the variable is set:

const  = (: string) => {
    const  = .(, 10);
    if (/gb$/i.()) return  * 1e9;
    if (/mb$/i.()) return  * 1e6;
    if (/kb$/i.()) return  * 1e3;
    return ;
};

// MAX_BODY=10mb
const maxBody = .('MAX_BODY', );
const maxBody: 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 = .('PORT', ..().().(1024));
const port: number

t3-env and znv do this job too, and do it well, but they're built on zod, where the validator is the dependency. envapt uses the Standard Schema interface, so which validator you bring is your call, and envapt itself adds nothing to your lockfile.

getRequired runs the value through the schema and returns whatever the schema outputs, typed. A bad PORT throws on the getRequired line, with the validator's issues on the error. The hand-written version gives you a NaN that some later arithmetic turns into garbage. For an optional value, getAs takes the same schema plus a fallback, returned as-is on a missing value, so it has to already satisfy the type because the schema never runs on it:

const timeout = .('TIMEOUT_MS', ..(), 30000);
const timeout: number

If you'd rather have a config class

The same reads bind to a class field with a decorator. The default form is a TC39 accessor decorator. Static fields use static accessor, instance fields use accessor x!, and no experimentalDecorators flag is needed.

class  {
    @('PORT', { : ., : 3000 })
    static accessor : number;

    @('LOG_LEVEL', { : .(['debug', 'info', 'warn']), : 'info' })
    static accessor : 'debug' | 'info' | 'warn';
}

Every other type is the same decorator with a converter in the options.

class  {
    @('MAX_RETRIES', { : ., : 3 })
    static accessor : number;

    @('APP_URL', { : ., : new ('http://localhost:3000') })
    static accessor : ;

    @('CACHE_TTL', { : ., : '5m' })
    static accessor : 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): string {
    if (!/^[\w-]{24,}\.[\w-]{6,}\.[\w-]{27,}$/.()) {
        throw new ('DISCORD_BOT_TOKEN is malformed');
    }
    return ;
}

class  {
    @('DISCORD_BOT_TOKEN', { : , : true })
    public accessor !: string; // a malformed token throws here
}

t3-env puts every variable in one createEnv call up front, validated once at startup. envapt puts the check on the field that uses the value. One central call is the right pick when a single app reads all its env. seedcord's config is spread across separately built pieces (the Bot reads the token, a webhook subscriber reads its URL), so each piece validates its own variable, and a malformed token throws the moment that field is read. A central config module would throw the same error three files away from the code that reads the token.

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, a PortableSource over the Worker env binding or the config your bundler injects in the browser. The typed reads, converters, and validators are identical across all of them. The .env cascade and the file-only config APIs are the part that stays on Node.

Loading a .env file on Node

For local development on Node you likely still want a .env file. envapt parses it itself, so it loads the file without adding a dotenv dependency:

import 'envapt/config';

That import reads the cascade and mirrors the result into process.env, the same way dotenv/config does. The cascade is per-environment and the most-specific file wins:

.env.production.local
.env.production
.env.local
.env

${VAR} references expand when you read the value, so DATABASE_URL=postgres://${DB_HOST}/${DB_NAME} resolves on its own. You don't strictly need that import, either. Remove it and the typed reads still work, reading the cascade directly. The import is only there for the libraries that read process.env straight, before envapt does.

Try it

So that's typed reads from any source (process.env, a Worker binding, a browser bundle, or a secrets object you supply), 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. It's on npm and JSR, and the source is on GitHub. If something's missing or broken, open an issue there.