Nx NestJs - Easy way to get, autofill and validate environment variables
Tags: nestjs, nx, javascript, webdev
Published: 2022-05-09 01:13:21

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
```typescript
/** 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;
```
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 
```typescript
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;
}
```

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:
```ts
// 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,
};
```

The other single thing remaining to do is registering the `ConfigModule`, so open `app.module.ts` and add 
```ts
 ConfigModule.forRoot({
      load: [getEnvConfig],
      isGlobal: true,
      cache: true,
    }),
```

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

## Validation
One way is to you [Joi](https://docs.nestjs.com/techniques/configuration#schema-validation), 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`.
```ts
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;
}
```
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 
```ts
ConfigModule.forRoot({
      load: [getEnvConfig],
      isGlobal: true,
      cache: true,
    }),
```
to 
```ts
  ConfigModule.forRoot({
      load: [getEnvConfig],
      isGlobal: true,
      cache: true,
      validate: envValidation,
    })
```

# 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
```ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api');
  await app.listen(3000);
}
```
we can get
```ts
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);
}
```

Thanks for reading!