DEV Community

Cover image for Reading typed config from .env in TypeScript
materwelon
materwelon

Posted on • Originally published at envapt.materwelon.dev

Reading typed config from .env in TypeScript

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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')!
};
Enter fullscreen mode Exit fullscreen mode
// 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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

${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)