Concepts
Resolution order
How defineEnv merges defaults, config files, and environment variables — and what happens when a required leaf has no value.
For every leaf in the schema, defineEnv looks in this order. Later sources win.
defaults— inline fallbacks passed todefineEnv({ defaults: … }).config— the loaded per-environment object.- Environment variable — the value pulled from
runtimeEnv(see below). On the server that'sprocess.env; in the browser it'swindow.__env, hydrated by<EnvScript />.
defineEnv({
schema: Env,
defaults: { server: { PORT: 3000 } }, // 1
config, // 2
// runtimeEnv defaults to process.env or window.__env // 3
});What happens when nothing matches
If a leaf is required and has no value from any source, defineEnv throws naming the dot-path:
Invalid value at "server.PORT": RequiredSchema-level defaults (z.string().default(…)) still apply normally; defaults in defineEnv is for values you only know at wiring time, not at schema-definition time.
What runtimeEnv is for
By default runtimeEnv is process.env (server) or window.__env (browser, populated by <EnvScript /> or readEnv()). Override it when you need to:
- Inject a fixture in tests.
- Read from a non-default global (e.g.
Deno.env.toObject()). - Combine multiple sources before merging.
defineEnv({ schema: Env, config, runtimeEnv: { ...process.env, ...overrides } });Type checking of inputs
configis checked againstConfig<typeof Env>— the input type of every leaf (pre-coercion).defaultsis checked against the output type (post-coercion), because defaults skip the schema's coercion step.
So db: { URL: 42 } in defaults is a compile error if URL is z.string(), even though the schema would accept 42 after coercion in config.