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.
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:
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:
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:
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
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
- we are sure that exact
process.env
variable has been parsed correctly.
Cons:
- for each groups we shall creates new guard, e.g.
numberGuard
,projectSpecificGuard1
,projectSpecificGuard2
, etc. - we need to create tests for each guard.
- supports only project needs guards.
- we are not handle objects, arrays, since
env
variables are described with primitives, in mostly cases is OK, but we are not 100% sure about it :)
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:
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:
// 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.