Overview
The mental model behind @vlandoss/env — one contract, three runtime adapters, and a single resolved env object.
@vlandoss/env is built around four ideas. Once they click, the rest of the API is small.
1. One contract per app
A schema declares every variable your app expects, grouped into branches. It's the only source of truth — types, validation, and env-var names all derive from it.
import { schema } from "@vlandoss/env";
const Env = schema({
server: {
HOST: z.string(),
PORT: z.coerce.number().int().positive()
},
db: {
URL: z.string()
},
});
export type EnvConfig = Config<typeof Env>;Leaves are Standard Schema validators (Zod, Valibot, ArkType…). Branches are nested objects. Other schema() results can be inlined to share contracts across files.
2. Per-environment config files, not .env per environment
Each environment has a file (development.ts, production.ts, …) that returns a typed object matching the schema. These are versioned defaults, not secrets. Secrets still come from your environment variables.
export default {
server: { PORT: 8080, HOST: "0.0.0.0" },
} satisfies EnvConfig;3. Env vars override config, with predictable naming
At boot, defineEnv merges three layers — see Resolution order. Each leaf maps to an env-var name by convention (server.PORT → SERVER_PORT); when the convention doesn't fit, override it in vars — see Env-var naming.
4. Runtime-agnostic core, opt-in adapters
The core (@vlandoss/env) doesn't touch the file system or the bundler. Adapters do:
| Entrypoint | Runtime | Responsibility |
|---|---|---|
@vlandoss/env | Any | Schema, resolution, validation, types |
@vlandoss/env/fs | Node / Bun / Deno | Load config files from disk (loadConfig) |
@vlandoss/env/vite | Build time | Alias #config, inject __ENV_NAME__ for the browser |
@vlandoss/env/react | SSR / SSG | Serialize public env into <script id="env"> for hydration |
@vlandoss/env/zod | Any | Opinionated Zod primitives (port, host, bool, secret) |
You import the adapter that matches where the code runs. Pure SPA? No /fs. No SSR? No /react. The core stays small.
Workers, Edge, and other isolate runtimes
The core never touches process directly. On runtimes where process isn't
defined (Cloudflare Workers without nodejs_compat, some Edge isolates),
pass the environment source explicitly:
export default {
async fetch(req, env, ctx) {
const resolved = defineEnv({ schema: Env, runtimeEnv: env });
// ...
},
};On Node, Bun, Deno and most Edge runtimes that polyfill process.env, the
default readEnv() picks it up automatically — no runtimeEnv needed.
How the pieces fit
schema() ─┐
config/<envName> ─┼──▶ defineEnv() ──▶ env (typed, validated)
environment vars ─┘schema()— the contract. Every variable, branch, and Standard Schema validator your app expects.config/<envName>— versioned defaults for the active environment, type-checked against the schema.- Environment variables — the runtime layer.
process.envon the server,window.__env(hydrated by<EnvScript />) in the browser. Always wins over config. defineEnv()— merges the three sources in resolution order, validates each leaf, and throws if anything is missing or wrong.env— the fully typed, validated object you import everywhere. Reading anything not in the schema is a compile error.
Read on:
- Resolution order — precedence of the three sources.
- Env-var naming — convention and overrides.
envName()— how the current environment is detected.