Implement GitOps workflows with ArgoCD and Flux for automated, declarative Kubernetes...
npx skills add jon23d/skillz --skill "zod-env"
Install specific skill from multi-skill repository
# Description
Use when adding or modifying environment variable handling in TypeScript projects or monorepos β especially when using process.env directly, missing startup validation, sharing env schemas across packages, or encountering "undefined is not a string" errors at runtime from missing env vars.
# SKILL.md
name: zod-env
description: Use when adding or modifying environment variable handling in TypeScript projects or monorepos β especially when using process.env directly, missing startup validation, sharing env schemas across packages, or encountering "undefined is not a string" errors at runtime from missing env vars.
Zod Env Validation
Overview
Validate all environment variables at process startup with Zod. The process dies immediately with a clear error listing every missing variable β before handling a single request. process.env is never accessed directly outside the env module.
When to use
- Setting up env handling in any new TypeScript service or monorepo package
- Adding a new environment variable to an existing service
- Refactoring raw
process.envaccess scattered across a codebase - Seeing
Cannot read properties of undefinedorNaNerrors traced back to missing env vars
The pattern
1. Shared packages/env β define once, compose everywhere
// packages/env/src/index.ts
import { z, ZodObject, ZodRawShape, ZodError } from 'zod';
// Reusable schema fragments β compose with .merge() / .extend()
export const sharedSchema = {
database: z.object({ DATABASE_URL: z.string().url() }),
node: z.object({ NODE_ENV: z.enum(['development', 'test', 'production']) }),
};
export function createEnv<T extends ZodRawShape>(
schema: ZodObject<T>
): z.infer<ZodObject<T>> {
const result = schema.safeParse(process.env);
if (!result.success) {
const lines = result.error.issues
.map(i => ` β’ ${i.path.join('.')}: ${i.message}`)
.join('\n');
throw new Error(`Invalid environment variables:\n\n${lines}\n`);
}
return result.data;
}
packages/env/package.json β declare zod here only:
{
"name": "@myorg/env",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } },
"dependencies": { "zod": "^3.23.0" }
}
Each consumer adds "@myorg/env": "workspace:*" β do not add zod directly to consumers. All z usage flows through @myorg/env to prevent version drift.
2. Per-package env.ts β compose, don't copy
// apps/api/src/env.ts
import { z } from 'zod';
import { createEnv, sharedSchema } from '@myorg/env';
export const env = createEnv(
sharedSchema.database
.merge(sharedSchema.node)
.extend({
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be β₯ 32 chars'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
})
);
Only include variables the package actually uses. packages/mailer should not validate DATABASE_URL if it never queries the database.
3. Entry point order β dotenv before env module
// apps/api/src/index.ts β app entry point
import 'dotenv/config'; // β FIRST line: loads .env for local dev
import { env } from './env'; // β SECOND: validation runs; process exits if invalid
import express from 'express';
// ... rest of app
dotenv is a dev dependency only: pnpm add -D dotenv --filter api
In production (CI/CD, containers) β real env vars are already set; dotenv/config is a no-op. Never commit .env files. Always commit .env.example.
4. Never access process.env outside env modules
// BAD β scattered throughout the codebase
const client = new Resend(process.env.RESEND_API_KEY);
// GOOD β validated and typed at startup
import { env } from './env';
const client = new Resend(env.RESEND_API_KEY);
If a variable is accessed via raw process.env anywhere other than the env module, it is unvalidated and untyped. Move it into the schema.
5. Test isolation β vi.stubEnv / jest.replaceProperty
Never mutate process.env directly in tests β it leaks across test cases.
// Vitest
import { describe, it, expect, vi, beforeEach } from 'vitest';
describe('when DATABASE_URL is missing', () => {
it('throws at createEnv call', () => {
vi.stubEnv('DATABASE_URL', ''); // restored automatically after each test
expect(() => createEnv(sharedSchema.database)).toThrow('DATABASE_URL');
});
});
// Jest
describe('when DATABASE_URL is missing', () => {
const original = process.env.DATABASE_URL;
afterEach(() => { process.env.DATABASE_URL = original; });
it('throws at createEnv call', () => {
delete process.env.DATABASE_URL;
expect(() => createEnv(sharedSchema.database)).toThrow('DATABASE_URL');
});
});
Common mistakes
- Lazy access β
process.env.DATABASE_URLinside a function body, not at startup. Validation then never runs until that code path is hit in production. .parse()instead of.safeParse()β Zod's.parse()throws one issue at a time;.safeParse()+ manual formatting shows all failures in one startup crash.z.string()for PORT β PORT arrives as a string fromprocess.env; usez.coerce.number()or the process crashes withExpected number, received string.dotenvas a regular dependency β it only applies locally. It belongs indevDependencies.- Validating too much in shared schemas β shared fragments (
sharedSchema.database) should only include what truly all consumers share. Over-broad shared schemas force unrelated packages to set variables they don't use.
.env.example β keep it in sync
Every time a variable is added to a schema, add it to .env.example in the same commit. This file is committed, .env is not.
# .env.example
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
NODE_ENV=development
JWT_SECRET=change-me-min-32-chars-xxxxxxxxxxx
PORT=3000
Checklist
- [ ]
packages/envowns thezoddependency; consumers useworkspace:* - [ ]
createEnvusessafeParseand reports all failures in one throw - [ ]
dotenv/configis the first import in every app entry point (dev dep only) - [ ] No
process.env.*outsideenv.tsfiles - [ ] Shared schema fragments composed with
.merge()β no copy-paste across packages - [ ]
z.coerce.number()for numeric env vars (PORT, timeouts, pool sizes) - [ ] Tests use
vi.stubEnvorjest.replacePropertyβ never directprocess.envmutation - [ ]
.env.exampleupdated in the same commit as schema changes
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.