DEV Community

Cover image for Ways to validate environment configuration in a forFeature Config in NestJs
Robert Gomez
Robert Gomez

Posted on

Ways to validate environment configuration in a forFeature Config in NestJs

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:

  1. Using Joi, a data validator for JavaScript.
  2. Custom validate function using class-transformer and class-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
Enter fullscreen mode Exit fullscreen mode

The .env file that we are gonna use is as follows:

NODE_ENV=development
PORT=3000
Enter fullscreen mode Exit fullscreen mode

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)
}));
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

For example, if we delete port from our schema object, we will see an error like this:

type error screenshot

👍 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: ...,
});
Enter fullscreen mode Exit fullscreen mode

🤔 I cannot live happy with that.

Creating a new interface

What I would love to have:

  1. Write property names just once
  2. Tell what is its value from the environment variables
  3. Tell what are its Joi validation rules
  4. Keep the type feature for safety

We can come up with this technique:

Record<keyof IAppConfig, { value: unknown; joi: Schema }>
Enter fullscreen mode Exit fullscreen mode

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(),
  },
};
Enter fullscreen mode Exit fullscreen mode

😱 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);
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

😈 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
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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),
  }),
);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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),
  };
});
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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>) {
  ...
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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),
  };

});
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
mickl profile image
Mick

Nice article! I am just wondering how can we now getting a value type safe? For example configService.get<string>('mongodb.uri') doesnt know mongodb and mongodb.uri exists (no autocompletion in the IDE) and it doesnt know it is a string (need to manually add the type)

Collapse
 
jmls profile image
jmls

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 ... )

Collapse
 
_joyce_0adefd3cc5954b6157 profile image
Joyce

Greate content! Thank you very much, could you please share the repo?

Collapse
 
cuongngman profile image
CuongNgMan

Great content!

Collapse
 
sokol8 profile image
Kostiantyn Sokolinskyi

Great thanks for a great content!
A github repo with the final code would make it even more useful

Collapse
 
cake_batter profile image
Lucas Moskun

ty <3