Schema composition
Share a contract across files by passing one schema() result as a branch of another. The inner shape is inlined recursively — no wrapper type, no runtime cost.
When to use this
- You want one contract reused in multiple places — e.g. a
PublicEnvthat's the source of truth for both server and client. - You want to keep the contract definitions colocated with the code that owns them, then assemble them at the edges.
How composition works
schema() accepts another schema() result as a branch. The inner shape is inlined recursively:
import { schema } from "@vlandoss/env";
import * as z from "zod";
const PublicEnv = schema({ API_BASE_URL: z.url() });
const ServerEnv = schema({
secrets: { DATABASE_URL: z.string().min(1) },
public: PublicEnv,
// ^ inlined — ServerEnv.shape.public is the same shape as PublicEnv.shape
});ServerEnv ends up with a public branch identical to PublicEnv — no wrapper, no special "embedded schema" runtime concept. From defineEnv's point of view it's just a plain nested branch.
Reference example
See the SSR guide for the full pattern with two files (schema.public.ts and schema.server.ts) and a flat env-var binding on the composed branch.
Combining with vars: null
When you inline a public schema into a server schema, the leaves under it sit at public.* — but you usually want them bound to the same env-var names whether read from server code or browser code. vars: { public: null } tells defineEnv to skip the branch prefix:
import { defineEnv } from "@vlandoss/env";
defineEnv({
schema: ServerEnv,
config,
vars: {
secrets: { DATABASE_URL: "DATABASE_URL" },
public: null, // API_BASE_URL stays API_BASE_URL (not PUBLIC_API_BASE_URL)
},
});See Env-var naming for the full rules around vars.
When to compose vs. when to duplicate
| Situation | Compose | Duplicate |
|---|---|---|
| Same values used isomorphically (server + client) | ✓ | |
| Same values across multiple server packages | ✓ | |
| Two contracts that just happen to share a leaf name | ✓ | |
| Values whose validation rules differ per consumer | ✓ |
Composing tightly couples both consumers to a single contract — that's the point when the values are genuinely the same thing.
Limits
- Composition is shape inlining, not class-style inheritance. There's no override; if
ServerEnv.public.API_BASE_URLneeds to differ fromPublicEnv.API_BASE_URL, they're different leaves and should live in different contracts. Config<typeof ServerEnv>only sees the inlined shape — the type system has no record of "this branch came fromPublicEnv". That's deliberate: it keeps the typing rules simple.
SSR / SSG
Server-rendered apps where the server has access to secrets and the browser receives only a public subset, injected into HTML via <EnvScript />.
Custom modes (staging, qa)
Surface non-default Vite modes to browser code. Why the envConfig() plugin is required even if you load config via dynamic import.