Skip to content

cytim/nodejs-typed-dotenv

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

55 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Continuous Integration

typed-dotenv

✨ Inspired by dotenv and dotenv-extended.

The Twelve-factor App suggests to store the config in the environment. This is an excellent idea, BUT there is ONE big shortcoming.

πŸ€·β€β™‚οΈ Environment variables must be string. Not every config is string however.

To read a typed environment variable (e.g. number or boolean), you need to convert the value to the corresponding type in your code manually. This greatly impacts the code readability and is error-prone.

🚫 UNDESIRED

const { parsed: env } = require('dotenv').config();

const timeout = parseInt(env.TIMEOUT) || 10;
if (timeout > 0) {
  // do something...
}

βœ… DESIRED: The variables are in the correct data types, and possibly assigned with the default value at the first place

const { env } = require('typed-dotenv').config();

if (env.timeout > 0) {
  // do something...
}

Table of Contents

What does typed-dotenv do?

typed-dotenv reads a template file, then manipulate the environment variables according to the template.

In the template, you can...

  1. specify the data type(s) for each variable. typed-dotenv will convert the environment variables into the target type(s) accordingly.

  2. specify whether a variable is required or optional. typed-dotenv will throw an error if a required variable is missing.

  3. specify the default values for the optional variables. typed-dotenv will apply the default values if the optional variables are missing.

Check out the .env.template file for more details.

πŸ‘‰πŸΌ Moreover, you can configure typed-dotenv to rename and nest the environment variables.

// Default output
{
  "MYSQL__DATABASE": "my_db",
  "MYSQL__POOL_SIZE": 5,
  // ...
}

// Renamed and nested output
{
  "mysql": {
    "database": "my_db",
    "poolSize": 5,
    // ...
  }
}

Installation

npm i --save typed-dotenv

# OR
yarn add typed-dotenv

Usage

  1. Prepare the .env.template at the project root.

    # EXAMPLE
    
    ##
    # @required {string}
    SECRET=
    
    ##
    # @optional {number} = 30
    TIMEOUT=
    
    ##
    # @optional {boolean} = false
    DEBUG=
    
    ##
    # @optional {json} = {"status":404}
    NOT_FOUND=
    
    ##
    # @optional {string[]} = foo,bar
    LIST=
  2. Copy the .env.template as .env, and fill in at least the required variables.

    SECRET=ThisIsTopSecret
  3. Import and configure typed-dotenv as early as possible in your application.

    const { error, env } = require('typed-dotenv').config();
    
    if (error) {
      // Handle the error
    }
    
    // Now you can refer to the `env` object for the environment variables.

    ⚠️ If you want to use the import syntax (i.e. ES Module), see the FAQ below.

IMPORTANT NOTES

  1. process.env will be modified to include the loaded variables, BUT the assigned values are the RAW STRING values, because process.env accepts string only.

  2. If a variable has already been set in process.env, it WILL be overwritten UNLESS includeProcessEnv is set to false.

The .env.template File

The .env.template file shares the same syntax as the .env. The only difference is that each variable has a special comment block - the annotation block.

An annotation block starts with the ## line, and follows by the lines that start with #.

##
# This is an annotation block.
# You can have some description for your variable.
# @optional {boolean} isDebug = false
DEBUG=

Every annotation starts with the @ symbol, follows by a keyword. The remaining part is specific for each annotation.

Currently, only 2 annotations are supported by this library - @required and @optional.

@required

The parser will throw an error if the required variable is missing in .env.

Format: @required {TYPES} CUSTOM_NAME

# EXAMPLES
# @required {string}
# @required {number|string}
# @required {number|string} customName

TYPES

The list of types separated by |. If multiple types are specified, the parser will follow the same sequence to convert the variable value. If the value fails to convert into any of the defined types, an error will be thrown.

The following types are supported:

string, string[], number, number[], boolean, boolean[], json

CUSTOM_NAME (optional)

If renaming is enabled, the parser will respect the custom name to rename the variable.

For example, MYSQL__POOL_SIZE will rename to mysql.poolSize by default. You can force it to rename as dbPoolSize by providing the custom name.

@optional

The parser will assign the default value (if given) or null to the variable if the variable is missing in .env.

Format: @optional {TYPES} CUSTOM_NAME = DEFAULT_VALUE

# EXAMPLES
# @optional {string}
# @optional {number|string}
# @optional {number|string} = 10
# @optional {number|string} customName
# @optional {number|string} customName = 10

TYPES

Please refer to the TYPES under the @required annotation.

CUSTOM_NAME (optional)

Please refer to the CUSTOM_NAME under the @required annotation.

= DEFAULT_VALUE (optional)

The default value to be assigned if the variable is missing in .env. The value must match one of specified types.

API Reference

πŸ’‘ config(options)

config will read your .env and .env.template to compose the environment variables.

const { error, env } = require('typed-dotenv').config({
  debug: false,
  path: '.env',
  encoding: 'utf8',
  errorOnFileNotFound: false,
  unknownVariables: 'keep',
  assignToProcessEnv: true,
  includeProcessEnv: true,
  template: {
    debug: false,
    path: '.env.template',
    encoding: 'utf8',
    errorOnFileNotFound: false,
    errorOnMissingAnnotation: false,
  },
  rename: {
    enabled: false,
    caseStyle: 'camelCase',
    nestingDelimiter: '__',
  },
});

options.debug (default: false)

Set to true to print the debug logs.

options.path (default: path.resolve(process.cwd(), '.env'))

The path to your .env file.

options.encoding (default: 'utf8')

The encoding of your .env file.

options.errorOnFileNotFound (default: false)

Set to true to throw an error when the .env file does not exist.

options.unknownVariables (default: 'keep')

The behaviour to handle a variable if it does not exist in .env.template but is found in .env.

  • 'keep': Simply keep the variable in the loaded variables.
  • 'remove': Remove the variable from the loaded variables.
  • 'error': Return an error with the list of unknown variables.

options.assignToProcessEnv (default: true)

Set to true to assign the loaded variables to process.env.

  1. The RAW STRING values, instead of the converted values, are assigned to process.env, because process.env accepts string only.

  2. If a variable has already been set in process.env, it WILL be overwritten UNLESS includeProcessEnv is set to false.

options.includeProcessEnv (default: true)

Set to true to include process.env to load the variables. The variables in process.env overrides the variables in .env.

If assignToProcessEnv is also set to true, process.env will be overwritten by the resulted variables.

options.template.path (default: path.resolve(process.cwd(), '.env.template'))

The path to your .env.template file.

options.template.encoding (default: 'utf8')

The encoding of your .env.template file.

options.template.errorOnFileNotFound (default: false)

Set to true to throw an error when the .env.template file does not exist.

options.template.errorOnMissingAnnotation (default: false)

Set to true to throw an error when any of the variables is not annotated properly.

options.rename.enabled (default: (see description))

When rename option is defined, rename.enabled defaults to be true, unless specified explicitly.

// Will NOT rename the variables by default.
const { env } = config();

// Will rename the variables using the default rename options.
const { env } = config({ rename: {} });

// Will NOT rename the varibles because the option is disabled explicitly.
const { env } = config({
  rename: {
    enabled: false,
    // ...
  },
});

options.rename.caseStyle (default: 'camelCase')

The case style for renaming the variables.

  • 'camelCase': Use lodash's camelCase function to rename the variables.
  • 'snake_case': Use lodash's snakeCase function to rename the variables.
  • null: Do not change the case style.

options.rename.nestingDelimiter (default: '__')

The delimiter for splitting the variable name into the nested structure.

For example, the default __ delimiter will convert MYSQL__USER__NAME=my_user into

{ "mysql": { "user": { "name": "my_user" } } }

πŸ’‘ compose(dotenvObj, templateObj, options)

You can construct your own dotenv object and template object to compose the variables.

const dotenvObj = {
  DEBUG: 'true',
};

const templateObj = {
  DEBUG: {
    required: false,
    types: ['boolean'],
    name: 'isDebug',
    rawDefaultValue: 'false',
    defaultValue: false,
  },
};

const options = {
  unknownVariables: 'keep',
  rename: {
    enabled: false,
    caseStyle: 'camelCase',
    nestingDelimiter: '__',
  },
};

const { rawEnv, convertedEnv, env } = require('typed-dotenv').compose(dotenvObj, templateObj, options);
// rawEnv = { DEBUG: 'true' }
// convertedEnv = { DEBUG: true }
// env = (options.rename.enabled) ? { isDebug: true } : { DEBUG: true }

options

Please refer to the corresponding options under the config function.

πŸ’‘ template.config(options)

Load the dotenv template.

const { error, parsed } = require('typed-dotenv').template.config({
  debug: false,
  path: '.env.template',
  encoding: 'utf8',
  errorOnFileNotFound: false,
  errorOnMissingAnnotation: false,
});

options

Please refer to options.template of the config function.

πŸ’‘ template.parse(src, options)

Parse the dotenv template.

const src = `
##
# @optional {boolean} = false
DEBUG=
`;

const options = {
  debug: false,
  errorOnMissingAnnotation: false,
};

const templateObj = require('typed-dotenv').template.parse(src, options);

src: string | Buffer

The template source in string or buffer.

options

Please refer to the corresponding options under options.template of the config function.

FAQ

Should I commit my .env and .env.template files?

.env

No, you should NOT commit .env to the version control system. The file is meant to be environment-dependent, and usually contains sensitive information like the passwords or API keys.

.env.template

Yes, the .env.template file is meant to be shared, so other people can follow the template to setup their own environments easily.

How do I use typed-dotenv with import?

When you run a module containing an import declaration, the modules it imports are loaded first, then each module body is executed in a depth-first traversal of the dependency graph, avoiding cycles by skipping anything already executed.

– ES6 In Depth: Modules

The following code WON'T load the environment variables for the foo module because config() runs AFTER importing the foo module.

import * as typedDotenv from 'typed-dotenv';
import foo from './foo';

typedDotenv.config();

You could solve the problem by either preloading typed-dotenv (ts-node --require dotenv/dist/config index.ts) or importing typed-dotenv/dist/config instead of typed-dotenv.

⚠️ There are 2 disadvantages with the above solutions however.

  1. process.env contains only the raw string values, instead of the converted values, of the variables. This goes against the purpose of using this library - type conversion.

  2. You cannot customise the configuration.

βœ… Therefore, the following solution is the most encouraged (for CommonJS as well).

/* * * * * * * * * *
 * 1. Setup `config.ts`
 * * * * * * * * * */

import * as typedDotenv from 'typed-dotenv';

const { error, env } = typedDotenv.config({
  // ...options
});

if (error) {
  // ...
}

export const config = env;

/* * * * * * * * * *
 * 2. In your module (e.g. `foo.ts`), import `config.ts`
 * * * * * * * * * */

import { config } from './config';

// Logic of your module.

/* * * * * * * * * *
 * 3. In your app's entry point (e.g. `index.ts`), import `config.ts` as soon as possible.
 * * * * * * * * * */

import './config';
import foo from './foo';

Other questions?

Feel free to raise an issue :)