- Introduction
- Prerequisites
- Methods to process configuration files
- Schema validation
- Preparing our environment
- Using Joi
- Using a custom validate function
- Using a base class
- Conclusion
Introduction
Is normal and a best practice to have a .env
file to change configurations quickly based on the environment variable.
Thank God that NestJS provides a ConfigModule
that exposes a ConfigService
which loads an .env
file. Internally, this uses dotenv to load the variables from the file into process.env
.
To set up the ConfigModule
is pretty straightforward if you follow the official documentation.
You can read more about NestJS Configuration here.
Prerequisites
To following along, ensure you have basic knowledge and experience with:
- NodeJS - Is a JavaScript runtime built on Chrome's V8 JavaScript engine.
- NestJS - A progressive Node.js framework for building efficient, reliable and scalable server-side applications.
- TypeScript - Is JavaScript with syntax for types.
- Environment Variables - Is a variable whose value is set outside the program.
Methods to process configuration files
You can process your file in the root module AppModule
with the forRoot()
method. The official documentation already shows how to do validations using this way.
If you have a more complex project structure, with feature-specific configuration files, the @nestjs/config
package provides a feature called partial registration, which references only the configuration files associated with each feature module. By using the forFeature()
method within a feature module, you can load just a few environment variables to a module.
The documentation doesn't mention how to apply validations if you are using the forFeature()
method. This will be our focus in this article.
Schema validation
The @nestjs/config
package enables two different ways to do validations:
- Using Joi, a data validator for JavaScript.
- Custom validate function using
class-transformer
andclass-validator
packages, which takes environment variables as an input.
We are gonna see each one with examples.
Preparing our environment
Install the required dependency:
npm i --save @nestjs/config
The .env
file that we are gonna use is as follows:
NODE_ENV=development
PORT=3000
Let's define a configuration namespace to load multiple custom environment variables:
import { registerAs } from '@nestjs/config';
export default registerAs('my-app-config-namespace', () => ({
nodeEnv: process.env.NODE_ENV,
port: parseInt(process.env.PORT)
}));
As the docs says, inside this registerAs()
factory function, the process.env
object will contain the fully resolved environment variable key/value pairs.
Lastly, let's create a module with the following:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
// This is our factory function from the step before.
import appConfig from './configuration';
@Module({
imports: [
ConfigModule.forFeature(appConfig)
],
providers: [],
exports: [],
})
export class AppConfigModule {}
The forFeature()
method doesn't have the property validationSchema
just like the forRoot()
has. This property enables you to provide a Joi validation. It also does not have the property validate
where you can pass a custom validate function.
At this moment, I was lost and I didn't know what to do. Let's continue...
Using Joi
Install the required dependency:
npm install --save joi
Let's grab our factory function from before and apply some validations:
import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';
export default registerAs('my-app-config-namespace', () => {
// Our environment variables
const values = {
nodeEnv: process.env.NODE_ENV,
port: parseInt(process.env.PORT),
};
// Joi validations
const schema = Joi.object({
nodeEnv: Joi.string().required().valid('development', 'production'),
port: Joi.number().required(),
});
// Validates our values using the schema.
// Passing a flag to tell Joi to not stop validation on the
// first error, we want all the errors found.
const { error } = schema.validate(values, { abortEarly: false });
// If the validation is invalid, "error" is assigned a
// ValidationError object providing more information.
if (error) {
throw new Error(
`Validation failed - Is there an environment variable missing?
${error.message}`,
);
}
// If the validation is valid, then the "error" will be
// undefined and this will return successfully.
return values;
});
I hope the comments helps to understand the code.
If we delete our .env
file or if we pass invalid values, we will see in the console something like this:
Error: Validation failed - Is there an environment variable missing?
"nodeEnv" is required. "port" must be a number
Types
If you have noticed, we are not using any types. Let's create an interface in a new file:
export interface IAppConfig {
nodeEnv: string;
port: number;
}
Now we can apply it to our factory function:
import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';
import { IAppConfig } from './interface';
// Factory function now has a return type
export default registerAs('my-app-config-namespace', (): IAppConfig => {
// Object with an interface
const values: IAppConfig = {
nodeEnv: process.env.NODE_ENV,
port: parseInt(process.env.PORT),
};
// Joi uses generics that let us provide an interface in the
// first position. In the second position, we provide -true-
// to tell Joi that every key of the interface is mandatory
// to be present in the schema.
const schema = Joi.object<IAppConfig, true>({
nodeEnv: Joi.string().required().valid('development', 'production'),
port: Joi.number().required(),
});
// ...
// ..
return values;
});
For example, if we delete port
from our schema
object, we will see an error like this:
👍 Nice job!
Avoid code duplication
Imagine that we have a lot of configuration modules, each one with a namespace, I'm too lazy to duplicate all of the code from before on each file. Besides, this is a bad practice.
In addition, is very hard for me to write the same property name twice, inside our values
and schema
object from before.
const values = {
nodeEnv: ...,
port: ...
};
const schema = Joi.object({
nodeEnv: ...,
port: ...,
});
🤔 I cannot live happy with that.
Creating a new interface
What I would love to have:
- Write property names just once
- Tell what is its value from the environment variables
- Tell what are its Joi validation rules
- Keep the type feature for safety
We can come up with this technique:
Record<keyof IAppConfig, { value: unknown; joi: Schema }>
We are using the Keyof Type Operator and the type Schema
that comes from the Joi library that represents validation rules.
Example of use:
const configs: Record<keyof IAppConfig, { value: any; joi: Schema }> = {
nodeEnv: {
value: process.env.NODE_ENV,
joi: Joi.string().required().valid("development", "production"),
},
port: {
value: parseInt(process.env.PORT),
joi: Joi.number().required(),
},
};
😱 That is so cool...
But, wait a minute. We cannot pass to Joi that thing as an input!... and you are right, there is more pending work for us. 😂
We need to figure out a way to have an object with Joi's needs, and another object to return what the factory function's needs. Each object has the same properties but with different values.
/*
Result example;
[
{ propName: ... },
{ propName: ... }
]
*/
const joiSchemaArr: SchemaMap<IAppConfig>[] = Object.keys(configs).map(
(key) => {
return {
[key]: configs[key].joi, // Keep an eye on this
};
}
);
/*
Result example;
{
propName: ...,
propName: ...
}
*/
const joiSchema: SchemaMap<IAppConfig> = Object.assign({}, ...joiSchemaArr);
const schema = Joi.object(joiSchema);
Okay, now we have what Joi's needs. Only one thing is left, the factory function. Thinking on repeating this code again to extract the value
property instead of te joi
property from our interface, the laziness came on me again. 😂
Utility power
Let's create an utility file called joi-util.ts
that help us to avoid duplicating the code on every configuration file without necessity. In addition, I will delegate the responsibility to throw the error to keep my factory function as clean as possible. Also, let's use some types
and Generics too. 💪🏻
import * as Joi from 'joi';
import { Schema, SchemaMap } from 'joi';
interface ConfigProps {
value: unknown;
joi: Schema;
}
export type JoiConfig<T> = Record<keyof T, ConfigProps>;
/**
* Utility class to avoid duplicating code in the configuration of our namespaces.
*/
export default class JoiUtil {
/**
* Throws an exception if required environment variables haven't been provided
* or if they don't meet our Joi validation rules.
*/
static validate<T>(config: JoiConfig<T>): T {
const schemaObj = JoiUtil.extractByPropName(config, 'joi') as SchemaMap<T>;
const schema = Joi.object(schemaObj);
const values = JoiUtil.extractByPropName(config, 'value') as T;
const { error } = schema.validate(values, { abortEarly: false });
if (error) {
throw new Error(
`Validation failed - Is there an environment variable missing?
${error.message}`,
);
}
return values;
}
/**
* Extract only a single property from our configuration object.
* @param config Entire configuration object.
* @param propName The property name that we want to extract.
*/
static extractByPropName<T>(
config: JoiConfig<T>,
propName: keyof ConfigProps,
): T | SchemaMap<T> {
/*
Result example;
[
{ propName: ... },
{ propName: ... }
]
*/
const arr: any[] = Object.keys(config).map((key) => {
return {
[key]: config[key][propName],
};
});
/*
Result example;
{
propName: ...,
propName: ...
}
*/
return Object.assign({}, ...arr);
}
}
I'm not so good on advanced types, this can be improved.
Did you notice anything new on our validate function? Yes, a thing called as
in TypeScript. It's a Type Assertion and let us to help the compiler to know which is the type that we are expecting from our extractByPropName()
function.
I know this file is long, but no worries... you won't have to repeat it never in your life.
Example of use:
import { registerAs } from '@nestjs/config';
import { IAppConfig } from './interface';
import * as Joi from 'joi';
import JoiUtil, { JoiConfig } from '../joi-util';
export default registerAs('my-app-config-namespace', (): IAppConfig => {
const configs: JoiConfig<IAppConfig> = {
nodeEnv: {
value: process.env.NODE_ENV,
joi: Joi.string().required().valid('development', 'production'),
},
port: {
value: parseInt(process.env.PORT),
joi: Joi.number().required(),
},
};
return JoiUtil.validate(configs);
});
😈 That is what I'm talking about, awesome!
Usage with multiple configuration modules
We now have a new business requirement and we need to communicate to a database. Let's create another configuration module with specific environment variables.
First, define the environment variables:
DATABASE_USERNAME=root
DATABASE_PASSWORD=123456789
DATABASE_NAME=mydb
DATABASE_PORT=3306
The configuration namespace to load multiple custom environment variables:
import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';
import JoiUtil, { JoiConfig } from '../joi-util';
interface IDatabaseConfig {
username: string;
password: string;
database: string;
port: number;
}
export default registerAs('database-config-namespace', (): IDatabaseConfig => {
const configs: JoiConfig<IDatabaseConfig> = {
username: {
value: process.env.DATABASE_USERNAME,
joi: Joi.string().required(),
},
password: {
value: process.env.DATABASE_PASSWORD,
joi: Joi.string().required(),
},
database: {
value: process.env.DATABASE_NAME,
joi: Joi.string().required(),
},
port: {
value: parseInt(process.env.DATABASE_PORT),
joi: Joi.number().required(),
},
};
return JoiUtil.validate(configs);
});
I've just add a few variables, in real life you will have more.
Lastly, let's create a module with the following:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
// This is our factory function from the step before.
import databaseConfig from './database-configuration';
@Module({
imports: [
ConfigModule.forFeature(databaseConfig)
],
providers: [],
exports: [],
})
export class DatabaseConfigModule {}
You will repeat these steps on every configuration module and that's it. 🙂
Using a custom validate function
To use this way, we need to install class-transformer
and class-validator
packages, which takes environment variables as an input.
npm i --save class-transformer class-validator
The documentation shows an example about this, but it's intended to be used with the forRoot()
method. Let's see how can we use this way by using the forFeature()
method.
Custom validator per factory function
Let's define a configuration namespace to load multiple custom environment variables:
import { registerAs } from '@nestjs/config';
import { IAppConfig } from './interface';
export default registerAs('my-app-config-namespace', (): IAppConfig => ({
nodeEnv: process.env.NODE_ENV,
port: parseInt(process.env.PORT),
}),
);
Now, we can take the same example from the documentation and adjust it to our requirements. Let's create a new file called app-env.validation.ts
with the following:
import { plainToClass } from 'class-transformer';
import { IsEnum, IsNumber, validateSync } from 'class-validator';
enum Environment {
Development = 'development',
Production = 'production',
}
class AppEnvironmentVariables {
@IsEnum(Environment)
NODE_ENV: Environment;
@IsNumber()
PORT: number;
}
export function validate(config: Record<string, unknown>) {
const validatedConfig = plainToClass(
AppEnvironmentVariables,
config,
{ enableImplicitConversion: true },
);
const errors = validateSync(validatedConfig, { skipMissingProperties: false });
if (errors.length > 0) {
throw new Error(errors.toString());
}
return validatedConfig;
}
To apply the validate function, is like follows:
import { registerAs } from '@nestjs/config';
import { IAppConfig } from './interface';
// This is our custom validate function from the step before.
import { validate } from './app-env.validation';
export default registerAs('my-app-config-namespace', (): IAppConfig => {
// Executes our custom function
validate(process.env);
// If all is valid, this will return successfully
return {
nodeEnv: process.env.NODE_ENV,
port: parseInt(process.env.PORT),
};
});
If we delete our NODE_ENV
and PORT
variables from the .env
file, we will see:
Error:
An instance of AppEnvironmentVariables has failed the validation:
- property NODE_ENV has failed the following constraints: isEnum
An instance of AppEnvironmentVariables has failed the validation:
- property PORT has failed the following constraints: isNumber
You need to do custom validate functions for every factory function with a namespace.
🤔 Mmm... this smells like a code duplication of the custom validate function! Well, this time is natural because each one will have different rules.
Looking at the file app-env.validation.ts
we have created, we can see a repetitive part that we can reuse across the project, the validate()
function.
export function validate(config: Record<string, unknown>) {
...
}
Extract the validate function
Let's create a new file called validate-util.ts
:
import { plainToClass } from 'class-transformer';
import { validateSync } from 'class-validator';
import { ClassConstructor } from 'class-transformer/types/interfaces';
export function validateUtil(
config: Record<string, unknown>,
envVariablesClass: ClassConstructor<any>
) {
const validatedConfig = plainToClass(
envVariablesClass,
config,
{ enableImplicitConversion: true },
);
const errors = validateSync(validatedConfig, { skipMissingProperties: false });
if (errors.length > 0) {
throw new Error(errors.toString());
}
return validatedConfig;
}
Our old app-env.validation.ts
will look like:
import { IsEnum, IsNumber } from 'class-validator';
enum Environment {
Development = 'development',
Production = 'production',
}
export class AppEnvironmentVariables {
@IsEnum(Environment)
NODE_ENV: Environment;
@IsNumber()
PORT: number;
}
Lastly, our factory function will look like:
import { registerAs } from '@nestjs/config';
import { IAppConfig } from './interface';
// This is our class that uses "class-validator" decorators
import { AppEnvironmentVariables } from './app-env.validation';
// Our new utility to apply the validation process
import { validateUtil } from '../validate-util';
export default registerAs('my-app-config-namespace', (): IAppConfig => {
// Executes our custom function
validateUtil(process.env, AppEnvironmentVariables);
// If all is valid, this will return successfully
return {
nodeEnv: process.env.NODE_ENV,
port: parseInt(process.env.PORT),
};
});
The core of the process for validation is extracted and does not need to be repeated anymore. Also, our AppEnvironmentVariables
is cleaner and easy to understand and maintain. 😀
Using a base class
Another way to apply validations is by using a base class
. All the credit goes to Darragh ORiordan and his article called How to validate configuration per module in NestJs. I encourage you to check it out!.
Conclusion
I've tried to recap in a single place all the ways you can do validations when using the forFeature()
method in NestJs.
I hope you liked my article and see you soon with more advices like this.
Top comments (6)
Nice article! I am just wondering how can we now getting a value type safe? For example
configService.get<string>('mongodb.uri')
doesnt knowmongodb
andmongodb.uri
exists (no autocompletion in the IDE) and it doesnt know it is a string (need to manually add the type)very interesting read - is there a github repo available with the code ?
Also just confirming that joi seems to be removed when you use the class validator. Is that correct ? :) (before I go and remove the joi code ... )
Greate content! Thank you very much, could you please share the repo?
Great content!
Great thanks for a great content!
A github repo with the final code would make it even more useful
ty <3