npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

typed-configuration

v1.0.0

Published

A library to impose strong typing on configuration variables

Downloads

3

Readme

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.