Typed Config & Env Validation
Fail fast on bad environment variables and inject fully-typed, namespaced configuration everywhere.
The worst production incidents start with a missing or malformed environment variable that nobody noticed until 3am. A senior Nest setup validates config at boot and crashes immediately if anything is wrong - far better than a undefined surfacing hours later.
Validate the schema at startup
@nestjs/config's ConfigModule.forRoot accepts a validate function. Run your whole process.env through a schema (Zod, Joi, or class-validator) there; throw and the app never starts with bad config.
ConfigModule.forRoot({
isGlobal: true,
validate: (env) => EnvSchema.parse(env),
});Namespaced config with registerAs
Group related settings into typed namespaces. registerAs('db', () => ({...})) gives you a token you can inject with full types, instead of scattering configService.get('DB_HOST') string lookups everywhere.
getOrThrow over get
For values that must exist, prefer configService.getOrThrow('KEY'). It removes the string | undefined from the type and makes the failure loud. Optional values keep get with an explicit default.
Type the whole thing
Pair ConfigService<AppConfig, true> (the true enables strict inference) with your namespace types so get('db.host') is autocompleted and checked. Config becomes as safe as the rest of your codebase.
Example
import { Module, Injectable } from '@nestjs/common';
import { ConfigModule, ConfigService, registerAs } from '@nestjs/config';
import { z } from 'zod';
// 1. One schema validates the entire environment at boot.
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export type Env = z.infer<typeof EnvSchema>;
// 2. Namespaced, typed config slices.
export const dbConfig = registerAs('db', () => ({
url: process.env.DATABASE_URL!,
poolSize: Number(process.env.DB_POOL ?? 10),
}));
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [dbConfig],
validate: (raw) => EnvSchema.parse(raw), // throws -> app won't start
}),
],
})
export class AppConfigModule {}
@Injectable()
export class DatabaseService {
private readonly url: string;
constructor(private readonly config: ConfigService<Env, true>) {
// getOrThrow removes `undefined` from the type and fails loudly.
this.url = this.config.getOrThrow('DATABASE_URL', { infer: true });
}
connectionString() {
return this.url;
}
}When to use it
- A team validates all required environment variables against a Joi schema in ConfigModule so a missing DATABASE_URL crashes the app at startup rather than at the first database call.
- A developer uses registerAs("database", () => ({ url: process.env.DATABASE_URL })) to create namespaced configuration objects that are injected with full TypeScript type safety.
- An engineer injects a typed ConfigType<typeof databaseConfig> instead of a string key so IDEs provide autocomplete and type checking for all config properties.
More examples
Joi env validation in ConfigModule
Validates all required env vars against a Joi schema at startup, collecting all errors at once so the app never boots in a misconfigured state.
import * as Joi from 'joi';
import { ConfigModule } from '@nestjs/config';
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').required(),
PORT: Joi.number().default(3000),
DATABASE_URL: Joi.string().uri().required(),
JWT_SECRET: Joi.string().min(32).required(),
}),
validationOptions: { abortEarly: false },
})Namespaced config with registerAs
Creates a namespaced database configuration factory so all DB settings live under the "database" namespace with typed properties.
import { registerAs } from '@nestjs/config';
export const databaseConfig = registerAs('database', () => ({
url: process.env.DATABASE_URL,
poolSize: parseInt(process.env.DB_POOL_SIZE ?? '10', 10),
ssl: process.env.DATABASE_SSL === 'true',
}));
// In AppModule:
ConfigModule.forRoot({ load: [databaseConfig] })Inject typed config namespace
Injects the typed config namespace using ConfigType<typeof databaseConfig> so dbCfg properties have full TypeScript autocompletion and type checking.
import { Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import { Inject } from '@nestjs/common';
import { databaseConfig } from './config/database.config';
@Injectable()
export class DatabaseService {
constructor(
@Inject(databaseConfig.KEY)
private dbCfg: ConfigType<typeof databaseConfig>,
) {}
getUrl() { return this.dbCfg.url; } // fully typed
getPoolSize() { return this.dbCfg.poolSize; }
}
Discussion