Skip to main content
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);
}

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

SourcePriorityUse case
process.envLowestCI/CD pipelines, system-level env
.env files (in load order)MediumProject defaults, local overrides
Explicit env recordHighestEdge 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

  1. Loads variables from .env files (if load is specified)
  2. Merges with process.env (files override process env)
  3. Applies explicit env record on top (if provided)
  4. Validates every variable against the schema
  5. Returns a frozen, immutable object — typed from the schema
  6. 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.