Nx NestJs - Easy way to get, autofill and validate environment variables

ipreda

Iulian Preda

Posted on May 9, 2022

Nx NestJs - Easy way to get, autofill and validate environment variables

Greetings, today we will discuss about environment variables in NestJs, specifically using Nx.

As you might know, Nx autogenerates a lot of boilerplate code for us. It does the same with the environment files.
After you create a new project you can see that you have 2 files environment and environment.prod.ts that are waiting for you.
These files are basically the same, the .prod file will replace the other one on build if the production flag is set.
All this magic can be seen inside project.json

Now, if we would be able to simply set everything in there, this article would be pointless. Usually, we want to have the secrets of the application… secret. For the local dev environment, it might be fine to save in the repo your config, but for production, it is definitely not.

So, to simplify a lot of things I propose a mixed approach. For local, we set everything is environment.ts and for production(and others) we set only nonsecret things in environment.prod.ts and the rest as environment variables.

Let's get to work!

Configuration

First, we will need the ConfigModule available in the app so proceed to run npm install --save @nestjs/config class-validator class-transformer

Then in the environment folder (or a different one) you can create env-config.ts which will contain

/** Environment variables take precedence */
export function getEnvConfig() {
  const envVariables = parseEnvVariables();
  return mergeObject(env, envVariables);
}

function parseEnvVariables() {
  const envVariables: DeepPartial<IEnvironment> = {};
  const env = process.env;
  if (env) {
    if (env.PRODUCTION) envVariables.production = env.PRODUCTION === 'true';
    if (env.PORT) envVariables.port = parseInt(env.PORT, 10);
    if (env.BASE_URL) envVariables.baseUrl = env.BASE_URL;
    if (env.GLOBAL_API_PREFIX) envVariables.globalApiPrefix = env.GLOBAL_API_PREFIX;

    envVariables.database = {};
    if (env.DATABASE_HOST) envVariables.database.host = env.DATABASE_HOST;
    if (env.DATABASE_PORT) envVariables.database.port = parseInt(env.DATABASE_PORT, 10);
    if (env.DATABASE_DATABASE) envVariables.database.database = env.DATABASE_DATABASE;
    if (env.DATABASE_USERNAME) envVariables.database.username = env.DATABASE_USERNAME;
    if (env.DATABASE_PASSWORD) envVariables.database.password = env.DATABASE_PASSWORD;
  }
  return envVariables;
}


function mergeObject(obj1: Record<string, any>, obj2: Record<string, any>) {
  for (const key in obj2) {
    if (obj2.hasOwnProperty(key)) {
      if (typeof obj2[key] === 'object') {
        mergeObject(obj1[key], obj2[key]);
      } else {
        obj1[key] = obj2[key];
      }
    }
  }
  return obj1;
}

type DeepPartial<T> = T extends object
  ? {
      [P in keyof T]?: DeepPartial<T[P]>;
    }
  : T;
Enter fullscreen mode Exit fullscreen mode

Let's explain, the getEnvConfig function will be called by the ConfigModule at start-up and will retrieve both the environment variables and the environment object from environment.ts. Then we will merge all the properties together and return the object. The environment variables have priority in the merge.

You might have noticed some interfaces too. Better typing is always good to have so we need to create env.interface.ts in the same folder containing

export interface IEnvironment {
  production: boolean;
  port: number;
  baseUrl: string;
  globalApiPrefix: string;
  database: IDatabaseEnvironment;
}

export interface IDatabaseEnvironment {
  host: string;
  port: number;
  database: string;
  username: string;
  password: string;
}
Enter fullscreen mode Exit fullscreen mode

At this point we are almost done.
All we need to do more is to add the interface above in the environment.ts and environment.prod.ts
My files look like this:

// environment.ts
import { IEnvironment } from './env.interface';

export const env: Partial<IEnvironment> = {
  production: false,
  globalApiPrefix: 'api',
  port: 3333,
  baseUrl: 'http://localhost',
  database: {
    host: 'localhost',
    port: 5432,
    database: 'postgres',
    username: 'postgres',
    password: 'password',
  },
};


// environment.prod.ts
export const env: Partial<IEnvironment> = {
  production: true,
};
Enter fullscreen mode Exit fullscreen mode

The other single thing remaining to do is registering the ConfigModule, so open app.module.ts and add

 ConfigModule.forRoot({
      load: [getEnvConfig],
      isGlobal: true,
      cache: true,
    }),
Enter fullscreen mode Exit fullscreen mode

This should get you going. Let's talk now about validation.

Validation

One way is to you Joi, but it is not recommended for Node users above v17.
Personally I used a custom validation.
Create in the same environment folder another file env-validator.

import { plainToClass } from 'class-transformer';
import { IsBoolean, IsNumber, IsObject, IsString, validateSync } from 'class-validator';

import { getEnvConfig } from './env-config';
import { IDatabaseEnvironment, IEnvironment } from './env.interface';

class DatabaseEnvironment implements IDatabaseEnvironment {
  @IsString()
  host: string;
  @IsNumber()
  port: number;
  @IsString()
  database: string;
  @IsString()
  username: string;
  @IsString()
  password: string;
}
class Environment implements IEnvironment {
  @IsBoolean()
  production: boolean;

  @IsNumber()
  port: number;

  @IsString()
  baseUrl: string;

  @IsObject()
  database: DatabaseEnvironment;

  @IsString()
  globalApiPrefix: string;
}

export function envValidation() {
const config = getEnvConfig();
  const validatedConfig = plainToClass(Environment, 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

As a short explanation, we get the environment variables using the function created at the beginning, then using a transformer function we create the objects above which contains the validators needed. Then we validate each property. If the validation fails the app will not run.

Now we only need to trigger the validation.
Let's go to app.module.ts and change

ConfigModule.forRoot({
      load: [getEnvConfig],
      isGlobal: true,
      cache: true,
    }),
Enter fullscreen mode Exit fullscreen mode

to

  ConfigModule.forRoot({
      load: [getEnvConfig],
      isGlobal: true,
      cache: true,
      validate: envValidation,
    })
Enter fullscreen mode Exit fullscreen mode

Usages

This configuration has many usages. For example you can dynamically change the database based on the environment, or the port.

Let's check one of the usages. Particularly the port.
We can update main.ts to use the ConfigService and get the env variable.
So from something like

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api');
  await app.listen(3000);
}
Enter fullscreen mode Exit fullscreen mode

we can get

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const config: ConfigService = app.get(ConfigService);
  const globalApiPrefix = config.get<IEnvironment['globalApiPrefix']>('globalPrefix') ?? 'api';

  app.setGlobalPrefix(globalApiPrefix);

  const port = config.get<IEnvironment['port']>('port') ?? 3333;
  await app.listen(port);
}
Enter fullscreen mode Exit fullscreen mode

Thanks for reading!

đź’– đź’Ş đź™… đźš©
ipreda
Iulian Preda

Posted on May 9, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related