@howells/envy
v0.3.7
Published
Zod-powered environment parsing for TypeScript applications.
Readme
@howells/envy
Zod-powered environment parsing for TypeScript applications.
Envy gives you a typed env object without import-time validation. Define a grouped schema, parse an input object, and keep application code away from raw process.env.
Install
npm install @howells/envy zodOr with pnpm:
pnpm add @howells/envy zodzod is a peer dependency because Envy uses your project's Zod version for schemas and inference.
The package also installs the envy binary:
npx envy --helpQuick Start
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);Agent Usage Contract
When generating or editing code that uses Envy:
- Import the parser from
@howells/envyand Zod fromzod. - Create one schema module that exports
envSchema. - Put private required keys in
server, public client keys inpublic, runtime-owned keys insystem, and integration keys that may be absent inoptional. - Parse explicit input at the runtime boundary:
parseServer(process.env)on the server andparseClient({ NEXT_PUBLIC_KEY: process.env.NEXT_PUBLIC_KEY })for client bundles. - Import the parsed
envobject from application code instead of readingprocess.envdirectly. - Never print or serialize env values. CLI and adapter output should include key names only.
Minimal Next.js layout:
// src/env/schema.ts
import { defineEnv } from "@howells/envy";
import { z } from "zod";
export const envSchema = defineEnv({
server: { DATABASE_URL: z.string().url() },
public: { NEXT_PUBLIC_APP_URL: z.string().url() },
});// 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,
});Groups
server: private values required by server-side codepublic: client-visible values, required by default and prefix-enforcedsystem: runtime or provider-owned values such asNODE_ENVandCIoptional: missing values are allowed, present values are validated
The default public prefix is NEXT_PUBLIC_.
defineEnv({
public: {
NEXT_PUBLIC_APP_URL: z.string().url(),
},
});Use a custom prefix when needed:
defineEnv(
{
public: {
PUBLIC_APP_URL: z.string().url(),
},
},
{
publicPrefix: "PUBLIC_",
},
);Parsing
Importing a schema does not read or validate process.env.
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
Server And Client
For Next.js apps, keep server and client env exports separate.
// 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.
Optional Values
Optional variables do not need .optional() in the Zod schema.
const envSchema = defineEnv({
optional: {
SENTRY_DSN: z.string().url(),
},
});
const env = envSchema.parse({});
env.SENTRY_DSN; // undefinedIf a value is present, it must still pass the schema.
Empty Strings
By default, Envy treats empty strings as missing values.
SENTRY_DSN=
PORT=defineEnv({
optional: {
SENTRY_DSN: z.string().url(),
},
system: {
PORT: z.coerce.number().default(3000),
},
});SENTRY_DSN becomes undefined; PORT uses its default.
Disable this only when "" is meaningful:
defineEnv(
{
server: {
EMPTY_ALLOWED: z.literal(""),
},
},
{
emptyStringAsUndefined: false,
},
);Lazy Access
Lazy mode validates declared keys when they are accessed.
const env = envSchema.lazy(process.env);Prefer full parsing when you can. Use lazy() for framework phases, scripts, or tests where validating every declared key up front is not viable.
CLI
Use the CLI to validate a local env file before CI or deployment:
npx envy check local --schema ./src/env/schema.ts --from .env.productionYour schema module can export the schema as default, envSchema, or schema.
Use --export <name> when you prefer a different named export.
npx envy check local \
--schema ./src/env/schema.ts \
--export appEnv \
--from .env.production \
--mode allOmit --from to validate the current process environment. Use --format json
for CI output that another tool can parse.
--json is an alias for --format json:
npx envy check local --schema ./src/env/schema.ts --from .env.production --jsonUse run local for smoke tests and scripts that should inherit validated
dotenv values:
npx envy run local --schema ./src/env/schema.ts --from .env --from .env.local -- node ./scripts/smoke.jsThe child command only starts after validation passes.
JSON success is written to stdout:
{ "ok": true, "data": {}, "metadata": {} }JSON errors are written to stderr with recoverable details:
{
"ok": false,
"error": {
"code": "ENVY_VALIDATION_FAILED",
"message": "Environment validation failed with 1 issue(s).",
"suggestions": ["Set every required schema variable in the checked env source."]
},
"metadata": {}
}Inspect the command contract programmatically:
npx envy describeMetadata
Raw Zod schemas are enough for most variables. Use v(...) when tooling needs per-variable metadata.
import { defineEnv, v } from "@howells/envy";
export const envSchema = defineEnv({
server: {
OPENAI_API_KEY: v(z.string().min(1), {
deploy: ["preview", "production"],
}),
},
});The parser ignores deploy metadata. Provider tooling can use it through
listDeployEnvVars().
Helper Subpaths
The package includes helper APIs for the deployment and framework edges:
import { listDeployEnvVars, listEnvVars } from "@howells/envy";
import { loadDotenv } from "@howells/envy/dotenv";
import { generateNextEnvFiles, 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";listEnvVars(): schema metadata for generated code, allowlists, and docslistDeployEnvVars(): deploy-relevant keys after applyingv(..., { deploy })@howells/envy/dotenv:.envloading without shelling out@howells/envy/next: generated Next client/server env boundaries@howells/envy/lint: Oxlint, ESLint, and Biome config helpers@howells/envy/adapters/vercel: Vercel env presence checks and API pushes@howells/envy/adapters/railway: Railway env presence checks and API pushes
Use metadata helpers when building agents, CI checks, or provider workflows:
const publicKeys = listEnvVars(envSchema, { groups: ["public"] }).map(
(entry) => entry.key,
);
const productionKeys = listDeployEnvVars(envSchema, {
environment: "production",
}).map((entry) => entry.key);Errors
Invalid input throws EnvValidationError.
import { EnvValidationError } from "@howells/envy";
try {
envSchema.parse(process.env);
} catch (error) {
if (error instanceof EnvValidationError) {
console.error(error.issues);
}
}Each issue includes:
keygroupmessagepath
Tests
Test with plain objects instead of mutating global process.env.
const env = envSchema.parseServer({
DATABASE_URL: "https://db.example.com",
OPENAI_API_KEY: "test-key",
NEXT_PUBLIC_APP_URL: "https://app.example.com",
});This avoids import-order problems and keeps env state local to the test.
Repository
GitHub: howells/envy
