惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

B
Blog RSS Feed
量子位
Recent Announcements
Recent Announcements
T
The Blog of Author Tim Ferriss
美团技术团队
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Blog — PlanetScale
Blog — PlanetScale
H
Help Net Security
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
博客园 - Franky
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
宝玉的分享
宝玉的分享
大猫的无限游戏
大猫的无限游戏
V
Visual Studio Blog
博客园 - 聂微东
aimingoo的专栏
aimingoo的专栏
Microsoft Security Blog
Microsoft Security Blog
U
Unit 42
J
Java Code Geeks
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
IT之家
IT之家
Hugging Face - Blog
Hugging Face - Blog
腾讯CDC
L
LangChain Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Stop your app from booting with broken env vars: a type-s...
Krishna Adhikari · 2026-05-31 · via DEV Community

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

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.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();

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 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
  },
});

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' },
});

❌ 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.

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

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,
  },
});

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…"

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 });

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);
  },
};

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

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,
  },
});


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

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

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


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.