frontier/skills/zod-env/SKILL.md
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.
npx skillsauth add jon23d/skillz zod-envInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
process.env access scattered across a codebaseCannot read properties of undefined or NaN errors traced back to missing env varspackages/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.
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.
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.
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.
vi.stubEnv / jest.replacePropertyNever 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');
});
});
process.env.DATABASE_URL inside 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 from process.env; use z.coerce.number() or the process crashes with Expected number, received string.dotenv as a regular dependency — it only applies locally. It belongs in devDependencies.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 syncEvery 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
packages/env owns the zod dependency; consumers use workspace:*createEnv uses safeParse and reports all failures in one throwdotenv/config is the first import in every app entry point (dev dep only)process.env.* outside env.ts files.merge() — no copy-paste across packagesz.coerce.number() for numeric env vars (PORT, timeouts, pool sizes)vi.stubEnv or jest.replaceProperty — never direct process.env mutation.env.example updated in the same commit as schema changesdevelopment
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.
testing
Use when creating a new skill, editing an existing skill, writing a SKILL.md, or verifying a skill works before deployment.
development
React UI design principles and conventions. Load when building or modifying any user interface or React components. Covers application type detection, visual standards, component design and structure, Mantine (business apps) and Tailwind (consumer apps), accessibility, responsiveness, state management, data fetching, testing, and in-app help patterns.
development
Use when setting up ESLint and/or Prettier in a TypeScript project, adding linting to an existing TypeScript codebase, or configuring typescript-eslint, eslint-config-prettier, or related packages.