Namespaces and Typed Config
Group related settings behind one key with registerAs, then inject them as a typed object instead of string paths.
registerAs<T>(token: string | symbol, configFactory: () => T | Promise<T>): typeof configFactory & { KEY: string | symbol; asProvider(): ... }A flat list of thirty variables is hard to pass around. A namespace groups related settings under a single key and gives you an injection token for them, so a service can ask for "the database settings" rather than for seven strings.
registerAs
registerAs('database', factory) returns the same factory function with two extra non-enumerable properties attached: KEY, the token the factory's result is filed under, and asProvider(). Register the factory with load: [databaseConfig] in forRoot(), or with ConfigModule.forFeature(databaseConfig) in the feature module that owns it. The values then read as config.get('database.host'), or by injecting KEY directly.
Typing
ConfigType<typeof databaseConfig> is the factory's return type, unwrapped if the factory returns a promise. Combined with @Inject(databaseConfig.KEY) it gives a service a typed object with no dotted strings to misspell.
Shorthand and longhand
databaseConfig.asProvider() returns exactly this object:
{ imports: [ConfigModule.forFeature(databaseConfig)],
useFactory: (config) => config,
inject: [databaseConfig.KEY] }Both forms appear in real projects, usually passed straight to another module's forRootAsync().
What trips people up
Factories registered with forFeature are merged as their module initialises, and module initialisation order is not fixed. Read partial configuration in onModuleInit(), not in a constructor.
Example
// src/config/database.config.ts
import { registerAs } from '@nestjs/config';
// The default export is the factory itself, with KEY and asProvider() attached.
// It runs when the module that registers it is created, so process.env must
// already be populated by ConfigModule.forRoot().
export default registerAs('database', () => ({
host: process.env.DATABASE_HOST ?? 'localhost',
// Parse here. ConfigType trusts whatever this factory returns, so an
// unparsed process.env value would be typed string | undefined.
port: Number.parseInt(process.env.DATABASE_PORT ?? '5432', 10),
username: process.env.DATABASE_USER ?? 'postgres',
password: process.env.DATABASE_PASSWORD ?? '',
database: process.env.DATABASE_NAME ?? 'app',
ssl: process.env.DATABASE_SSL === 'true',
poolSize: Number.parseInt(process.env.DATABASE_POOL_SIZE ?? '10', 10),
}));
// src/app.module.ts
// ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig] })
// makes the values available as config.get('database.host') app-wide.
When to use it
- Hand TypeORM or Mongoose a typed settings object instead of a dozen configService.get calls.
- Keep each feature's settings in the feature folder, registered with forFeature.
- Parse and default every numeric or boolean setting in one factory so no service parses strings.
- Give a service a constructor that names exactly the configuration it depends on.
More examples
Injecting a namespace by its token
This is the version to prefer inside a feature module: the service's constructor states exactly which configuration it depends on, and nothing else is reachable from it. Compare with injecting the whole ConfigService, where any service can read any key and nothing in the signature says which ones it actually uses.
// src/database/pool.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import databaseConfig from '../config/database.config.js';
@Injectable()
export class PoolService {
constructor(
// KEY is the token registerAs filed the factory's result under.
// ConfigType<typeof databaseConfig> is that factory's return type,
// so dbConfig.port is number and a typo is a compile error.
@Inject(databaseConfig.KEY)
private readonly dbConfig: ConfigType<typeof databaseConfig>,
) {}
connectionString(): string {
const { username, password, host, port, database } = this.dbConfig;
return `postgres://${username}:${password}@${host}:${port}/${database}`;
}
}
asProvider(), and the longhand it stands for
Every Nest module with a forRootAsync takes this imports/inject/useFactory trio, so learning the longhand once explains TypeORM, Mongoose, BullMQ and the cache module at the same time. Write the longhand when the keys need renaming, and asProvider() when they do not.
// src/database/database.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigType } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import databaseConfig from '../config/database.config.js';
@Module({
imports: [
TypeOrmModule.forRootAsync({
// Longhand. databaseConfig.asProvider() returns this same object,
// except that its useFactory passes the config straight through
// instead of mapping it. Use asProvider() when the namespace already
// has the exact shape the other module expects:
// TypeOrmModule.forRootAsync(databaseConfig.asProvider())
imports: [ConfigModule.forFeature(databaseConfig)],
inject: [databaseConfig.KEY],
useFactory: (db: ConfigType<typeof databaseConfig>) => ({
type: 'postgres' as const,
host: db.host,
port: db.port,
username: db.username,
password: db.password,
database: db.database,
ssl: db.ssl,
autoLoadEntities: true,
}),
}),
],
})
export class DatabaseModule {}
Partial registration with forFeature
Use forFeature when a feature owns settings nothing else needs — mail, an S3 bucket, a third-party API. It keeps the root module from growing a load array that lists every corner of the app, at the cost of one rule to remember: build things from that config in onModuleInit, not in the constructor.
// src/config/mail.config.ts
import { registerAs } from '@nestjs/config';
export default registerAs('mail', () => ({
host: process.env.SMTP_HOST ?? 'localhost',
port: Number.parseInt(process.env.SMTP_PORT ?? '587', 10),
from: process.env.MAIL_FROM ?? '[email protected]',
}));
// src/mail/mail.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import mailConfig from '../config/mail.config.js';
import { MailService } from './mail.service.js';
@Module({
// Registers this namespace only for this module's providers, so the root
// module does not need a load entry for every feature in the app.
imports: [ConfigModule.forFeature(mailConfig)],
providers: [MailService],
})
export class MailModule {}
// src/mail/mail.service.ts
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import mailConfig from '../config/mail.config.js';
@Injectable()
export class MailService implements OnModuleInit {
private transport?: string;
constructor(
@Inject(mailConfig.KEY)
private readonly config: ConfigType<typeof mailConfig>,
) {}
// Module init order is not guaranteed, so partial config is read here
// rather than in the constructor.
onModuleInit() {
this.transport = `${this.config.host}:${this.config.port}`;
}
}
Discussion