Documentation Index
Fetch the complete documentation index at: https://docs.vertz.dev/llms.txt
Use this file to discover all available pages before exploring further.
createEnv() validates environment variables at startup using a @vertz/schema schema. If any variable is missing or invalid, the server fails immediately with a clear error — not 30 minutes later when a request hits an undefined config value.
Why not process.env?
process.env.PORT is string | undefined. A typo in the variable name is a silent undefined. A missing .env file is a runtime surprise. There’s no type safety, no validation, and no way for TypeScript to help you.
// Dangerous — silent failures
const port = Number(process.env.PROT) || 3000; // Typo: PROT instead of PORT
const dbUrl = process.env.DATABASE_URL!; // Crashes at first query, not at startup
createEnv() catches both issues at startup:
import { createEnv } from '@vertz/server';
import { s } from '@vertz/schema';
const env = createEnv({
schema: s.object({
PORT: s.coerce.number().default(3000),
DATABASE_URL: s.string(),
}),
});
// env.PORT — number, guaranteed
// env.DATABASE_URL — string, guaranteed
// env.PROT — TypeScript error, caught at compile time
Basic usage
// src/api/env.ts
import { createEnv } from '@vertz/server';
import { s } from '@vertz/schema';
export const env = createEnv({
schema: s.object({
PORT: s.coerce.number().default(3000),
DATABASE_URL: s.string(),
NODE_ENV: s.enum(['development', 'production', 'test']).default('development'),
}),
});
Then import it wherever you need config:
// src/api/server.ts
import { createServer } from '@vertz/server';
import { env } from './env';
const app = createServer({
/* ... */
});
if (import.meta.main) {
app.listen(env.PORT);
}
import.meta.main is true when the file is executed directly (vtz src/api/server.ts) and false when it’s imported by another module — for example, a test that imports app to make in-process requests. This is the standard “run if main” idiom. The vtz runtime sets it natively; the boolean type comes from vertz/client, which is already in the scaffolded tsconfig.json.
Loading .env files
Use the load property to read variables from dotenv files:
// src/api/env.ts
import { createEnv } from '@vertz/server';
import { s } from '@vertz/schema';
export const env = createEnv({
load: ['.env', '.env.local'],
schema: s.object({
PORT: s.coerce.number().default(3000),
DATABASE_URL: s.string(),
API_SECRET: s.string(),
}),
});
Files are loaded in order — later files override earlier ones. This lets you keep shared defaults in .env and developer-specific overrides in .env.local.
Precedence
| Source | Priority | Use case |
|---|
process.env | Lowest | CI/CD pipelines, system-level env |
.env files (in load order) | Medium | Project defaults, local overrides |
Explicit env record | Highest | Edge runtimes, tests |
Loaded files override process.env. If .env sets PORT=4000 and process.env.PORT is 3000, the result is 4000. This means your .env files are the source of truth for local development.
Missing files
Missing files are silently skipped. This is intentional — .env.local often doesn’t exist in CI, and that shouldn’t break your build:
createEnv({
load: ['.env', '.env.local'], // .env.local may not exist — that's fine
schema: s.object({ PORT: s.coerce.number().default(3000) }),
});
Common file patterns
// Development
load: ['.env', '.env.local'];
// Environment-specific
load: ['.env', `.env.${process.env.NODE_ENV}`, '.env.local'];
Add .env.local to .gitignore — it’s for secrets that shouldn’t be committed.
How it works
- Loads variables from
.env files (if load is specified)
- Merges with
process.env (files override process env)
- Applies explicit
env record on top (if provided)
- Validates every variable against the schema
- Returns a frozen, immutable object — typed from the schema
- Throws at startup if validation fails
Startup error example
If DATABASE_URL is missing and has no default:
Environment validation failed:
DATABASE_URL: Required
The server never starts. No ambiguity about what’s wrong.
Coercion
Environment variables are always strings. Use s.coerce.* to parse them into the right types:
import { createEnv } from '@vertz/server';
import { s } from '@vertz/schema';
createEnv({
schema: s.object({
PORT: s.coerce.number().default(3000), // "3000" → 3000
ENABLE_CACHE: s.coerce.boolean().default(true), // "true" → true
MAX_RETRIES: s.coerce.number().int().min(0),
}),
});
Edge runtimes
On Cloudflare Workers, environment variables are passed as context bindings — not available in process.env. Pass them explicitly:
// worker.ts
import { createEnv } from '@vertz/server';
import { envSchema } from './env'; // your schema from the Basic usage example
export default {
async fetch(request: Request, workerEnv: Env) {
const env = createEnv({
schema: envSchema,
env: workerEnv, // Use worker bindings instead of process.env
});
// ...
},
};
Testing
Inject controlled values for deterministic tests:
import { createEnv } from '@vertz/server';
import { s } from '@vertz/schema';
const env = createEnv({
schema: s.object({
PORT: s.coerce.number(),
API_KEY: s.string(),
}),
env: {
PORT: '4000',
API_KEY: 'test-key',
},
});
expect(env.PORT).toBe(4000);
expect(env.API_KEY).toBe('test-key');
Immutability
The returned object is deeply frozen. Attempts to mutate it throw at runtime:
env.PORT = 9999; // TypeError: Cannot assign to read only property
This prevents accidental mutation of configuration values across your application.
Pattern: single env module
Define one env.ts file per service and import it everywhere. Don’t scatter createEnv() calls across multiple files:
src/api/
env.ts ← single source of truth for env
server.ts ← imports env
db.ts ← imports env
This gives you one place to see all required environment variables and their types.