Skip to main content

Using dotenv-guards library for environment variables management

Image of the author

Vitali Haradkouvitalics

.env guard

Context

My previous project was very huge and complex, we’re working on automotive industry and our mission - make users happy with minimal involving. The user should fullfil financial info and upload driver license - and then he can purchase any car, matched by his financial capabilities. As I mentioned before - this is huge project with a lot of involving teams. On this project my role - lead and drive core changes of automation framework.

While I was on this project automation team facing with a problem, which is that we have a lot of environment variables, which should be parsed by rules, e.g. jobsCount, browser etc. . We need to manage them in a way, which is easy to use and easy to change.

Let’s see how it was before the library usage.

Before

We use dotenv to parse and load environment variables to process.env object.

How it was before refactoring.

file.ts
process.env.SOME_VARIABLE === "first" ? true : false;

Implementation

We define all process.env variables and creates utility with name env and writes function to load and register

How it was:

Project Structure
  • Directorysrc
    • Directoryutils
      • index.ts - exports functions from “env” module
      • Directoryenv
        • register.ts - this file is responsible for loading and parse env variables
        • index.ts - exported variables are used in “register.ts”
  • package.json - dependencies

Let’s deep dive into index.ts file:

src/utils/env/index.ts
export let RETRY_COUNT = process.env.RETRY_COUNT;
export let BROWSER = 'chrome';
...
// other variables

But this variables are predefined and if engineer set them with wrong values - we get an unexpected error or even behavior.

Example:

src/utils/env/register.ts
import { RETRY_COUNT } from "./index.ts";
export default function register() {
dotenv.load(); // loads .env file
RETRY_COUNT = +process.env.RETRY_COUNT;
}

The problem here with parsing is that we have a lot of variables with various requirements, e.g. numeric value, should be Finite and valid number, string always should be a subset of enum, etc.

The next step is creating utility function to transform by predefined rules.

Example:

src/utils/env/guards/number.ts
type Options = {
// project specific options for all numerics types
};
export default function numberGuard(
variable: string | undefined,
options: Options
) {
const numberLike = Number(variable);
let result: number;
// do specifics transformations and set result variable
return result;
}

Usage in register file

src/utils/env/register.ts
import { RETRY_COUNT } from "./index.ts";
import numberGuard from "./guards/number";
export default function register() {
dotenv.load(); // loads .env file
RETRY_COUNT = numberGuard(process.env.RETRY_COUNT);
}

Pros

Cons:

After

We made a decision to write own dotenv guards and make it open source.

We was focusing on routine transformations. For numbers it is: undefined, finite and safe

getting started: npm install dotenv-guards

using in our project:

utils/env/register.ts
import { config } from "dotenv";
import { numberGuard } from "dotenv-guards";
export default function register() {
// load env variable
load();
// required variable, throws an error if `process.env.RETRY_COUNT` is not defined
RETRY_COUNT = numberGuard(process.env.RETRY_COUNT, {
throwOnFinite: true,
throwOnUndefined: true,
fallback: 0,
});
// JOB_COUNT variable will be always defined, since fallback value is provided
JOB_COUNT = numberGuard(process.env.JON_COUNT, { fallback: 0 });
}

Why dotenv-guards useful?

Well, our API provides fallback value in case of errors, it makes more flexible.

Also, From 2 version - I’ll provides define and revoke functions, so if you feel like primitives are not enough - you may define own guard.

Example:

import { define, revoke } from "dotenv-guards";
const jsonGuard = define((envVariable: string | undefined) => {
// checks that variable is exists and not undefined
const parsed = JSON.parse(envVariable);
return parsed;
});
// using guard
const res = jsonGuard('{"qwe": true}'); // returns {"qwe": true}
// or when jsonGuard is no need anymore - use revoke function, it will allocates memory, since it uses proxy.revoke under the hood.
revoke(jsonGuard);
// it you want to call after revoked - you will get TypeError.
jsonGuard('{"qwe": true}'); // TypeError. since it was revoked

The reason why define function is exists - is making sure that first argument is env-like(string | undefined).

It also written on Typescript and all inputs/outputs are strongly typed.

First argument should be string|undefined. This is essential, since environment-like variables are strings.

You can also define generic function.

Example:

import { define } from "dotenv-guard";
const customGuard = define(
<T>(envLike: string | undefined, additionalData: T) => {
return additionalData;
}
);
customGuard("123", []); // type is array

Conclusion

As for me - the best way to implementing env module is creating an object and JSON schema definitions. Since JSON schema has standards and more flexible.

For example, I’ll take [class-validator](https://github.com/typestack/class-validator) and [class-transformer](https://github.com/typestack/class-transformer) packages. And I’ll also pickup dotenv-guards to transform properties safety.

It will look like:

src/utils/env.ts
// example takes from
// <https://github.com/typestack/class-validator#usage>
import {
validate,
validateOrReject,
IsInt,
Length,
IsEmail,
IsFQDN,
IsDate,
Min,
Max,
} from "class-validator";
import { Expose, plainToClass, Transform } from "class-transformer";
import { numberGuard } from "dotenv-guards";
class Environment {
@Expose() // mark property as necessary. process.env.jobCount will be transformed on Environment.jobCount
@Transform((v) => numberGuard(v.value)) // using transformation
@IsInt() // validateOrReject, check filed on integer
@Min(0) // validateOrReject, check filed on minimum value
@Max(10) // validateOrReject, check filed on maximum value
jobCount: number;
}
export let environment: Environment;
async function parse() {
load(); // loads .env
environment = plainToClass(
Environment, // use this class as base
process.env, // use all .env variables
{ excludeExtraneousValues: true } // ignore undefined properties, like PATH, HOME, etc.
);
try {
await validateOrReject(environment); // validate async
} catch (e) {
console.log(
"Caught promise rejection (validation failed). Errors: ",
errors
);
}
}
await parse(); // no errors, transformation and validation got successfully

At this article you see how to use dotenv-guards library and how it solves our issues in project with dotenv usage and parsing.

I hope you enjoy this article, share it with your friends and colleagues.

See you again, Cheers đź‘‹

All my Links: