DEV Community

Krishna Adhikari
Krishna Adhikari Subscriber

Posted on

Stop your app from booting with broken env vars: a type-safe, universal config library

TL;DRprocess.env.PORT is a string | undefined, your bundler silently inlines client env at build time, and every runtime exposes env differently. @teispace/env is 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

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),
  },
});
Enter fullscreen mode Exit fullscreen mode
env.PORT;          // 3000 — a real `number`
typeof env.PORT;   // "number"
env.ENABLE_CACHE;  // false — a real `boolean`
Object.isFrozen(env); // true
Enter fullscreen mode Exit fullscreen mode

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.PORT is number, env.NODE_ENV is 'development' | 'production' | 'test', env.DATABASE_URL is string — no z.infer, no manual interface, no as.


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

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

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

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 Standard Schema — a tiny shared interface (the spec was designed by the authors of Zod, Valibot, and ArkType). @teispace/env accepts any Standard-Schema validator as a schema entry, and you can mix it with the built-in coercers in one schema:

import { z } from 'zod';
import * as v from 'valibot';
import { defineEnv, e } from '@teispace/env';

export const env = defineEnv({
  schema: {
    DATABASE_URL: z.string().url(),        // Zod
    REGION: v.picklist(['us', 'eu']),      // Valibot
    PORT: e.port().default(3000),          // built-in coercer
  },
});
Enter fullscreen mode Exit fullscreen mode

No adapters, no lock-in. (Env validation is synchronous, so use synchronous schemas — async schemas throw a clear error rather than silently producing a pending value.)


Fail fast, fail clearly

A misconfigured environment should stop the app at boot with a report that tells you everything wrong at once — not the first error, then another redeploy, then the next error. Here's a real failure:

defineEnv({
  schema: {
    DATABASE_URL: e.url(),
    API_KEY: e.string({ min: 1 }),
    PORT: e.port(),
  },
  runtimeEnv: { PORT: '99999' },
});
Enter fullscreen mode Exit fullscreen mode
❌ Invalid environment variables:

  • DATABASE_URL: Missing required environment variable "DATABASE_URL"
  • API_KEY: Missing required environment variable "API_KEY", received nothing (missing)
  • PORT: Expected a valid port (1-65535), received "99999"

Fix these variables and restart.
Enter fullscreen mode Exit fullscreen mode

All three problems, one error, with what was received. And secrets are redacted automatically — by name heuristic (KEY, SECRET, TOKEN, PASSWORD, PRIVATE) or by marking a coercer .secret():

defineEnv({
  schema: { API_SECRET: e.string({ min: 50 }) },
  runtimeEnv: { API_SECRET: 'short' },
});
// • API_SECRET: Expected a string of length >= 50, received length 5, received "***"
//                                                                              ^^^^^ not your secret
Enter fullscreen mode Exit fullscreen mode

The raw value never reaches your logs or CI output — even when it's an element inside an array.


The client/server split + leak guard

This is the feature that prevents a 3 a.m. incident. Declare which variables are server-only and which are client-exposed, and @teispace/env:

  1. asserts at define time that every client variable carries the framework prefix, and
  2. throws if you read a server secret from client code — so a secret physically cannot be read in a browser bundle.
import { defineEnvSplit, e } from '@teispace/env';

export const env = defineEnvSplit({
  clientPrefix: 'PUBLIC_',
  server: { DB_PASSWORD: e.string({ min: 1 }), STRIPE_SECRET: e.string({ min: 1 }) },
  client: { PUBLIC_API_URL: e.url() },
  // Bundlers can't read dynamic env on the client, so list the values explicitly:
  runtimeEnv: {
    DB_PASSWORD: process.env.DB_PASSWORD,
    STRIPE_SECRET: process.env.STRIPE_SECRET,
    PUBLIC_API_URL: process.env.PUBLIC_API_URL,
  },
});
Enter fullscreen mode Exit fullscreen mode
env.PUBLIC_API_URL;  // ✅ ok everywhere
env.DB_PASSWORD;     // ❌ on the client, throws:
// "Attempted to access server-only env var "DB_PASSWORD" on the client.
//  This variable is declared under `server` and is intentionally not bundled
//  into client code…"
Enter fullscreen mode Exit fullscreen mode

The guard is implemented with a Proxy, and it holds against every leak path I could think of — direct access, computed keys, destructuring, Object.values, JSON.stringify, spread, Reflect.get. On the client, the server value isn't even present in the underlying object, so there's nothing to leak before the throw.

Why runtimeEnv? Because bundlers statically replace env access at build time and only for literal keys (see problem #3). On the server, @teispace/env auto-reads process.env, so runtimeEnv is optional. On the client, you must list the keys so the bundler can inline them — there's no way around the bundler, so the library makes the requirement explicit and type-checked rather than letting it fail silently.


Universal: the same library on every runtime

Node / NestJS / Express / Fastify / Hono

On the server, env is auto-sourced from process.env / Deno.env / Bun.env (detected defensively — it never crashes on import, even in a browser bundle). The /node preset adds optional .env loading:

import { defineEnv, e } from '@teispace/env/node';

export const env = defineEnv({
  load: true, // load the .env cascade before validating
  schema: {
    NODE_ENV: e.enum(['development', 'production', 'test']).default('development'),
    PORT: e.port().default(3000),
    DATABASE_URL: e.url(),
  },
});

// NestJS: feed the validated object straight into ConfigModule
// ConfigModule.forRoot({ validate: () => env, isGlobal: true });
Enter fullscreen mode Exit fullscreen mode

Cloudflare Workers (no global process.env)

Workers pass bindings into the request handler, so there's nothing to read at import time. Use createEnv to build a parser and call it per request — it's memoized per binding, so you pay validation once:

import { createEnv, e } from '@teispace/env';

const parseEnv = createEnv({
  schema: { API_KEY: e.string({ min: 1 }), UPSTREAM: e.url() },
});

export default {
  fetch(req: Request, env: unknown) {
    const config = parseEnv(env); // fully typed: config.API_KEY, config.UPSTREAM
    return new Response(config.UPSTREAM);
  },
};
Enter fullscreen mode Exit fullscreen mode

Framework presets

Each preset bakes in the correct client prefix and re-exports e:

import { defineEnv, e } from '@teispace/env/next';       // NEXT_PUBLIC_
import { defineEnv, e } from '@teispace/env/vite';       // VITE_   (reads import.meta.env)
import { defineEnv, e } from '@teispace/env/nuxt';       // NUXT_PUBLIC_
import { defineEnv, e } from '@teispace/env/astro';      // PUBLIC_ (reads import.meta.env)
import { defineEnv, e } from '@teispace/env/sveltekit';  // PUBLIC_
import { defineEnv, e } from '@teispace/env/node';       // flat + optional .env loading
Enter fullscreen mode Exit fullscreen mode

A full Next.js example:

// env.ts
import { defineEnv, e } from '@teispace/env/next';

export const env = defineEnv({
  server: {
    DATABASE_URL: e.url(),
    STRIPE_SECRET: e.string({ min: 1 }),
  },
  client: {
    NEXT_PUBLIC_API_URL: e.url(),
    NEXT_PUBLIC_GA_ID: e.string().optional(),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    STRIPE_SECRET: process.env.STRIPE_SECRET,
    NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
    NEXT_PUBLIC_GA_ID: process.env.NEXT_PUBLIC_GA_ID,
  },
});
Enter fullscreen mode Exit fullscreen mode

Loading .env files

@teispace/env/load loads the standard cascade with ${VAR} expansion — a drop-in superset of dotenv:

import { loadEnv } from '@teispace/env/load';

loadEnv(); // populates process.env, returns the merged values
Enter fullscreen mode Exit fullscreen mode

Precedence (highest wins): .env.env.local.env.[mode].env.[mode].local (and .env.local is skipped in test mode, matching Vite). It supports ${VAR}, ${VAR:-default}, \$ escapes, quoted/multiline values, and export KEY=…. Or, the dotenv/config muscle-memory version:

import '@teispace/env/config'; // side-effect import; loads the cascade
Enter fullscreen mode Exit fullscreen mode

It's also safe off-Node: if there's no filesystem (browser, Workers), it degrades to a no-op instead of crashing.


Robustness & security details

A config library sits at your app's front door, so the small things matter:

  • Aggregated errors — every problem reported at once, never first-only.
  • Secret redaction — by name heuristic and .secret(), including values nested inside array elements. (This was a real bug an adversarial review caught and we fixed before release: array-element values weren't being redacted. They are now.)
  • skipValidation keeps defaults — for CI/Docker build steps you can skip throwing while still applying coercion and defaults, so the typed shape holds. (A subtle footgun in some tools is that skipping validation also drops defaults — this one doesn't.)
  • Parse once — validation runs at module evaluation; reads are plain property access.
  • Frozen output — the result is Object.freezed.
  • Never crashes on import — runtime detection is fully defensive across Node/Bun/Deno/Workers/browser.
  • Secure-by-default URLse.url() rejects javascript:, data:, vbscript:, and file: schemes by default (they have no business in an env var and are dangerous if they reach the DOM), while still accepting normal network schemes so postgres://, redis://, amqp://, mongodb:// connection strings keep working. Opt out per-field if you really need to.
  • ReDoS-safe — the built-in email/URL/host regexes are linear-time (verified against pathological inputs).
  • Zero dependencies, ESM, fully tree-shakeable, ships types.
// skipValidation example: defaults still apply, no throw on missing required
const env = defineEnv({
  schema: { PORT: e.port().default(3000), DATABASE_URL: e.url() },
  runtimeEnv: {},
  skipValidation: true,
});
env.PORT; // 3000 — default applied even though validation was skipped
Enter fullscreen mode Exit fullscreen mode

Migrating from dotenv or t3-env

From dotenv: replace import 'dotenv/config' with import '@teispace/env/config' to get the same loading plus the cascade and expansion. Then wrap your reads in a defineEnv schema to add validation and types incrementally.

From t3-env: the shape is intentionally familiar — server, client, clientPrefix, runtimeEnv. The differences you gain: built-in coercers (no required validator), a true coerced single-source-of-truth object, universal runtimes (Workers/Deno), and the same API across backend and frontend instead of framework-specific entry points.


Why we built it

We maintain a set of open-source packages at Teispace (a Next.js starter CLI, a theme manager, a Lexical editor), and every one of them re-solved environment configuration slightly differently — and slightly wrong. We wanted one small, correct, dependency-free primitive that we could use in a Next app, a NestJS API, a Worker, and a build script without thinking about it. So we researched the whole space, wrote down every footgun, and built the package that fixes all of them at once — then had it adversarially reviewed before shipping.

It's MIT, open source, and the full readable source lives on GitHub (the published bundle is minified for size). If it saves you one 2 a.m. "why is this undefined" debugging session, it did its job.

  • npm: @teispace/env
  • Install: npm i @teispace/env

If you try it, I'd love to hear what runtime/framework combo you're on and whether anything's missing. Drop a comment. 👇

Built by Teispace.

Top comments (0)