3 minute read

The baseline project

This post assumes you are following along with the baseline project tutorial, but the concepts apply to any Typescript project.

Improving our configuration loading

Currently, we are storing the configuration for our application externally, in environmental variables. This is beneficial, because it allows us to change our app configuration without changing any of the code in our codebase.

However, we want to ensure that all of the environmental variables needed for our app to run are set when our app starts. Otherwise, our app might start initially, then crash due to an error when a variable is unexpectedly undefined.

It would also be nice to export a single config object that we can use throughout our application, instead of having to reference process.env.VARIABLE_NAME any time we use an environmental variable.

Setting up validation

Joi is a validation library that allows us to describe a schema for an object, validate it, and provide descriptive errors if validation fails. We will also use this library later in the project to validate user input, so let’s go ahead and install it:

$ yarn add joi

Let’s create a src/config.ts file that will handle loading any .env files and validating the process environment:

$ touch src/config.ts

Currently, our app relies on two environmental variables:

  • NODE_ENV: Is a string which must be either development, test, or production, and should default to development.
  • PORT: Is a number which which must be a valid port, and should default to 3000.

Let’s create a Joi schema that describes this data model:

// src/config.ts

import Joi from "joi";

import { loadConfig } from "./util/load-config";

const schema = Joi.object()
  .keys({
    NODE_ENV: Joi.string()
      .valid("development", "test", "production")
      .default("development"),
    PORT: Joi.number().port().default(3000),
  })
  .unknown();

Note the use of the unknown() method, which tells Joi that we are only validating the two keys described in the schema, and that it should ignore any keys. This is needed because we will be passing the entire process.env object to Joi for validation, and that object contains many environmental variables that are of no concern to our application.

Next, let’s write a utility function in src/util/load-config.ts that will load any .env files into the environment, and perform the actual validation:

$ touch src/util/load-config.ts
// src/util/load-config.ts

import dotenv from "dotenv";
import type { Schema } from "joi";

import { handle } from "./error";

export const loadConfig = (schema: Schema) => {
  dotenv.config({ path: __dirname + "/../../.env" });

  const { value, error } = schema.validate(process.env);
  if (error) {
    handle(new Error(`Invalid environment: ${error.message}`));
  }
  return value;
};

In the loadConfig function, we use dotenv to load any .env file variables into the environment, then validate process.env against the schema we passed to the function. If there is an error, we pass it to our error handler and crash the app. Otherwise, we return the validated result.

Back in src/config.ts, let’s use the loadConfig function to validate the environment and export a typed config object:

import { loadConfig } from "./util/load-config";

// existing code ...

const env = loadConfig(schema);

export const config = {
  env: env.NODE_ENV as "development" | "test" | "production",
  port: env.PORT as number,
};

We can safely type cast the config properties since they have been validated by Joi at this point.

Let’s remove the dotenv call in our src/index.ts file (since loading any .env files is handled in our src/config.ts file), and replace any references to process.env with the properties from our config object:

// src/index.ts

import { createHttpTerminator } from "http-terminator";

import { app } from "./app";
import { config } from "./config";
import { handle } from "./util/error";
import { logger } from "./util/logger";

process.on("unhandledRejection", (err) => {
  throw err;
});

process.on("uncaughtException", (err) => {
  handle(err);
});

const server = app.listen(config.port, () => {
  logger.info(`started server on :${config.port} in ${config.env} mode`);
});

const httpTerminator = createHttpTerminator({ server });

const shutdownSignals = ["SIGTERM", "SIGINT"];

shutdownSignals.forEach((signal) =>
  process.on(signal, async () => {
    logger.info(`${signal} received, closing gracefully ...`);
    await httpTerminator.terminate();
  })
);

Commit

Go ahead and stage your changes:

$ git add .

And commit them to source control:

$ git commit