~ 7 min read

Configuration Decoded: Lesser-Known Tips for Working with env-schema in Node.js

share this story on
Level up your Node.js apps with env-schema! Manage configurations effortlessly and learn useful practices for building for configuration management.

As Node.js developers, we often find ourselves dealing with various configuration options to tailor our applications for different environments. Configuration management is a critical aspect of building backend servers, allowing us to control the behavior of our applications without modifying the codebase, such as defining database configuration and other parameters.

In this blog post, we will explore the env-schema npm package, an open-source library, part of the Fastify maintainers team, that simplifies configuration management in Node.js apps.

Whether you are a seasoned developer or just starting your Node.js journey, understanding how to manage configurations efficiently is essential for building scalable and maintainable applications.

Introducing env-schema

The env-schema npm package offers a straightforward yet effective solution for handling configuration data in Node.js applications. It enables us to define a schema for our environment variables, ensuring that the required variables are present and have the correct data types. By validating and loading the configuration data, env-schema helps us avoid runtime errors and ensures our application starts with a valid configuration.

To get started, we need to install the package via npm:

npm install env-schema

Now, let’s dive into a code example that demonstrates how to use env-schema in a Node.js application with a schema defining essential configuration variables.

// config.js
const envSchema = require('env-schema');

const schema = {
  type: 'object',
  required: ['PORT', 'DB_USER', 'DB_PASS', 'DB_HOST', 'DB_PORT', 'DB_NAME'],
  properties: {
    PORT: {
      type: 'integer',
      default: 3000,
    },
    DB_USER: {
      type: 'string',
    },
    DB_PASS: {
      type: 'string',
    },
    DB_HOST: {
      type: 'string',
      default: 'localhost',
    },
    DB_PORT: {
      type: 'integer',
      default: 5432,
    },
    DB_NAME: {
      type: 'string',
    },
  },
};

const config = envSchema({ schema });

console.log('Configuration:', config);

In this example, we define a schema object containing the required environment variables (PORT, DB_USER, DB_PASS, DB_HOST, DB_PORT, and DB_NAME). Additionally, we provide default values for some variables to ensure our application works even if they are not explicitly set in the environment.

WARNING: in a production environment you shouldn’t log the configuration due to privacy and security concerns. The use of console.log in the above code snippet is just an example for demonstration purposes.

When you run the application, env-schema will validate the environment variables based on the schema. If any required variables are missing or have incorrect types, it will throw an error with meaningful messages indicating which variables failed validation.

env-schema Nuggets: unlocking hidden configuration features in Node.js

Now that we have a basic understanding of how to use env-schema, let’s explore other useful practices for managing configuration in Node.js apps.

Data loading precedence in env-schema

One essential aspect of configuration management is determining the order of precedence for loading data from different sources. With env-schema, the order of precedence is as follows, from least significant to most:

  1. Data sourced from a .env file (when dotenv configuration option is set).
  2. Data sourced from environment variables in process.env.
  3. Data provided via the data configuration option.

As such, any configuration parameters provided via the data option will always take precedence and override any other values from either the .env file and process’s environment variables.

By understanding this precedence, we can ensure that our application’s configurations are correctly loaded.

Supporting dual-parameters for the same configuration

In some scenarios, you may encounter applications that offer multiple ways to configure the same functionality. For example, instead of specifying individual database configuration variables (DB_USER, DB_PASS, DB_HOST, etc.), you might have a single variable called DATABASE_URL that contains all the necessary information. This approach is common, especially in platforms like Heroku.

To support both methods of configuration, we can use the anyOf schema declaration as part of our required fields. Let’s modify our previous schema to accommodate the DATABASE_URL option:

const schema = {
  type: 'object',
  required: ['PORT', 'HOST'],
  anyOf: [
    {
      required: ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'],
    },
    {
      required: ['DATABASE_URL'],
    },
  ],
};

With this updated schema, either specifying individual database variables or providing a DATABASE_URL will be considered valid, making your application more flexible in different environments.

Custom validators in env-schema

When working with configuration management in Node.js using env-schema, most built-in types would work great out of the box. However, there are instances when your application requires more specialized validation for specific configuration variables. This is where custom validators come to the rescue.

Custom validators allow you to define your own validation logic, tailoring it precisely to your application’s needs. Let’s dive into how you can create and use custom validators with env-schema.

Creating a custom validator

To create a custom validator, you need to provide a validation function that accepts a value and returns true if the value is valid or an error message as a string if it fails validation. The error message will be displayed when the configuration variable fails the validation check.

We are going to use Ajv custom validators for this and pass our own Ajv instance to env-schema. Create a config.js file with the following contents:

const { envSchema, str } = require("env-schema");
const Ajv = require("ajv");

function validateApiKey(schema, data) {
  if (!data || typeof data !== "string" || data.length !== 32) {
    validateApiKey.errors = [
      {
        keyword: "apiKeyValidation",
        message: "API key must be a non-empty string of length 32.",
      },
    ];
    return false;
  }
  return true;
}

// Create a new instance of Ajv
const ajv = new Ajv({ allErrors: true, removeAdditional: true });

// Add the custom validation keyword
ajv.addKeyword({
  keyword: "apiKeyValidation",
  validate: validateApiKey,
});

// Schema definition with custom validator
const schema = {
  type: "object",
  properties: {
    API_KEY: {
      default: "",
      apiKeyValidation: true, // Use the custom validation keyword
    },
  },
};

// Applying the schema to environment variables
const config = envSchema({ schema, dotenv: true, ajv });
console.log(config);

In this example, the validateApiKey function checks if the API key is a non-empty string and has a length of exactly 32 characters. If the validation fails, it throws a ValidationError with a descriptive error message. Otherwise, it returns true, indicating that the API key is valid.

Next, create a .env file in the same directory with an API_KEY definition:

API_KEY=1234

And finally, run the config code to see the validation in action:

node config.js

An error should be thrown similar to the following, indicating that the API key is invalid:

/projects/env-schema/node_modules/env-schema/index.js:86
    throw error
    ^

  Error: env/API_KEY API key must be a non-empty string of length 32.

  errors: [
    {
      keyword: 'apiKeyValidation',
      message: 'API key must be a non-empty string of length 32.',
      instancePath: '/API_KEY',
      schemaPath: '#/properties/API_KEY/apiKeyValidation'
    }
  ]

By incorporating the custom validator into your schema, env-schema will automatically apply the validation function to the API_KEY configuration variable during the validation process.

Schema documentation with env-schema

There are times when configuration schemas can become complex, making it challenging for team members to understand the purpose and expected values of each configuration variable.

To address this issue, the schema configuration, supported by Ajv, provides two handy properties for adding documentation to your configuration schema: examples and description.

  • The examples property allows you to provide usage examples for each configuration variable in your schema. These examples showcase the format and expected values of the configuration, making it easier for developers to understand how to set up the environment variables correctly.

  • The description property enables you to include a brief description of the configuration variable’s purpose or usage. It acts as a quick summary that provides context and clarity to developers regarding the significance of each configuration setting.

We can build upon the above example to include these documentation properties for the API_KEY configuration variable:

// Schema definition with custom validator
const schema = {
  type: "object",
  properties: {
    API_KEY: {
      default: "",
      apiKeyValidation: true, // Use the custom validation keyword
      description: 'API key for accessing external services',
      examples: ['a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6'],
    },
  },
};

Conclusion

The env-schema npm package offers a fantastic solution for managing configuration in Node.js applications. By defining a schema for your environment variables, you can ensure that the correct configurations are in place for your app to run smoothly. Its validation and data loading precedence features give you confidence that your application starts with a valid and consistent configuration.

Happy coding and may your Node.js apps thrive with the power of env-schema!