TL;DR —
process.env.PORTis astring | undefined, your bundler silently inlines client env at build time, and every runtime exposes env differently.@teispace/envis a zero-dependency library that loads, validates, coerces, and types your environment once and works the same on Node, Bun, Deno, Cloudflare Workers, Next.js, NestJS, Vite, Nuxt, Astro, and SvelteKit. Bring Zod/Valibot/ArkType, or use the built-in coercers.npm i @teispace/env.
import { defineEnv, e } from '@teispace/env';
export const env = defineEnv({
schema: {
NODE_ENV: e.enum(['development', 'production', 'test']).default('development'),
PORT: e.port().default(3000),
DATABASE_URL: e.url(),
ENABLE_CACHE: e.boolean().default(false),
},
});
env.PORT; // number ← coerced AND typed (not the string "3000")
env.DATABASE_URL; // string
env.ENABLE_CACHE; // boolean
If a variable is missing or malformed, your app fails fast at boot with one clear error — not three layers deep at 2 a.m. with a cryptic undefined.
The problem nobody admits is a problem
Environment variables feel trivial. You add dotenv, read process.env.WHATEVER, ship it. Then reality arrives:
1. process.env lies to TypeScript
Every value on process.env is typed string | undefined. So this compiles and explodes at runtime:
const port = process.env.PORT; // string | undefined
server.listen(port); // expected a number… 🙃
const retries = process.env.RETRIES * 2; // NaN, silently
You can augment NodeJS.ProcessEnv to claim PORT: number, but that's a lie: declaration merging tells TypeScript what should exist, not what does, and process.env.PORT is still the string "3000" at runtime. The type and the value disagree — the worst kind of bug.
2. The "transform" trap
Libraries like t3-env validate and coerce into a returned object. Great — if you read that object. But the moment a teammate reads raw process.env.PORT after a coercion, the type says number and the value is still a string. The type lies again, just one level removed.
3. Your bundler rewrites env behind your back
This is the one that eats afternoons. In Vite, import.meta.env.VITE_X is statically replaced at build time — and only for literal, VITE_-prefixed keys. In Next.js, process.env.NEXT_PUBLIC_X is inlined into the browser bundle at build. So:
// ❌ On the client, this is `undefined` after bundling:
const key = 'VITE_API_URL';
const url = import.meta.env[key]; // dynamic key → not statically replaced
A naive getEnv(key) helper that reads a dynamic key works on the server and silently returns undefined in the browser. The bundler can only see literal access.
4. Every runtime exposes env differently
| Runtime | Where env lives | Global? |
|---|---|---|
| Node / Bun | process.env | ✅ |
| Deno | Deno.env.get() (or process.env in compat) | ✅ |
| Cloudflare Workers | the env binding passed into your handler | ❌ no global at all |
| Vite client | import.meta.env.VITE_* (build-time replaced) | ⚠️ build-time only |
| Next client | process.env.NEXT_PUBLIC_* (build-time inlined) | ⚠️ build-time only |
Write a helper that reads process.env and it crashes on Workers. Write one for Workers and it's awkward everywhere else.
5. Secrets leak into client bundles
Forget the NEXT_PUBLIC_/VITE_ prefix discipline once and your STRIPE_SECRET ships to every browser. There's usually nothing stopping you.
The existing tools each solve part of this:
dotenv | @t3-oss/env | envalid | |
|---|---|---|---|
Loads .env | ✅ | ❌ | ❌ |
| Validates + coerces + types | ❌ | ✅ | ✅ |
| Types never lie | — | ⚠️ (via raw process.env) | ✅ |
| Bring any validator | — | ⚠️ (Zod/Std Schema) | ❌ |
| Built-in coercers (zero dep) | — | ❌ | ✅ |
| Client/server leak guard | ❌ | ✅ | ❌ |
| Universal runtime (incl. Workers) | ⚠️ | ❌ | ❌ |
| Dependencies | 0 | 0 (needs a validator) | 1 |
Nobody does all of it in one lean package. That gap is @teispace/env.
Install
npm i @teispace/env
# pnpm add @teispace/env · yarn add @teispace/env · bun add @teispace/env · deno add npm:@teispace/env
ESM-only. Requires Node ≥ 22.12 (or Bun / Deno / Workers). Zero runtime dependencies.
The core idea: one validated, coerced, frozen object
defineEnv reads your environment, validates each variable, coerces it to the right type, and returns a frozen object that is the single source of truth:
import { defineEnv, e } from '@teispace/env';
export const env = defineEnv({
schema: {
NODE_ENV: e.enum(['development', 'production', 'test']).default('development'),
PORT: e.port().default(3000),
DATABASE_URL: e.url(),
ENABLE_CACHE: e.boolean().default(false),
},
});
env.PORT; // 3000 — a real `number`
typeof env.PORT; // "number"
env.ENABLE_CACHE; // false — a real `boolean`
Object.isFrozen(env); // true
Because the value was actually coerced (not type-asserted), the type and the runtime value can never disagree. Read env.* everywhere; never touch process.env again. This is the "types never lie" guarantee — and it's the headline difference from approaches that only fix the type.
The output type is fully inferred from your schema.
env.PORTisnumber,env.NODE_ENVis'development' | 'production' | 'test',env.DATABASE_URLisstring— noz.infer, no manual interface, noas.
Built-in coercers (e.*)
Each coercer turns the raw string | undefined into a typed, validated value. Constraints are passed as options; behaviors are chained:
e.string({ min, max, regex, startsWith, endsWith });
e.number({ min, max, int });
e.int({ min, max });
e.port(); // 1–65535
e.boolean(); // true/1/yes/on vs false/0/no/off/""
e.url({ protocol }); // WHATWG URL; e.urlObject() returns a URL instance
e.email();
e.enum(['a', 'b', 'c']); // narrows to 'a' | 'b' | 'c'
e.json<T>(innerSchema?); // JSON.parse + optional shape validation
e.array({ separator, trim, of }); // "a,b,c" → string[] (or coerced items via `of`)
e.host();
e.hostname();
Chainable on every coercer — each one narrows the type precisely:
e.string().optional(); // string | undefined
e.port().default(3000); // number (and no longer optional)
e.string({ min: 1 }).secret(); // value redacted in any error output
e.number().refine((n) => n % 2 === 0, 'must be even');
e.string().transform((s) => s.toUpperCase());
e.url().describe('Public API base URL');
A small but real example mixing several:
const env = defineEnv({
schema: {
EVEN_WORKERS: e.number({ int: true }).refine((n) => n % 2 === 0, 'must be even'),
ALLOWED_ORIGINS: e.array({ of: e.url() }), // "https://a.com,https://b.com" → string[]
LOG_LEVEL: e.enum(['debug', 'info', 'warn', 'error']).default('info'),
SERVICE_NAME: e.string().transform((s) => s.toLowerCase()),
},
});
env.ALLOWED_ORIGINS is string[], env.LOG_LEVEL is the union, and each origin is itself URL-validated.
Bring your own validator (Standard Schema)
Already invested in Zod, Valibot, or ArkType? They all implement .
SOCIAL SHARE CARD GENERATOR