configugator
TypeScript icon, indicating that this package has built-in type declarations

2021.5.23-r5 • Public • Published

The mission of configugator is to minimize errors associated with using configuration data and to make thos errors happen as early as possible, when they cannot be prevented.

This is accomplished by bringing strong typing and early validation to processing configuration data.

Overview

With configugator, you define how config data are mapped and a strong typing is built up as you go along. When you've finished defining the mapping between your configuration data source and a useable config object, a type can easily be inferred.

Defining a Mapping

Configuration mappings are declared in chains of calls to a builder, starting with emptyConfigMapperBuilder. A convenience type defition has been provided to extract the type of a parsed config object from the resulting mapper.

Following is an example.

// config-definition.ts

import {
  emptyConfigMapperBuilder,
  types as t,
  optional, required,
  ConfigType
} from 'configugator'

export const configMapper = emptyConfigMapperBuilder
  .withMapping(required(t.string), 'FIRST_NAME', 'LAST_NAME')
  .withMapping(optional(t.string), 'MIDDLE_NAME')
  .withMapping(optional(t.int), 'GENERATION')
  .build()

export type Config = ConfigType<typeof configMapper>

configMapper is able to turn any dictionary similar to process.env into an object of type Config.

Config will be a type equivalent to the following:

{
  FIRST_NAME: string
  LAST_NAME: string
  MIDDLE_NAME?: string
  GENERATION?: int  
}

Parsing Configuration Data

Once a configuration mapper has been defined, it can be used against any string-to-string dictionary.

Following is an example of using it to consume process.env.

// env-config.ts

import { configMapper } from './config-definition';

const config = configMapper.map(process.env)

export default config

Simulating Configurations

The defined type can also easily be used to create mock configurations for testing purposes.

Following is an example.

// mock-env.ts

import { Config } from './config-definition';

export const shortName: Config = {
  FIRST_NAME: 'Marco',
  LAST_NAME: 'Pollo',
}

export const longerName: Config = {
  FIRST_NAME: 'John',
  MIDDLE_NAME: 'Q.',
  LAST_NAME: 'Public',
}

export const longestName: Config = {
  FIRST_NAME: 'Hubert',
  MIDDLE_NAME: 'R.',
  LAST_NAME: 'Fancybottom',
  GENERATION: 4,
}

export function testThis(formatThis: Config) {
  const parts: string[] = []
  parts.push(formatThis.FIRST_NAME)
  if (!!formatThis.MIDDLE_NAME)
    parts.push(formatThis.MIDDLE_NAME)
  parts.push(formatThis.LAST_NAME)
  let name = parts.join(' ')
  if (formatThis.GENERATION !== undefined) {
    switch(formatThis.GENERATION) {
      case 2: name += ', Jr.'; break
      case 3: name += ', III'; break
      case 4: name += ', IV'; break
      case 5: name += ', V'; break
    }
  }
  return name
}

Validation

In addition to converting a valid set of configuration strings into a meaningful, strongly typed object, configugator will also give usefull errors.

For instance, consider the following configuration mappings:

// error-config-definition.ts

import {
  ConfigType, emptyConfigMapperBuilder,
  optional, required,
  types as t
} from 'configugator';

export const configMapper = emptyConfigMapperBuilder
  .withMapping(required(t.string), 'DOMAIN', 'PATH')
  .withMapping(optional(t.string), 'PROTOCOL', 'QUERY', 'USER', 'PASSWORD')
  .withMapping(optional(t.int), 'PORT')
  .build()

export type Config = ConfigType<typeof configMapper>

Now imagine your process.env contains invalid values similar to those shown below:

// error-config-examples.ts

import { ConfigurationErrors } from 'configugator'
import { configMapper } from './error-config-definition'

try {
  configMapper.map({
    DOMAIN: 'someplace.com',
    PATH: '/endpoint-path',
    PROTOCOL: 'http',
    PORT: '100.4'
  })
} catch (errors) {
  if (errors instanceof ConfigurationErrors)
    console.log(errors.messages)
}

An attempt to map those config values would produce an exception with the following error messages in it:

[
  "optional integer field 'PORT' has an invalid value '100.4' (must be an integer)"
]

As another example, imagine your process.env contains the following invalid values:

// error-config-examples.ts

import { ConfigurationErrors } from 'configugator'
import { configMapper } from './error-config-definition'

try {
  configMapper.map({
    DOMAIN: 'someplace.com',
    PORT: 'no, merlot'
  })
} catch (errors) {
  if (errors instanceof ConfigurationErrors)
    console.log(errors.getReport())
}

This would generate an error report that looks like the following:

The following errors prevented configuration:
  - required string field 'PATH' has no value
  - optional integer field 'PORT' has an invalid value 'no, merlot' (invalid format)

These are clear, actionable details about what is wrong with the properties that can be used to quickly correct the problem.

Default Values for Optional Configurations

It is also possible to specify a default value as shown in the code below:

import { emptyConfigMapperBuilder, optionalWithDefault, types as t } from 'configugator';

const configMapper = emptyConfigMapperBuilder
  .withMapping(optionalWithDefault(t.string, '-'), 'USER_NAME')
  .build()

console.log(JSON.stringify({
  "empty result": configMapper.map({}),
  "populated result": configMapper.map({ USER_NAME: '@Awesome-O' })
}, undefined, 2))

Executing that code produces the following output:

{
  "empty result": {
    "USER_NAME": "-"
  },
  "populated result": {
    "USER_NAME": "@Awesome-O"
  }
}

Note, an optionalWithDefault creates a configuration value that is not optional, from the perspective of your code. It is only optional in the original configuration source.

Consequently, you would not be able to assign a value of type ConfigType<typeof configMapper> without the source object containing a USER_NAME property.

Example

Here is a comparison of using process.env as many node applications do with using configugator.

Without Configugator

Consider the following application:

// job.ts

import fs from 'fs'

const inputFile = fs.readFileSync(process.env.SRCFILE!).toString()

const rawRows = inputFile
  .split('\n')
  .map(row => row.trim())
  .map(s => s.split(',').map(cell => cell.trim()))

const headerRow = rawRows[0]
const reportColumns = process.env.COLS!.split(',')

const columnIndeces = reportColumns
  .map(columnName => headerRow.indexOf(columnName))

let report: string = process.env.COLS! + '\n'

for (const inputRow of rawRows) {
  report += columnIndeces.map(i => inputRow[i]).join(',')
  report += '\n'
}

fs.writeFileSync(process.env.OUTFILE!, report)

Imagine it will be run with the following environment variables:

OUTPUT_FILE=output.txt

This will fail...

// job.ts

import fs from 'fs'

const inputFile = fs.readFileSync(process.env.SRCFILE!).toString()
//                                ^^^^^^^^^^^^^^^^^^^^
// error about not being able to read a file with a null filename

After a little study, you can determine that there is an environment variable missing and update the env.

SRCFILE=in.csv
OUTPUT_FILE=output.txt

That leads to the next error:

const reportColumns = process.env.COLS!.split(',')
//                                      ^^^^^
// error about there not being a property split on type undefined

That one can be inferred from the file, so we fix it with this change:

const reportColumns = process.env.COLS?.split(',') ?? headerRow

However, we quickly find it wasn't the only reference when we get a poorly-generated report with no header row:

let report: string = process.env.COLS! + '\n'
//                               ^^^^^
// oops! first row ends up blank!

So we fix that, too:

let report: string = reportColumns.join(',') + '\n'

This reveals yet another inscrutible error:

fs.writeFileSync(process.env.OUTFILE!, report)
//               ^^^^^^^^^^^^^^^^^^^
// error about not being able to write to a file with a null file name

After determining that it is an environmental problem, we can again fix the environment variables:

SRCFILE=in.csv
OUTFILE=output.txt

Finally, the system is working.

With Configugator

Now let's see what the same application would look like with configugator:

// job.ts

import fs from 'fs'
import {
  emptyConfigMapperBuilder, optional, required, types as t
} from '../..'

const configMapping = emptyConfigMapperBuilder
  .withMapping(required(t.string), 'SRCFILE', 'OUTFILE')
  .withMapping(optional(t.string), 'COLS')
  .build()

const config = configMapping.map(process.env)

const inputFile = fs.readFileSync(config.SRCFILE).toString()

const rawRows = inputFile
  .split('\n')
  .map(row => row.trim())
  .map(s => s.split(',').map(cell => cell.trim()))

const headerRow = rawRows[0]
const reportColumns = config.COLS?.split(',') ?? headerRow

const columnIndeces = reportColumns
  .map(columnName => headerRow.indexOf(columnName))

let report: string = reportColumns + '\n'

for (const inputRow of rawRows) {
  report += columnIndeces.map(i => inputRow[i]).join(',')
  report += '\n'
}

fs.writeFileSync(config.OUTFILE, report)

Note that strong typing discouraged the creation of the column headings bug that existed in the other version of the job.

The environment has the same problems as before, though:

OUTPUT_FILE=output.txt

This time, we get a simple error report from configugator right at the beginning of executing the job.

const config = configMapping.map(process.env)
//                           ^^^^^^^^^^^^^^^^
// one error that tells you what you are missing
// and what kind of values are expected for those
// enviroment variables

As a result we are able to quickly fix all the environment variables:

SRCFILE=in.csv
OUTFILE=output.txt

Supported Configuration Values

While you can extend with any kind of configuration value you like, strings, integers, floating point numbers, and URLs are supported natively.

Package Sidebar

Install

npm i configugator

Weekly Downloads

1

Version

2021.5.23-r5

License

MIT

Unpacked Size

30.2 kB

Total Files

22

Last publish

Collaborators

  • maxguernseyiii