Zod-powered environment parsing for TypeScript apps, with a CLI for local checks, lint helpers, Next.js env generation, and Vercel/Railway env checks and pushes.
Application code imports a typed env object instead of reading process.env directly. Validation runs when you ask for it, tests can pass plain objects, and deploy checks catch missing or misspelled variables before a deploy starts.
@howells/envy includes the parser, the envy check local CLI, dotenv helpers, Next.js codegen helpers, lint helpers, and Vercel/Railway provider adapters.
Env bugs are usually small mistakes with expensive timing:
- a required secret is missing in production
- a key is misspelled in
.env.production - a public variable is not prefixed correctly for Next.js
- tests fail because env validation ran at import time
- deployment scripts corrupt secrets with shell quoting or trailing newlines
- application code quietly bypasses the typed env module with
process.env
The schema is the source of truth:
import { defineEnv } from "@howells/envy";
import { z } from "zod";
export const envSchema = defineEnv({
server: {
DATABASE_URL: z.string().url(),
OPENAI_API_KEY: z.string().min(1),
},
public: {
NEXT_PUBLIC_APP_URL: z.string().url(),
},
system: {
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
},
optional: {
COHERE_API_KEY: z.string().min(1),
},
});
export const env = envSchema.parseServer(process.env);The core parser is published on npm as @howells/envy. Install it with Zod:
npm install @howells/envy zodThat also installs the envy binary:
npx envy --helpOr with pnpm:
pnpm add @howells/envy zod
pnpm exec envy --helpFor this workspace:
pnpm install
pnpm check
pnpm buildGroups tell the parser and helper tools how each key behaves:
defineEnv({
server: {
DATABASE_URL: z.string().url(),
},
public: {
NEXT_PUBLIC_APP_URL: z.string().url(),
},
system: {
CI: z.coerce.boolean().default(false),
},
optional: {
SENTRY_DSN: z.string().url(),
},
});server: private, required by defaultpublic: client-visible, required by default, prefix-enforcedsystem: runtime/provider-owned values, excluded from deploy pushes by defaultoptional: missing is allowed, present values are validated
The default public prefix is NEXT_PUBLIC_.
Importing a schema does not validate the process environment.
const env = envSchema.parse(process.env);Parsed output:
- contains only schema-declared keys
- strips unknown input keys
- converts empty strings to
undefinedby default - returns a plain frozen object
Use separate exports in Next.js apps:
// src/env/server.ts
import { envSchema } from "./schema";
export const env = envSchema.parseServer(process.env);// src/env/client.ts
import { envSchema } from "./schema";
export const env = envSchema.parseClient({
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
});parseServer() includes server, public, system, and optional keys. parseClient() includes only public keys.
Lazy access is for runtimes and tests that cannot validate every key up front:
const env = envSchema.lazy(process.env);lazy() validates a key when that key is accessed. Prefer explicit parsing when possible.
Test env behavior with plain objects. Do not mutate global process.env unless a test specifically needs to cover process integration.
const env = envSchema.parseServer({
DATABASE_URL: "https://db.example.com",
OPENAI_API_KEY: "test-key",
NEXT_PUBLIC_APP_URL: "https://app.example.com",
});For mutable test state:
import { createEnvStore } from "@envy/test";
const store = createEnvStore(envSchema, {
DATABASE_URL: "https://db.example.com",
});
store.override({ OPENAI_API_KEY: "test-key" });
store.reset();See Testing Guide.
The published package includes a working local preflight command:
npx envy check local --schema ./src/env/schema.ts --from .env.productionOmit --from to validate the current process environment:
npx envy check local --schema ./src/env/schema.tsUse --mode server, --mode client, or --mode all to choose the parser method. The default is server.
npx envy check local --schema ./src/env/schema.ts --from .env.production --mode allFor agents and CI, use structured output and command introspection:
npx envy check local --schema ./src/env/schema.ts --from .env.production --json
npx envy describeJSON output uses a stable envelope:
{ "ok": true, "data": {}, "metadata": {} }Use run local when a smoke test or script needs the same validated dotenv
sources loaded into its child process:
npx envy run local --schema ./src/env/schema.ts --from .env --from .env.local -- node ./scripts/smoke.jsOn success, Envy stays silent and the child process owns stdout and stderr. If validation fails, the command is not started.
Errors use the same shape on stderr and semantic exit codes:
64: usage error65: env validation failed66: schema or env file could not be read70: internal error
See CLI Guide, Deploy Guide, and Lint Guide.
The published package also exposes helper APIs:
import { loadDotenv } from "@howells/envy/dotenv";
import { syncNextEnv } from "@howells/envy/next";
import { createOxlintConfig } from "@howells/envy/lint";
import { vercel } from "@howells/envy/adapters/vercel";
import { railway } from "@howells/envy/adapters/railway";packages/
core/ # defineEnv, parsing, metadata, type model
cli/ # envy binary, config loading, check/push/init/sync
config/ # defineConfig for envy.config.ts
dotenv/ # .env parser/loading helpers
next/ # Next init/sync/codegen helpers
test/ # test helpers
lint/ # oxlint/biome/eslint config helpers
adapters/
vercel/ # Vercel check/push
railway/ # Railway check/pushpnpm install
pnpm check
pnpm buildThe repo uses:
- pnpm workspaces
@howells/lint@howells/typescript-config- tsup
- Vitest
- TypeScript