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

Example Β· typescript
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.

Example Β· ts
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.

Example Β· ts
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.

Example Β· ts
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

  • Be the first to comment on this lesson.