Concepts

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.

src/config/production.ts
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.PORTSERVER_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:

EntrypointRuntimeResponsibility
@vlandoss/envAnySchema, resolution, validation, types
@vlandoss/env/fsNode / Bun / DenoLoad config files from disk (loadConfig)
@vlandoss/env/viteBuild timeAlias #config, inject __ENV_NAME__ for the browser
@vlandoss/env/reactSSR / SSGSerialize public env into <script id="env"> for hydration
@vlandoss/env/zodAnyOpinionated 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.env on 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:

On this page