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.
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.
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
}
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
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
}
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.
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.
Here is a comparison of using process.env
as many node applications do with using 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.
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
While you can extend with any kind of configuration value you like, strings, integers, floating point numbers, and URLs are supported natively.