Skip to content

FRD: Config & Environment Library

FieldValue
IDFRD-015
OwnerDavid Holmes
StatusDraft
Last Updated2026-05-26
Open Source Librarieszod, dotenv
RelatedADR-027 (Default Tech Stack)
Target Releasev2.0.0
TypeLibrary
ComplexityS

Document Summary

This FRD defines a shared configuration and environment-variable library that provides Zod-backed env schemas, app-safe and server-safe accessors, secret-safe logging, and a consistent config-loading pattern. The library replaces per-app ad-hoc process.env access with a validated, typed, and documented approach that catches missing or malformed environment variables at startup rather than at runtime.


Introduction

Every application in the platform reads environment variables for configuration: database URLs, API keys, feature flags, service endpoints, log levels. Today each app invents its own config bootstrap: some use raw process.env access with no validation, some use ad-hoc Zod schemas, some use .env files with inconsistent naming conventions. Misconfigured environments cause runtime crashes that are difficult to diagnose. This library standardises config loading so that every app fails fast at startup with a clear error message when configuration is invalid.


Scope

In scope

  • defineEnvSchema(schema) function that creates a validated, typed env config from a Zod schema.
  • loadEnv(schema, source?) function that reads from process.env (or a provided source) and validates against the schema.
  • App-safe accessors: a clientEnv subset that only exposes non-secret values safe for browser bundles.
  • Server-safe accessors: a serverEnv subset that includes secrets and is only available in server contexts.
  • Secret-safe logging: logConfig(config) that prints the loaded configuration with secret values masked.
  • .env file loading via dotenv integration (optional, development only).
  • Docs covering local, dev, staging, and production configuration patterns.
  • Tests validating schema enforcement, default values, secret masking, and error messages.

Out of scope

  • Secret management infrastructure (Vault, AWS Secrets Manager, Doppler).
  • Feature flag systems (LaunchDarkly, Unleash).
  • Runtime config hot-reloading.
  • Environment-specific config files (e.g., config.production.json). The library reads from env vars only.

Users and Pain Points

UserPain Point
App developerAccesses process.env.DATABASE_URL with no validation; a typo (DATABSE_URL) causes a runtime crash deep in the database connection code instead of at startup.
App developerNo clear distinction between env vars safe for the browser bundle and server-only secrets. Accidentally exposes an API key in the client bundle.
App developerEach app has a different .env.example format with inconsistent naming conventions.
DevOps engineerDeploys a new environment and gets cryptic runtime errors because a required env var is missing. Wants a clear startup error listing all missing variables.
Security reviewerFinds secrets logged in plaintext during config debugging.

Definitions

TermDefinition
Env schemaA Zod schema that defines the expected environment variables, their types, defaults, and whether they are secret.
Client envThe subset of configuration values that are safe to include in browser bundles (no secrets).
Server envThe full configuration including secrets, only available in server-side code.
SecretAn environment variable whose value must not appear in logs, error messages, or browser bundles. Identified by a .secret() marker in the schema.
Fail-fastThe application exits immediately at startup if required env vars are missing or invalid, rather than failing later at runtime.

Current State

  • No shared typed runtime config helper exists.
  • Each app invents its own config bootstrap, typically with raw process.env access.
  • Some apps use .env files with dotenv; others do not.
  • No convention for distinguishing client-safe vs. server-only variables.
  • No convention for masking secrets in log output.
  • Naming conventions are inconsistent (e.g., DB_URL vs. DATABASE_URL vs. POSTGRES_URL).

Proposed Solution

Schema definition

import { z } from "zod";
import { defineEnvSchema, secret } from "@dmwd/config";
const envSchema = defineEnvSchema({
// Server-only secrets
DATABASE_URL: secret(z.string().url()),
JWT_SECRET: secret(z.string().min(32)),
// Client-safe values
PUBLIC_API_URL: z.string().url(),
PUBLIC_APP_NAME: z.string().default("My App"),
// Shared values with transforms
PORT: z.coerce.number().int().positive().default(3000),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
NODE_ENV: z.enum(["development", "staging", "production"]).default("development"),
});

Loading and validation

// Fails fast at startup if validation fails
const env = loadEnv(envSchema);
// Access typed values
const dbUrl: string = env.DATABASE_URL;
const port: number = env.PORT;

Client/server split

// Server-side: all values available
const serverEnv = env;
// Client-side: only non-secret values
const clientEnv = env.client;
// clientEnv.PUBLIC_API_URL ✓
// clientEnv.DATABASE_URL ✗ (TypeScript error, not present)

Secret-safe logging

logConfig(env);
// Output:
// DATABASE_URL: [SECRET]
// JWT_SECRET: [SECRET]
// PUBLIC_API_URL: https://api.example.com
// PUBLIC_APP_NAME: My App
// PORT: 3000
// LOG_LEVEL: info
// NODE_ENV: production

Error reporting

When validation fails, loadEnv throws an EnvValidationError with a formatted message listing all invalid variables:

Environment validation failed:
DATABASE_URL: Required
JWT_SECRET: String must contain at least 32 character(s) (received 10)
PORT: Expected number, received "abc"

Requirements

IDPriorityRequirement
CFG-01P0defineEnvSchema accepts a Zod schema and returns a typed schema descriptor with secret annotations.
CFG-02P0loadEnv validates process.env against the schema and returns a typed config object.
CFG-03P0loadEnv throws EnvValidationError with all validation failures listed on schema mismatch.
CFG-04P0secret() marker identifies values that must not appear in logs or client bundles.
CFG-05P0env.client accessor returns only non-secret values with correct TypeScript types.
CFG-06P0logConfig(env) prints all values with secrets masked as [SECRET].
CFG-07P1Optional dotenv integration loads .env files in development.
CFG-08P0Tests for schema validation, default values, secret masking, and error formatting.
CFG-09P1Zod coerce and transform work for numeric and boolean env vars.
CFG-10P1env.isProduction, env.isDevelopment, env.isStaging boolean convenience accessors.

Functional Requirements

  1. Schema definition: defineEnvSchema wraps a Zod object schema with metadata about which keys are secrets. The secret() helper returns the same Zod type but marks it in the schema metadata.
  2. Validation: loadEnv(schema, source?) reads from source (defaults to process.env), strips unknown keys, validates against the schema, and returns the parsed result. If validation fails, it collects all errors and throws EnvValidationError.
  3. Client split: The client property on the returned config is a Proxy (or a pre-filtered plain object) that only exposes non-secret keys. Accessing a secret key on client returns undefined at runtime and is a TypeScript error at compile time.
  4. Secret-safe logging: logConfig iterates over the schema keys, prints non-secret values as-is, and prints secret values as [SECRET]. Output is a formatted string suitable for a startup log line.
  5. Dotenv integration: When NODE_ENV is development and dotenv is installed, loadEnv automatically loads .env, .env.local, and .env.development files (in that order, later files override). In production, .env files are not loaded.
  6. Error formatting: EnvValidationError includes a message with one line per invalid variable, a issues array of { key, message } objects, and the original Zod error for programmatic access.
  7. Convenience booleans: The returned config includes isProduction, isDevelopment, and isStaging computed from the NODE_ENV value (if it exists in the schema).

Non-Functional Requirements

CategoryRequirement
Startup timeloadEnv completes in under 5ms for a schema with 30 variables.
Bundle sizeThe library adds less than 1 KB gzipped beyond Zod itself.
PortabilityWorks in Node 20+, Deno (via Deno.env), and Cloudflare Workers (via env parameter). loadEnv accepts a source parameter so it is not coupled to process.env.
SecuritySecret values are never included in error messages. If a secret fails validation, the error says the variable name and the Zod error message but not the actual value.
Type safetyThe return type of loadEnv is fully inferred from the Zod schema. env.client excludes secret keys at the type level.

API/Interface Requirements

Schema definition

function defineEnvSchema<T extends z.ZodRawShape>(
shape: T,
): EnvSchema<T>;
function secret<T extends z.ZodTypeAny>(schema: T): SecretSchema<T>;

Loading

function loadEnv<T extends EnvSchema<any>>(
schema: T,
source?: Record<string, string | undefined>,
): EnvConfig<T>;
interface EnvConfig<T> {
// All keys from the schema, typed
[key: string]: inferred;
// Client-safe subset (secrets excluded)
client: ClientEnv<T>;
// Convenience booleans
isProduction: boolean;
isDevelopment: boolean;
isStaging: boolean;
}

Logging

function logConfig(env: EnvConfig<any>): string;

Error

class EnvValidationError extends Error {
readonly issues: Array<{ key: string; message: string }>;
readonly zodError: z.ZodError;
}

Accessibility Requirements

This is a headless library with no UI. Error messages should be clear and readable in terminal output. No accessibility requirements apply directly.


Content and Documentation Requirements

  • TSDoc on every exported function and type.
  • A docs/best-practices/config-and-env.mdx guide covering: defining an env schema, loading config, client/server split, secret masking, dotenv integration for development, production deployment checklist, and testing with custom sources.
  • Storybook docs page under Docs/Best Practices/Config & Environment.
  • An .env.example template generator: generateEnvExample(schema) that produces a commented .env.example file from the schema.

Dependencies

DependencyTypeNotes
zodExistingSchema validation and type inference.
dotenvPeer (optional).env file loading in development. Not a runtime dependency in production.

Risks and Tradeoffs

RiskLikelihoodImpactMitigation
secret() marker relies on developer discipline; forgetting to mark a secret exposes it to the client bundle.MediumHighLint rule or code review checklist item. Variable naming convention: secrets do not start with PUBLIC_. The library warns at load time if a non-PUBLIC_ variable is not marked as secret.
Dotenv auto-loading in development may surprise developers who expect env vars from their shell.LowLowOnly load .env files when NODE_ENV=development and dotenv is installed. Log a message when files are loaded.
Zod coerce for numbers may silently convert invalid strings to NaN.LowMediumThe schema uses z.coerce.number().int().positive() which rejects NaN. Document this pattern.
Some runtimes (Cloudflare Workers) do not have process.env.MediumLowloadEnv accepts a source parameter. Document the Workers pattern: loadEnv(schema, env) where env is the Workers env binding.

Open Questions

  1. Should the library support nested config objects (e.g., database: { url, poolSize }) or keep it flat? Leaning toward flat keys matching env var names, with an optional group() utility for documentation purposes.
  2. Should generateEnvExample(schema) be a runtime export or a CLI command? Leaning toward a runtime export that apps call in a script.
  3. Should the library validate that PUBLIC_* variables are not marked as secrets and vice versa? This would enforce a naming convention but may be too opinionated.

Acceptance Criteria

  • defineEnvSchema accepts a Zod schema with secret() annotations and returns a typed schema descriptor.
  • loadEnv validates process.env and returns a fully typed config object.
  • Missing or invalid env vars cause EnvValidationError with all failures listed (not just the first one).
  • Secret values never appear in error messages.
  • env.client excludes secret values at both the type level and runtime.
  • logConfig prints all values with secrets masked as [SECRET].
  • z.coerce.number() and z.coerce.boolean() work for numeric and boolean env vars.
  • Default values from the Zod schema are applied when env vars are missing.
  • loadEnv(schema, source) accepts a custom source for testing and non-Node runtimes.
  • isProduction, isDevelopment, isStaging convenience booleans are correct.
  • Tests cover: valid config, missing required vars, invalid types, default values, secret masking, and custom source.

LLM Handoff Instructions

When implementing this FRD:

  1. Create src/lib/config/ as a new module. Files: index.ts, schema.ts (defineEnvSchema, secret marker), loader.ts (loadEnv), logger.ts (logConfig), errors.ts (EnvValidationError), types.ts (type utilities for client/server split).
  2. The secret() function wraps a Zod schema by attaching a Symbol metadata marker. defineEnvSchema reads this marker to build the secret key set.
  3. loadEnv uses z.object(shape).safeParse(source). On failure, map zodError.issues to { key: issue.path[0], message: issue.message } and throw EnvValidationError.
  4. The client accessor is a plain frozen object created at load time by filtering out secret keys. Do not use a Proxy — a plain object is simpler and produces better error messages.
  5. logConfig iterates over Object.keys(schema), checks the secret set, and formats output. Return a string; the caller can log it however they prefer.
  6. Dotenv integration: check typeof process !== "undefined" && process.env?.NODE_ENV === "development". Dynamically import("dotenv") and call .config(). Catch the import error silently if dotenv is not installed.
  7. Tests go in src/lib/config/*.test.ts. Pass a custom source record to loadEnv instead of mutating process.env.
  8. Run pnpm typecheck and pnpm vitest run --project unit before declaring complete.

Decision Log

DateDecisionRationale
2026-05-26Flat env vars only, no nested config objects.Env vars are inherently flat. Nested config adds indirection without benefit.
2026-05-26secret() marker via Symbol metadata, not a naming convention.Naming conventions are fragile and vary across teams. Explicit marking is safer.
2026-05-26dotenv is a peer dependency, not bundled.Production deployments should not depend on .env files. Development-only convenience.
2026-05-26loadEnv accepts a source parameter for portability.Supports Cloudflare Workers, Deno, and test environments without process.env.

Document History

VersionDateAuthorChanges
0.12026-05-26David HolmesInitial draft.