Web TippsUse custom web fonts in Google Sheets charts(08.09.2026 um 17:05 Uhr)
Web TippsIntroducing the new 1Password App for Google Chat(08.09.2026 um 18:02 Uhr)
Web TippsUse custom web fonts in Google Sheets charts(08.09.2026 um 17:05 Uhr)
Web TippsIntroducing the new 1Password App for Google Chat(08.09.2026 um 18:02 Uhr)

🔧 Programmierung 🕛 vor 2 Monaten 8 Min Lesezeit
0

Reading typed config from .env in TypeScript

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

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:




CODE
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:




CODE
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:




CODE
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:




CODE
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:




CODE
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:




CODE
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:




CODE
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:




CODE
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:




CODE
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:




CODE
// 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')!
};









CODE
// 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:




CODE
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:




CODE
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:




CODE
.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 and . Please feel free to ask questions, report bugs, or suggest features in the repo! I'm always open to feedback and contributions.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
3 Quellen
Use custom web fonts in Google Sheets charts
2 Quellen
Introducing the new 1Password App for Google Chat
1 Quelle
Context-aware access controls are available for Gemini Enterprise in the Admin console
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Reading typed config from .env in TypeScript

Thematisch verwandte Begriffe: Reading, typed, config, from · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...