Validate NestJS config with Standard Schema.
Define your environment as a schema and get validated, coerced, fully typed config for @nestjs/config. Works with Zod, Valibot, ArkType, or any Standard Schema validator.
- 🧩 Validator-agnostic: Use Zod, Valibot, ArkType, or any Standard Schema library.
- 🔒 End-to-end types: Infer a typed config for
ConfigServiceandprocess.envfrom one schema. - ♻️ Automatic coercion: Env strings become numbers, booleans, and JSON, with an opt-out.
- 🧱 Structured config: Nested schemas map to
UPPER_SNAKE_CASEkeys and namespaced config. - 🚦 Fail fast: Invalid env aborts startup and reports every problem at once.
pnpm add nest-safe-config @nestjs/config zod # or valibot, arktype, ...Each leaf schema maps to one environment variable. Nested objects become key prefixes.
// app.config.ts
import type { InferConfig } from 'nest-safe-config'
import { defineConfig } from 'nest-safe-config'
import { z } from 'zod'
export const config = defineConfig({
nodeEnv: z.enum(['development', 'production', 'test']).default('development'),
port: z.number().default(3000),
database: {
url: z.string(),
poolSize: z.number().default(10),
},
})
export type AppConfig = InferConfig<typeof config>Valibot
import { defineConfig } from 'nest-safe-config'
import * as v from 'valibot'
export const config = defineConfig({
nodeEnv: v.optional(v.picklist(['development', 'production', 'test']), 'development'),
port: v.optional(v.number(), 3000),
database: {
url: v.string(),
poolSize: v.optional(v.number(), 10),
},
})ArkType
import { type } from 'arktype'
import { defineConfig } from 'nest-safe-config'
export const config = defineConfig({
nodeEnv: type('"development" | "production" | "test"'),
port: type('number'),
database: {
url: type('string'),
poolSize: type('number'),
},
})ArkType
.default()is not supported on a standalone leaf. See Known limitations.
Pass validate and load to ConfigModule.forRoot. validate runs at startup; load exposes one namespaced config per top-level key.
// app.module.ts
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { config } from './app.config'
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validate: config.validate,
load: config.load,
}),
],
})
export class AppModule {}NestJS loads .env and merges it over process.env before validate runs, so these rules apply to .env values too. Use validate and load from the same defineConfig result.
Type ConfigService with the exported AppConfig for autocompletion and inferred return types on get.
// app.service.ts
import type { AppConfig } from './app.config'
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
@Injectable()
export class AppService {
constructor(private readonly configService: ConfigService<AppConfig, true>) {}
get port(): number {
return this.configService.get('port', { infer: true })
}
get databaseUrl(): string {
return this.configService.get('database.url', { infer: true })
}
}Augment NodeJS.ProcessEnv with InferProcessEnv so direct process.env reads are typed. This is useful outside the DI container, for example in main.ts or instrumentation files.
// env.d.ts
import type { InferProcessEnv } from 'nest-safe-config'
import type { config } from './app.config'
declare global {
namespace NodeJS {
interface ProcessEnv extends InferProcessEnv<typeof config> {}
}
}
export {}Nested keys are joined into a single UPPER_SNAKE_CASE variable.
defineConfig({
mail: {
auth: { user: z.string(), pass: z.string() },
},
})
// reads MAIL_AUTH_USER and MAIL_AUTH_PASSRead a whole namespace with a dotted path: configService.get('mail.auth', { infer: true }).
A leaf whose schema is an object, array, or record is parsed from a single JSON-encoded variable. A union leaf accepts whichever branch matches: a JSON value for an object or array branch, or a plain string for a string branch.
defineConfig({
// ORIGINS='["https://a.com","https://b.com"]'
origins: z.array(z.string()),
// a path to a file, or the inline JSON itself
googleApplicationCredentials: z.union([
z.string(),
z.object({
project_id: z.string(),
client_email: z.string(),
private_key: z.string(),
}),
]).optional(),
})GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp.json
# or
GOOGLE_APPLICATION_CREDENTIALS='{"project_id":"app","client_email":"sa@app.iam","private_key":"..."}'Env strings are pre-parsed with destr by default, so number, boolean, and JSON schemas work without z.coerce.
defineConfig({ port: z.number(), debug: z.boolean() })
// PORT=3000 becomes the number 3000
// DEBUG=true becomes the boolean true
destris lenient. It normalizes some inputs (for example'8e3'becomes8000) and strips surrounding quotes, while out-of-range or leading-zero digit strings stay strings. Use{ coerce: false }to keep a value exactly as written.
Set { coerce: false } to keep env strings raw, for values that must not be parsed such as secrets that look numeric.
defineConfig({ apiKey: z.string() }, { coerce: false })With coercion off, opt individual leaves back into parsing through your validator, for example z.coerce.number().
Missing, empty, and whitespace-only values are treated as absent, so defaults and optionals apply.
defineConfig({
port: z.number().default(3000), // PORT unset or blank -> 3000
sentryDsn: z.string().optional(), // SENTRY_DSN unset -> undefined
})Lean on your validator: built-in formats and constraints, custom refinements, and transforms.
import { defineConfig } from 'nest-safe-config'
import { z } from 'zod'
export const config = defineConfig({
// built-in formats and constraints
port: z.int().min(1).max(65535),
publicUrl: z.url(),
// custom refinement
stripeKey: z.string().refine(v => v.startsWith('sk_'), 'must start with "sk_"'),
// transform: "a.com, b.com" -> ["a.com", "b.com"]
corsOrigins: z.string().transform(v => v.split(',').map(o => o.trim())),
})Transformed output types flow through: InferConfig and ConfigService see corsOrigins as string[]. Validation is synchronous, so async refinements and transforms throw at startup; move that work into your bootstrap.
When validation fails, startup aborts with a ConfigValidationError that lists every offending variable.
Config validation failed:
- port: Invalid input: expected number, received string (env: PORT)
- database.url: Invalid input: expected string, received undefined (env: DATABASE_URL)
The error exposes a structured issues array (path, env, message, raw) and sets cause to the underlying Standard Schema issues.
Keys are converted to UPPER_SNAKE_CASE. Nested objects are joined with underscores.
| Schema path | Environment variable |
|---|---|
port |
PORT |
apiKey |
API_KEY |
database.url |
DATABASE_URL |
database.poolSize |
DATABASE_POOL_SIZE |
mail.auth.user |
MAIL_AUTH_USER |
- Synchronous only. Async refinements and transforms (Zod async
.refine, Valibot*Async, ArkType async morphs) throw at validation time. - ArkType defaults. A standalone leaf cannot use
.default(), because the result is not a Standard Schema. Wrap it in an object schema, or apply the default in your own bootstrap.