1.0.0 • Published 5 years ago

typed-configuration v1.0.0

Weekly downloads
1
License
GPL-2.0-only
Repository
github
Last release
5 years ago

typed-configuration

A library to impose strong typing on configuration values

Motivation

Configuring a large system is complicated and important. The consensus, for better or worse, is to set environment variables outside the executable and then read them inside it.

This library allows systems written in Typescript to

  1. declare the names and datatypes of the configuration they need
  2. enforce the types of the configuration they need at compile-time
  3. enforce the types of the configuration they receive at run-time

Introduction

Using this library, each configuration is rendered as Typescript interface. For example:

interface MyServiceConfig {
  username: string;
  port: number;
  active: boolean;
}

Then configuration has a description, which is an object that has the same properties as the interface, but the value of the property specifies the rule for getting the value of the configuration:

const MyServiceConfigDescription =  {
  username: stringConfigValue('SAMP_USERNAME'),
  port: intConfigValue('SAMP_PORT', 3000), 
  active: boolConfigValue('active', true), 
};

Now the description can be evaluated against the environment variables to produce the configuration instance:

const config: MyServiceConfig = evaluateConfig(MyServiceConfigDescription);

Compile-time type safety

The library is completely type-safe. If there is a type-mismatch between what a configuration wants and what the configuration descriptor can provide, the code will not compile. Consider this example:

  a: string;
}
const configDescription = {
  b: stringConfigValue("B"),
};
const config: SomeConfig = evaluateConfig(configDescription);

The compiler will object to this, saying Property 'a' is missing in type '{ b: string; }' but required in type 'SomeConfig'. Similarly,

  a: number;
}
const configDescription = {
  a: stringConfigValue ("A"),
};
const config: SomeConfig = evaluateConfig(configDescription);

will fail: Type 'string' is not assignable to type 'number'.

Unfortunately, the following will compile:

  a: string;
}
const configDescription = {
  a: stringConfigValue ("A"),
  b: stringConfigValue ("B"),
};
const config: SomeConfig = evaluateConfig(configDescription);

The b is left unread, but that is just a (minor) waste of resources, not an error. If you suspect a descriptor might be unnecessary, comment it out and try to recompile the system.

Evaluation

Typescript typing can guarantee that the type of the configuration description matches configuration description, but it cannot guarantee that the environment at runtime will match configuration description. All that checking must be done at run-time. The evaluation is done as follows:

  • if the value of named environment variable, called the source value, is present and can be converted to the specified type, that converted value is use.
  • if the value of named environment variable is present but cannot be converted to the specified type -- for example, a value that is supposed to be a number but is set to "TWO" or a value that is supposed to be a boolean but is set to "YES" -- but a default-value is given, the default-value is used
  • if the value of named environment variable is present but cannot be converted to the specified type and no default-value is given, a TypeError is thrown.
  • if the value of named environment variable is not present, regardless of any default-value, a TypeError is thrown.

When is the TypeError is thrown?

If a needed source value is not present, when is the TypeError is thrown?

The intuitive answer would be, I think, "when the configuration is created"; that is, when evaluateConfig() is invoked.

Since that is the intuitive, nature answer, that is in fact the default behavior. All the code sample in this README for example will raise a TypeError on the evaluation.

But, that might not always be what you want. In many companies, the files that set the environment variable are not checked into source control: they contain passwords and API-keys that are considered company-confidential. In consequence, developers and testers are frequently forced to manually set environment variables to harmless values to placate obscure subsystems that have nothing to do with the work they are trying to do.

For that situation, the library supports "lazy evaluation":

const config: SomeConfig = evaluateConfig(configDescription, true);

If there is a problem with the environment but it turns out the configuration value is never actually read, the exception is never through.

Best Practices

A good practice is to define the configuration interface right before the class that needs it and then gather all the configuration instances in a single file, like this:

src/services/MyService.ts:

export interface MyServiceConfig {
  username: string;
  port: number;
  active: boolean;
}
export class MyService {
  constructor(private readonly config: MyServiceConfig) { }
...

src/config/environment.ts:

import  { MyServiceConfig } from '../services/MyService'
export const myServiceConfig: MyServiceConfig = evaluateConfig({
  username: stringConfigValue('SAMP_USERNAME'),
  port: intConfigValue('SAMP_PORT', 3000), 
  active: boolConfigValue('active', true), 
});

A better practice is to use dependency injection to inject the configuration into the service. That will be described in another article.

Descriptor factories

Each entry in the config description object is called a descriptor, and descriptors are made by descriptor factories. Each factory is invoked with a name and an optional default-value. The library comes the following descriptor factories:

  • booleanConfigValue - returns a boolean, requires that the source value is literally "true" or "false" (case-insensitive, leading and trailing whitespace ignored)
  • intConfigValue - returns a number, requires that the source value is non-empty string of digits, with an optional preceding sign (leading and trailing whitespace ignored)
  • stringConfigValue - returns a string, accepts anything, even an empty string
  • nonEmptyStringConfigValue - returns a string, accepts anything except an empty string

Custom descriptor factories

If you need a more sophisticated descriptor factory, you can easily make one, using the createDescriptorFactory() function. It takes two arguments: a parser and a message. The parser is a function takes a string and returns the appropriate type, or undefined if the string is parsable; the message describes the acceptable inputs. For example:

type Color = "red" | "green" | "blue";
export const enumConfigValue = createDescriptorFactory<Color>((s: string) => {
  const sv = s.toLowerCase().trim();
  return sv === "red" || sv === "green" || sv === "blue" ? sv : undefined;
}, "'red', 'green', or 'blue'");

Configuration sources

A configuration source is the structure from which the source values are obtained. Conventionally, of course, that is the environment variable, but the library allows for an alternative.

A Configuration Source is just a function that takes a string and returns a string or undefined. The default Configuration Source is EnvironmentSource, which just just looks up its argument in the process.env, but there is also DictionarySource, a higher-order function that takes a hashmap as returns a custom Configuration Source based on that hashmap.

const testSource = DictionarySource({ "BASECOLOR" : "red" });
const configLayout = {
    color: enumConfigValue("BASECOLOR")
};
const config = evaluateConfig(configLayout, false, testSource);

config.color will of course be set to "red". DictionarySource currently is only used for testing but perhaps there is some other use.