Typed and validated environment configuration for NestJS
Zod v4 · Fail-fast · Value-free errors · Zero runtime dependencies
@bymax-one/nest-config gives a NestJS application a single, typed, validated
entry point for environment configuration. It validates process.env exactly
once at bootstrap against a Zod v4 schema, fails fast with
one aggregated, human-readable error report, and exposes the result as a
deep-frozen, fully typed configuration object available anywhere in the
application through dependency injection.
The library replaces three recurring anti-patterns: scattered process.env.X
reads with ad-hoc parsing scattered across services, lazy validation where a
missing variable only explodes on the first request that needs it, and
untyped config access (config.get('port') as number) that bypasses the type
system exactly where mistakes are most expensive.
- Validate once, at bootstrap. The environment is parsed and validated a single time, before the application starts serving traffic. No lazy validation at first access, ever.
- Fail fast, fail complete. A misconfigured process does not boot. Every violation is reported together in one aggregated error, so an operator fixes the environment in one round trip instead of one variable at a time.
- Never echo values. Validation errors report variable names, paths, and constraint descriptions only. Raw values never appear in the error output, so secrets cannot leak into logs or crash reports.
- Typed end to end. The configuration type is inferred from the schema.
get('database.url')returnsstring,get('server.port')returnsnumber, with no casts and noanyanywhere in the chain. - Immutable by construction. The validated object is deep-frozen before it enters the DI container. Configuration is a fact about the process, not a mutable bag.
- The environment is injectable. The raw source defaults to
process.envbut is a plain injectable record, so tests validate any input without touching the real environment. - Zero runtime dependencies.
dependenciesis empty; NestJS,zod, andreflect-metadataare peer dependencies controlled by the consumer.
pnpm add @bymax-one/nest-config @nestjs/common @nestjs/core reflect-metadata zod| Package | Version |
|---|---|
| Node.js | >=24.0.0 |
@nestjs/common |
^11.0.0 |
@nestjs/core |
^11.0.0 |
reflect-metadata |
^0.2.0 |
zod |
^4.0.0 |
Define a schema with defineEnv. Top-level keys are namespaces, leaves are
environment-derived values; each leaf reads a SCREAMING_SNAKE_CASE variable
derived from its path (database.url reads DATABASE_URL).
import { z } from 'zod'
import { defineEnv } from '@bymax-one/nest-config'
export const envSchema = defineEnv({
server: z.object({
port: z.coerce.number().int().min(1).max(65535).default(3000),
env: z.enum(['development', 'test', 'production']).default('development')
}),
database: z.object({
url: z.url()
}),
redis: z.object({
url: z.url()
}),
log: z.object({
level: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info')
})
})
export type AppConfig = typeof envSchema.inferRegister BymaxConfigModule once in the root module (it is global by
default):
import { Module } from '@nestjs/common'
import { BymaxConfigModule } from '@bymax-one/nest-config'
import { envSchema } from './config/env.schema'
@Module({
imports: [BymaxConfigModule.forRoot({ schema: envSchema })]
})
export class AppModule {}Inject the typed ConfigService wherever configuration is needed, no casts:
import { Inject, Injectable } from '@nestjs/common'
import { ConfigService } from '@bymax-one/nest-config'
import type { AppConfig } from './config/env.schema'
@Injectable()
export class FeatureProvider {
public constructor(@Inject(ConfigService) private readonly config: ConfigService<AppConfig>) {}
public describeConnection(): string {
return `${this.config.get('database.url')}::${this.config.get('server.port')}`
}
}A process started with an incomplete environment exits immediately with the aggregated report, never one violation at a time:
BymaxConfigValidationError: environment validation failed (3 issues)
DATABASE_URL missing required value (expected: url)
AUTH_JWT_SECRET too short (expected: string, minimum 32 characters)
SERVER_PORT invalid value (expected: integer between 1 and 65535)
Fix the variables above and restart the process.
Raw values are never printed, not truncated, not masked, absent. This is a hard guarantee of the package, verified by tests.
A thin, typed factory over z.object(...) that establishes the two-level
namespace convention: top-level keys are namespaces, leaves are
environment-derived values. It composes the caller's schemas as-is, never
rewriting, cloning, or wrapping them, so coercion and defaults stay explicit
consumer choices. Leaves use z.coerce.* because the source arrives as
strings (the shape of process.env).
A leaf can override its derived variable name through schema metadata when a legacy name must be preserved:
database: z.object({
// Reads DB_CONNECTION_STRING instead of the derived DATABASE_URL.
url: z.url().meta({ env: 'DB_CONNECTION_STRING' })
})| Method | Signature |
|---|---|
forRoot(options) |
(options: BymaxConfigModuleOptions) => DynamicModule |
forRootAsync(options) |
(options: ConfigurableModuleAsyncOptions<...>) => DynamicModule |
BymaxConfigModuleOptions:
| Field | Type | Required | Notes |
|---|---|---|---|
schema |
EnvSchema |
yes | Produced by defineEnv. |
source |
Record<string, string | undefined> |
no | Defaults to process.env in the provider factory. Injectable for tests. |
onValidationError |
(issues: ReadonlyArray<ConfigIssue>) => void |
no | Observability hook invoked before the fail-fast throw. Cannot suppress the failure. |
strict |
boolean |
no | When true, source variables matching a namespace prefix but no declared leaf raise BYMAX_CONFIG_UNKNOWN_KEY. Defaults to false. |
forRootAsync resolves the source through another provider, for example a
secrets-manager client composed with process.env:
BymaxConfigModule.forRootAsync({
imports: [SecretsModule],
useFactory: (secrets: SecretsProvider) => ({
schema: envSchema,
source: { ...process.env, ...secrets.asEnvRecord() }
}),
inject: [SecretsProvider]
})isGlobal defaults to true and can be set to false through the standard
extras when an application intentionally scopes configuration to a submodule.
The typed, injectable accessor over the frozen configuration object.
| Method | Signature | Notes |
|---|---|---|
get(path) |
<P extends Path<TConfig>>(path: P) => PathValue<TConfig, P> |
Dot-path access, type inferred from the schema. |
getAll() |
() => Readonly<TConfig> |
The deep-frozen root object. |
has(path) |
(path: Path<TConfig>) => boolean |
True when the resolved value is not undefined. |
Because validation completed at bootstrap, get never throws for a declared
path: every leaf either passed validation, received its default, or the
process did not start.
export class BymaxConfigValidationError extends Error {
public readonly code: ConfigValidationCode // 'BYMAX_CONFIG_VALIDATION'
public readonly issues: ReadonlyArray<ConfigIssue>
}
export interface ConfigIssue {
readonly path: string // e.g. "database.url"
readonly variable: string // e.g. "DATABASE_URL"
readonly code: ConfigIssueCode
readonly message: string // value-free constraint description
}ConfigErrorCode is the frozen catalog backing ConfigValidationCode (the
error's own code) and ConfigIssueCode (each issue's code); see the
error catalog below for every value.
BYMAX_CONFIG and BYMAX_CONFIG_OPTIONS are module-local Symbol tokens,
exported for advanced factory-style wiring that needs the raw frozen config
or the resolved options directly instead of going through ConfigService:
import { Inject, Injectable } from '@nestjs/common'
import { BYMAX_CONFIG } from '@bymax-one/nest-config'
import type { AppConfig } from './config/env.schema'
@Injectable()
export class RawConfigConsumer {
public constructor(@Inject(BYMAX_CONFIG) private readonly config: Readonly<AppConfig>) {}
}EnvSchema, EnvShape, EnvOutput, EnvLeaf, and EnvNamespace type the
two-level schema shape produced by defineEnv; Path<T> and PathValue<T, P>
are the template-literal utilities behind ConfigService.get's compile-time
dot-path inference, limited to the two-level namespace convention. All five
are exported for advanced generic code (custom decorators, typed wrappers)
that needs to reference the schema shape directly.
| Code | Meaning |
|---|---|
BYMAX_CONFIG_VALIDATION |
One or more schema violations (top-level error code). |
BYMAX_CONFIG_MISSING |
Required variable absent from the source (issue code). |
BYMAX_CONFIG_INVALID |
Present but failed its constraint (issue code). |
BYMAX_CONFIG_UNKNOWN_KEY |
Strict mode: source variable matches no declared leaf. |
@bymax-one/nest-config/testing removes every excuse for touching
process.env in tests. createTestConfig synthesizes a complete valid
source from the schema (defaults where declared, deterministic placeholder
values elsewhere), applies selective overrides, then runs the exact
production pipeline (validate, freeze), so a test exercises the same code
path a running application does.
import { createTestConfig } from '@bymax-one/nest-config/testing'
import { envSchema } from './config/env.schema'
const config = createTestConfig(envSchema, {
database: { url: 'postgres://localhost:5432/test' }
})configTestingModule registers the production BymaxConfigModule with a
synthesized source, ready to drop into a Nest TestingModule graph:
import { Test } from '@nestjs/testing'
import { configTestingModule } from '@bymax-one/nest-config/testing'
import { envSchema } from './config/env.schema'
import { FeatureProvider } from './feature.provider'
const moduleRef = await Test.createTestingModule({
imports: [configTestingModule(envSchema, { server: { port: 0 } })],
providers: [FeatureProvider]
}).compile()Placeholder synthesis never fabricates values that could accidentally pass a secret-strength constraint; length and format constraints are honored. The subpath has no Jest dependency and works with any runner, though the family standard is Jest.
- Validate once, at bootstrap. No lazy validation at first access.
- Fail fast, fail complete. All violations are reported together in one aggregated error.
- Never echo values. Validation errors report variable names, paths, and constraint descriptions only, never raw values.
- Immutable by construction. The validated configuration is deep-frozen before it enters the DI container.
- Configuration over convention. Everything goes through
forRoot/forRootAsync. No file discovery, no magic paths, no hidden precedence rules.
- Two-level namespace convention. The path-inference utilities
(
Path<T>,PathValue<T, P>) targetnamespace.leafschemas. Deeper nesting validates correctly but is not covered byget()path inference;getAll()remains fully typed for arbitrary depth. - String-shaped sources only. The source record is
Record<string, string | undefined>by design, the shape of a process environment. Structured sources must be serialized into that shape before validation. - No
.envfile loading. Process environment population is the platform's job: Node's native--env-file, container orchestrators, or CI secrets. Bundling a loader would add a dependency and a second source-of-truth. - No multi-source precedence. One source record per module
registration. Precedence between layers (defaults, env, secrets) is the
caller's composition (
{ ...a, ...b }), kept explicit on purpose.
Publishing is driven entirely from CI through the release.yml workflow, which
uses npm OIDC trusted publishing (no long-lived npm token) and attaches build
provenance. Provenance and the security workflows (CodeQL, Scorecard, dependency
review) require a public repository, so the publish job is gated on repository
visibility and stays inactive while the repository is private.
Go-live is a deliberate, manual sequence performed by a maintainer:
- Verify the gates locally.
pnpm mutation(score at or above the break threshold of 95),pnpm prepublishOnly,pnpm build && pnpm test:e2e, andpnpm publish --dry-run(confirm the tarball packs onlydist/,LICENSE,README.md, andCHANGELOG.md). - Confirm the version.
package.jsonversionand the top datedCHANGELOG.mdheading must both read the version being released. - Make the repository public. This activates the provenance publish job and the CodeQL, Scorecard, and dependency-review gates.
- Tag from
main. Push an annotatedvX.Y.Ztag matching the manifest version. The tag push triggersrelease.yml, which verifies the tag againstpackage.json, re-runs the release gates, and publishes with provenance. - Post-publish smoke. In a scratch directory outside the repository, install the freshly published version alongside the NestJS 11 peers and boot a minimal fixture to confirm the shipped artifact resolves and starts.