Validating configuration with io-ts

Thom
Published on March 4th 2021
A modular synthesizer
How we ensure our services don't get deployed with invalid configuration

Background

At Candide we use a microservice architecture of Node.js services written in TypeScript, running on Kubernetes. Here we'll explore how we ensure our services don't run with invalid configuration.

Example service

Let's consider a simple Express service:
1import * as express from "express";
2
3const port = 3000;
4
5const app = express();
6app.get("/", (req, res) => res.send("Hello World!"));
7
8app.listen(port, () => console.log(`Example app listening on port ${port}!`));
Say we want to configure two aspects of this service, which might vary per-environment:
  • the port to listen on
  • the response to send
We could write it like so:
1import * as express from "express";
2
3const port = process.env.EXAMPLE_PORT;
4
5const app = express();
6app.get("/", (req, res) =>
7 res.send(process.env.EXAMPLE_RESPONSE.toUpperCase())
8);
9
10app.listen(port, () => console.log(`Example app listening on port ${port}!`));
This would work, but there are several problems with this approach. Let's address some of them individually.

Missing values

If we forget to supply the EXAMPLE_PORT environment variable, port will be undefined.
You might be tempted to think that if we supply undefined for the port then Express would refuse to start. Nope:
If port is omitted or is 0, the operating system will assign an arbitrary unused port
So our server will start, but on an arbitrary port. Probably not very useful. Chances are there are clients attempting to connect to this server on a specific port, which will now fail to do so.
At Candide, we would probably catch this pretty quickly. Kubernetes will try to send health check requests to the service, which would fail. But we wouldn't want to rely on this.
Let's put in a check to ensure we haven't forgotten to supply the port:
1import * as express from "express";
2
3const port = process.env.EXAMPLE_PORT;
4
5if (port == null) {
6 throw new Error("Required config: EXAMPLE_PORT");
7}
8
9const app = express();
10app.get("/", (req, res) =>
11 res.send(process.env.EXAMPLE_RESPONSE.toUpperCase())
12);
13
14app.listen(port, () => console.log(`Example app listening on port ${port}!`));
Now, if we try to launch the service without a port, it will fail to start. The sooner we fail, the sooner we can find and fix the issue. Having a specific error emitted from this service (rather than from client services) makes it much quicker and easier to debug what the error is.
What if we forget to supply EXAMPLE_RESPONSE? Well, again the server will start just fine, but every request to that endpoint will error. We won't see any problems until traffic has already started reaching the service. Doing a check at start up will make sure we fail before starting to receive traffic. Since we run on Kubernetes, no traffic will be routed to the service until it's ready.
Let's refactor like so:
1import * as express from "express";
2
3const port = process.env.EXAMPLE_PORT;
4const responseText = process.env.EXAMPLE_RESPONSE;
5
6if (port == null) {
7 throw new Error("Required config: EXAMPLE_PORT");
8}
9if (responseText == null) {
10 throw new Error("Required config: EXAMPLE_RESPONSE");
11}
12
13const app = express();
14app.get("/", (req, res) => res.send(responseText.toUpperCase()));
15
16app.listen(port, () => console.log(`Example app listening on port ${port}!`));

Incorrect type

OK, now we're checking that we have all of the required config values. But what happens if the port is not an integer?
Let's say we set EXAMPLE_PORT=eighty, we might expect Express to reject that because it's obviously not a valid port number.
Instead, when running it we see: Example app listening on port eighty!.
Oh.
If you run ls -ld * you might see something like:
1$ ls -ld *
2srwxrwxr-x 1 thom thom 0 Mar 4 17:27 eighty=
See that s before the file permissions? That means this is a Unix socket. Under the covers, Express is using the Node.js net package which provides IPC support.
This probably isn't what we wanted. Again, the server appears to start up fine, but nothing will be able to connect.
Let's do some more validation, to make sure the port is something valid:
1import * as express from "express";
2
3const portVar = process.env.EXAMPLE_PORT;
4const responseText = process.env.EXAMPLE_RESPONSE;
5
6if (portVar == null) {
7 throw new Error("Required config: EXAMPLE_PORT");
8}
9if (responseText == null) {
10 throw new Error("Required config: EXAMPLE_RESPONSE");
11}
12
13let port;
14try {
15 port = parseInt(portVar, 10);
16} catch (error) {
17 throw new Error("EXAMPLE_PORT is not an integer");
18}
19
20if (port < 0 || port > 65535) {
21 throw new Error("EXAMPLE_PORT is out of range");
22}
23
24const app = express();
25app.get("/", (req, res) => res.send(responseText.toUpperCase()));
26
27app.listen(port, () => console.log(`Example app listening on port ${port}!`));
Now we'll fail early if EXAMPLE_PORT isn't supplied or is invalid. Great.

Using io-ts

We can take this a step further using the excellent io-ts library.
Here is an example which uses io-ts to validate the config, convert the port to an integer, and give us a well-typed object we can pass around to the rest of our service.
1import * as express from "express";
2import * as t from "io-ts";
3import { IntFromString } from "io-ts-types/lib/IntFromString";
4import { failure } from "io-ts/lib/PathReporter";
5
6type Config = Readonly<{
7 port: number;
8 responseText: string;
9}>;
10
11const IOEnv = t.type({
12 EXAMPLE_PORT: IntFromString,
13 EXAMPLE_RESPONSE: t.string
14});
15
16const decodedConfig = IOEnv.decode(process.env).map(
17 (env): Config => ({
18 port: env.EXAMPLE_PORT,
19 responseText: env.EXAMPLE_RESPONSE
20 })
21);
22
23if (decodedConfig.isLeft()) {
24 throw new Error(
25 "Config validation errors: " + failure(decodedConfig.value).join("\n")
26 );
27}
28
29const config: Config = decodedConfig.value;
30
31const app = express();
32
33app.get("/", (req, res) => res.send(config.responseText.toUpperCase()));
34
35app.listen(config.port, () =>
36 console.log(`Example app listening on port ${config.port}!`)
37);
In a larger service, we would extract out the config validation into its own module. The rest of the service would use the Config object to access config, rather than process.env directly. In fact, we could use this ESLint rule to disallow direct use of process.env if we wanted.
For more on configuration see The Twelve-Factor App.

Be the first to download the app

Help us build a place where community meets knowledge. Try it out and let us know what you think.
Download on the App StoreGet it on Google Play

What is Candide?

Candide has everything for plant lovers – buy plants from independent sellers and book tickets to visit inspiring gardens near you. Identify plants in seconds from a single photo and learn how to care for them with our in-depth guides.

OUR APP

Learn how to care for your plants and share your growing successes on Candide’s free app for your phone or tablet.

Download on the App StoreGet it on Google Play

Germinated in Bristol © 2021 Candide