Preview
- Strongly Typed argument(property path) for Nested Configuration Object
- Dynamic return type according to config variables
Introduction
In NestJS, ConfigModule with ConfigService helps store and load configuration variables in .env file for convenience instead of using process.env.
Using the standard constructor injection, loading the variables with ConfigService would be like:
// config/configuration.ts
export default () => ({
port: parseInt(process.env.PORT, 10) || 3000,
database: {
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT, 10) || 5432
}
});
// app.module.ts
@Module({
imports: [
ConfigModule.forRoot({
load: [configuration],
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
// app.service.ts
@Injectable()
export class AppService {
constructor(
private readonly configService: ConfigService
) {}
// somewhere in the code
something(){
// get a custom configuration value
const dbHost = this.configService.get<string>('database.host');
}
}
Problems
However, this.configService.get just like the above code may not be enough for us using typescript.
- There is no automatic “Type Hinting” for
this.configService.get. NestJS provides a useful feature of type hinting by passing the type as shown above(this.configService.get<string>), but it may be troublesome to deliver the type at every variable. -
“Type Hinting” does not guarantee the actual type of config variables. Since it is just type hinting,
this.configService.get<number>('[database.host](http://database.host)');does not show any errors even thoughdatabase.hostis a string type.
const dbHost = this.configService.get<number>('database.host'); // not showing type error const numVar: number = dbHost; -
The argument
propertyPathcan by any string value. The parameter ofthis.configService.get,propertyPathis a string type, which means that there is no checking for whether given property path is actually in the configuration object or not.
// not showing type error const dbHost = this.configService.get<string>('host'); // undefined console.log(dbHost)
Solution
So here I am documenting my approach for resolving the problems mentioned above.
Step 1.
First is to make interface and give type for the custom configuration.
// config/configuration.ts
export interface EnvironmentVariables {
port: number;
database: {
host: string;
port: number;
}
}
export default (): EnvironmentVariables => ({
port: parseInt(process.env.PORT, 10) || 3000,
database: {
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT, 10) || 5432
}
});
By doing so, we can check the config variables and their types registered in the ConfigModule.
Step 2.
// config/typed-config.service.ts
type Leaves<T> = T extends object
? {
[K in keyof T]: `${Exclude<K, symbol>}${Leaves<T[K]> extends never
? ''
: `.${Leaves<T[K]>}`}`;
}[keyof T]
: never;
type LeafTypes<T, S extends string> = S extends `${infer T1}.${infer T2}`
? T1 extends keyof T
? LeafTypes<T[T1], T2>
: never
: S extends keyof T
? T[S]
: never;
In order to receive a definite property path from the nested configuration object, only the leaf paths must be received, so the custom type called Leaves is required. This code was referenced at https://stackoverflow.com/questions/58434389/typescript-deep-keyof-of-a-nested-object .
Then the second custom type called LeafTypes is needed for automatically importing the types of leaf paths.
Step 3.
// config/typed-config.service.ts
@Injectable()
export class TypedConfigService {
constructor(private configService: ConfigService) {}
get<T extends Leaves<EnvironmentVariables>>(propertyPath: T): LeafTypes<EnvironmentVariables, T> {
return this.configService.get(propertyPath);
}
}
// config/typed-config.module.ts
// @Global() if needed
@Module({
imports: [
ConfigModule.forRoot({
load: [configuration],
}),
],
providers: [TypedConfigService],
exports: [TypedConfigService],
})
export class TypedConfigModule {}
Because the ConfigService cannot be used as it is, I created a new service TypedConfigService. The get method was created in the same way as ConfigService, and T was set as Leaves<Environment Variables> to guarantee the type of propertyPath, and return the type accordingly.
Of course, you can override all methods by implementing or extending the ConfigService.
Don't forget to import the ConfigModule to inject the ConfigService from TypedConfigService.
Step 4.
// app.module.ts
@Module({
imports: [
TypedConfigModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Finally, we need to import its containing module TypedConfigModule to the module that we want to use config variables.
Usage
Now the strongly typed code is available!
We can use the new service as we used the ConfigService before.
// app.service.ts
@Injectable()
export class AppService {
constructor(
private readonly configService: TypedConfigService // ConfigService
) {}
// somewhere in the code
something(){
// get a custom configuration value
const dbHost = this.configService.get('database.host');
}
}
Summary
The ConfigService currently provided by NestJS has something to be desired related to the type.
However, by using the custom types and the new service that once wraps the existing ConfigService, we expect to be able to utilize more convenient typescript code with strongly typed config variables.
Thank you so much for reading this post and I hope you found this useful for your great applications!
Any better code feedback and questions would be welcome! Thank You :) 🖖
You can can check out the code on GitHub.




Top comments (2)
Doesn't cover situations where you have nested objects in your config service and just want to grab that entire nested object rather than the full path to a prop in that nested object.
Thanks for your feedback!
To get whole env variables as a nested object, you can make a function that calls configuration() directly.
Or if you want to get a partial nested object as an env variable, you can use
type Pathsinstead oftype Leavesfrom the stackoverflow. You can also check it from my repo.