node-config made type-safe

aigoncharov

Andrey Goncharov

Posted on June 1, 2020

node-config made type-safe

I now have a new shiny blog. Read this article with the latest updates there https://blog.goncharov.page/node-config-made-type-safe

node-config has been serving the Node.js community as pretty much the default config solution for many years. Its simplistic, yet powerful design helped it to spread like a virus across multiple JS libraries. Yet those very design choices don't always play nice with new strictly-typed kids on the block. Like TypeScript. How could we keep using our favorite config tool and stay on the type-safe side of things?

Step 1: Make an interface for your config

Supposedly, you have a config folder somewhere in your project, which in the most simple case has this structure:

  • default.ts
  • production.ts

Yes, node-config speaks TypeScript out-of-the-box!

Let's consider a case of writing a config for an app, which creates new worlds populated only by cats.

Our default config could look like this:

// default.ts
const config = {
  // Default config assumes a regular 4-pawed 1-tailed cat
  cat: {
    pawsNum: 4,
    tailsNum: 1,
  },
}

module.exports = config
Enter fullscreen mode Exit fullscreen mode

Our production config could be this:

// production.ts
const configProduction = {
  // In production we create mutant ninja cats with 8 paws
  cat: {
    pawsNum: 8,
  },
}

module.exports = configProduction
Enter fullscreen mode Exit fullscreen mode

Basically, our production config is always a subset of our default one. So we could create an interface for our default config and use a partial (DeepPartial to be true) of that interface for our production config.

Let's add constraint.ts file with the interface:

// constraint.ts
export interface IConfigApp {
  cat: {
    pawsNum: number
    tailsNum: number
  }
}
// We'll need this type for our production config.
// Alternatively, you can use ts-essentials https://github.com/krzkaczor/ts-essentials
export type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends Array<infer U>
    ? Array<DeepPartial<U>>
    : T[P] extends ReadonlyArray<infer U>
    ? ReadonlyArray<DeepPartial<U>>
    : DeepPartial<T[P]>
}
Enter fullscreen mode Exit fullscreen mode

Then we could use it in our default.ts:

// default.ts
import { IConfigApp } from './constraint'

const config: IConfigApp = {
  // Default config assumes a regular 4-pawed 1-tailed cat
  cat: {
    pawsNum: 4,
    tailsNum: 1,
  },
}

module.exports = config
Enter fullscreen mode Exit fullscreen mode

And in our production.ts:

// production.ts
import { IConfigApp, DeepPartial } from './constraint'

const configProduction: DeepPartial<IConfigApp> = {
  // In production we create mutant ninja cats with 8 paws
  cat: {
    pawsNum: 8,
  },
}

module.exports = configProduction
Enter fullscreen mode Exit fullscreen mode

Step 2: Make config.get type-safe

So far we resolved any inconsistencies between a variety of our configs. But config.get still returns any.

To fix that let's add another typed version of config.get to its prototype.

Assuming your projects has the folder config in its root and the code of your app in the folder src, let's create a new file at src/config.service.ts

// src/config.service.ts
import config from 'config'

// The relative path here resolves to `config/constraint.ts`
import { IConfigApp } from '../config/constraint'

// Augment type definition for node-config.
// It helps TypeScript to learn about uor new method we're going to add to our prototype.
declare module 'config' {
  interface IConfig {
    // This method accepts only first-level keys of our IConfigApp interface (e.g. 'cat').
    // TypeScript compiler is going to fail for anything else.
    getTyped: <T extends keyof IConfigApp>(key: T) => IConfigApp[T]
  }
}

const prototype: config.IConfig = Object.getPrototypeOf(config)
// Yep. It's still the same `config.get`. The real trick here was with augmenting the type definition for `config`.
prototype.getTyped = config.get

export { config }
Enter fullscreen mode Exit fullscreen mode

Now we can use config.getTyped anywhere in our app, importing it from src/config.service.

Be aware, we no longer can do caonfig.getTyped('cat.pawsNum'). It's impossible to make it type-safe.

To make import config from 'config' work set "esModuleInterop": true under compilerOptions in your tsconfig.json.

It could look like this in our src/app.ts:

// src/app.ts
import { config } from './config.service'

const catConfig = config.getTyped('cat')
Enter fullscreen mode Exit fullscreen mode

Live demo

You might want to use tsconfig-paths to avoid deep relative imports.

Hopefully, you've found something useful for your project. Feel free to communicate your feedback to me! I most certainly appreciate any criticism and questions.

💖 💪 🙅 🚩
aigoncharov
Andrey Goncharov

Posted on June 1, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related