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.host
is a string type.const dbHost = this.configService.get<number>('database.host'); // not showing type error const numVar: number = dbHost;
-
The argument
propertyPath
can by any string value. The parameter ofthis.configService.get
,propertyPath
is 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/configuration.ts
export type Leaves<T> = T extends object
? {
[K in keyof T]: `${Exclude<K, symbol>}${Leaves<T[K]> extends never
? ''
: `.${Leaves<T[K]>}`}`;
}[keyof T]
: never;
export 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 :) 🖖
Top comments (1)
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.