Feature-scoped environment validation for web apps.
feature-env helps you define environment variables once, validate only the groups each module needs, and generate .env.example from the same schema.
Previously named
safe-env-route.
npm install feature-envsrc/env/schema.ts
import { defineEnv, enumOf, int, str, url } from "feature-env";
export const envSchema = defineEnv({
shared: {
NODE_ENV: enumOf(["development", "test", "production"] as const),
APP_URL: url(),
PORT: int(),
},
db: {
MONGODB_URI: str(),
},
});src/env/server.ts
import { requireEnv } from "feature-env";
import { envSchema } from "./schema";
export const serverEnv = requireEnv(envSchema, ["shared"] as const);src/env/db.ts
import { requireEnv } from "feature-env";
import { envSchema } from "./schema";
export const dbEnv = requireEnv(envSchema, ["db"] as const);This is a practical backend pattern (like your BPLO setup):
- Define one grouped schema in
src/env/schema.ts - Create one env module per feature (
db.ts,auth.ts,mail.ts,security.ts, etc.) - Each module calls
requireEnv(...)oroptionalEnv(...)only for groups it needs - Derive app-specific values (like
isProduction, parsed origin lists) inside those modules
src/env/schema.ts (example shape)
import { bool, defineEnv, enumOf, int, str } from "feature-env";
export const envSchema = defineEnv({
runtime: {
NODE_ENV: enumOf(["development", "test", "production"] as const),
},
shared: {
NODE_ENV: enumOf(["development", "test", "production"] as const),
PORT: int(),
CORS_ORIGINS: str(),
},
db: {
MONGO_DB_URI: str(),
},
auth: {
JWT_ACCESS_TOKEN: str(),
JWT_REFRESH_TOKEN: str(),
GOOGLE_CLIENT_ID: str(),
},
tokens: {
JWT_ACCESS_TOKEN: str(),
JWT_REFRESH_TOKEN: str(),
},
mail: {
MAIL_HOST: str(),
MAIL_PORT: int(),
MAIL: str(),
MAIL_PASSWORD: str(),
},
security: {
REFRESH_COOKIE_NAME: str(),
REFRESH_COOKIE_PATH: str(),
GLOBAL_RATE_LIMIT_MINUTES: int(),
GLOBAL_RATE_LIMIT_MAX: int(),
RECAPTCHA_SECRET_KEY: str(),
},
payments: {
PAYMENT_QR_SECRET: str(),
},
seed: {
SEED_SUPER_ADMIN: bool(),
SUPER_ADMIN_EMAIL: str(),
SUPER_ADMIN_PASSWORD: str(),
},
});src/env/server.ts (required + derived)
import { requireEnv } from "feature-env";
import { envSchema } from "@/env/schema";
const sharedEnv = requireEnv(envSchema, ["shared"] as const);
const normalizeOrigin = (value: string) => value.trim().replace(/\/+$/, "");
const envAllowedOrigins = sharedEnv.CORS_ORIGINS.split(",")
.map(normalizeOrigin)
.filter(Boolean);
const isProduction = sharedEnv.NODE_ENV === "production";
const devFallbackOrigins = isProduction ? [] : ["http://localhost:5173"];
export const serverEnv = {
...sharedEnv,
isProduction,
allowedOrigins: Array.from(
new Set([...envAllowedOrigins, ...devFallbackOrigins]),
),
};src/env/security.ts (combine groups + derive)
import { requireEnv } from "feature-env";
import { envSchema } from "@/env/schema";
const env = requireEnv(envSchema, ["runtime", "security"] as const);
export const securityEnv = {
...env,
isProduction: env.NODE_ENV === "production",
};src/env/payments.ts (optional values + normalization)
import { optionalEnv } from "feature-env";
import { envSchema } from "./schema";
const optionalEnvValues = optionalEnv(envSchema, [
"payments",
"tokens",
] as const);
const normalize = (value: unknown) => String(value ?? "").trim();
export const paymentEnv = {
paymentQrSecret: normalize(optionalEnvValues.PAYMENT_QR_SECRET),
jwtAccessToken: normalize(optionalEnvValues.JWT_ACCESS_TOKEN),
};Important: avoid selecting groups together when they define the same key name (for example runtime.NODE_ENV and shared.NODE_ENV), because duplicate keys across selected groups now throw a validation error.
src/app.ts (or src/index.ts)
import { serverEnv } from "./env/server";
import { connectDB } from "./db/connect";
async function bootstrap() {
console.log(`Starting on port ${serverEnv.PORT}`);
await connectDB();
}
bootstrap().catch((error) => {
console.error(error);
process.exit(1);
});src/db/connect.ts
import mongoose from "mongoose";
import { dbEnv } from "../env/db";
export async function connectDB() {
await mongoose.connect(dbEnv.MONGODB_URI);
}Registers grouped schema (source of truth for validation and .env.example generation).
Validates selected groups and throws on missing/invalid values.
Validates selected groups but allows missing values.
Creates and returns .env.example content as a string from the schema registered with defineEnv().
import { generateEnvExample } from "feature-env";
import { envSchema } from "./env/schema";
const output = generateEnvExample();
console.log(output);With options:
import { generateEnvExample } from "feature-env";
import { envSchema } from "./env/schema";
const output = generateEnvExample({
includeComments: true,
newlineBetweenGroups: true,
});Generates .env.example content from the same schema and writes it to a file. Default path is .env.example.
import { writeEnvExample } from "feature-env";
writeEnvExample(); // .env.example
writeEnvExample("./config/.env.example");
writeEnvExample(".env.example", { overwrite: false }); // throw if file existsProgrammatic implementation example:
scripts/generate-env-example.ts
import "../src/env/schema"; // calls defineEnv(...)
import { writeEnvExample } from "feature-env";
writeEnvExample(".env.example");# [shared]
APP_URL=
NODE_ENV=
PORT=
DEBUG=
# [auth]
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# [payments]
STRIPE_SECRET_KEY=str(): stringurl(): valid URL stringbool(): boolean (true/false,1/0,yes/no,on/off)int(): whole numberport(): valid TCP port (1-65535)enumOf([...]): one allowed string valuejson(): parses JSON string to object/array
Pass a custom env object (useful for tests):
requireEnv(["shared"], {
env: {
APP_URL: "https://example.com",
NODE_ENV: "development",
PORT: "3000",
DEBUG: "true",
},
});Fail on keys not defined in selected groups:
requireEnv(["shared"], {
strictUnknownKeys: true,
env: {
APP_URL: "https://example.com",
APP_URl: "https://typo.example.com",
},
});npx feature-env API_KEY DATABASE_URLnpx feature-env --generate-exampleCustom output path:
npx feature-env --generate-example --out ./config/.env.examplePrevent overwrite of an existing output file:
npx feature-env --generate-example --no-overwriteExplicit schema path:
npx feature-env --generate-example --schema ./dist/env/schema.jsIf your schema is TypeScript and compiled to dist, run:
npm run build
npx feature-env --generate-exampleAuto-detect checks (in order):
dist/env/schema.jsdist/schema.jsenv/schema.jsschema.js
Notes:
- Installing
feature-envalone does not generate.env.example. - Generation happens when
npx feature-env --generate-exampleis executed. - If you install globally (
npm install -g feature-env), you can runfeature-env ...withoutnpx.
In the app that uses feature-env, add:
{
"scripts": {
"build": "tsc",
"env:example": "npx feature-env --generate-example",
"postinstall": "npm run build && npm run env:example"
}
}Use --schema only when auto-detect does not match your layout.
For gradual migration:
import { assertEnv, checkEnv, runCli } from "feature-env/legacy";examples/express-app/src/env/schema.tsexamples/express-app/src/env/server.tsexamples/express-app/src/env/auth.tsexamples/express-app/src/env/payments.ts
npm run build
npm test