Config Validation with io-ts

1.1k views Asked by At

I've just started using io-ts instead of runtypes in a new project. The pattern I have for config validation is to create an object with the types of each part of the config;

const configTypeMap = {
    jwtSecret: t.string,
    environment: t.string,
    postgres: postgresConnectionType
} as const

type Config = { [Key in keyof typeof configTypeMap]: t.TypeOf<typeof configTypeMap[Key]> }

and another object with the values that should satisfy that type;

const envVarMap = {
    jwtSecret: process.env.JWT_SECRET,
    environment: process.env.ENVIRONMENT,
    postgres: {
        user: process.env.POSTGRES_USER,
        password: process.env.POSTGRES_PASSWORD,
        host: process.env.POSTGRES_HOST,
        port: process.env.POSTGRES_PORT,
        database: process.env.POSTGRES_DATABASE,
    }
} as const

Then I create a function that takes in a key and returns a validated piece of the config under that key;

const getConfig = <T extends keyof Config>(key: T): Config[T] => {
    const result: Either<t.Errors, Config[T]>  = configTypeMap[key].decode(envVarMap[key])
    if (isLeft(result)) {
        throw new Error(`Missing config: ${key}`)
    }

    return result.right
}

This worked fine in runtypes (although it looked a little different). However, in io-ts, configTypeMap[key].decode is inferred as;

Left<t.Errors> | Right<string> | Right<{
    user: string;
    password: string;
    host: string;
    port: string;
    database: string;
}>

which has lost all context about which key the decode function was accessed from. I can cast result back to the correct type of Either<t.errors, Config[T]>, but I would like a way to do this without casting to validate that I'm not just ignoring an error.

EDIT:

playing around in the playground, I've managed to get a reproducing example that does not involve io-ts, so I think this is just a problem with my understanding of typescript inference. I'd still like to find a way to end up with a function that has the signature <T extends keyof Config>(key: T): Config[T].

2

There are 2 answers

1
Jack On

By removing the Config type and setting the return type of the get function to Static<typeof config[T]>, the desired response can be obtained. I don't know if you need the Config type for something else, or if this will work with io-ts but that may fix your problem.

Here's an updated playground with an example.

0
Souperman On

I think you are correct that this is sort of a limitation of TypeScript in that it cannot correctly tell that the two interfaces will have types that align if you use the same key to index into them. TypeScript can, however, figure this out if you break apart the look up with subsequent function calls.

This sort of curried function makes it so that TypeScript can follow the types throughout:

import * as t from 'io-ts';
import * as E from 'fp-ts/lib/Either';

function makeConfigValidator<A>(codecs: {[K in keyof A]: t.Type<A[K]> }) {
    return <K extends keyof A>(key: K) => (
        config: {[ConfigKey in keyof A]: unknown },
    ): A[K] => {
        const validated = codecs[key].decode(config);
        if (E.isLeft(validated)) {
            throw new Error('invalid');
        }
        return validated.right;
    };
}

const jwtSecret: string = makeConfigValidator(configTypeMap)('jwtSecret')(envVarMap);
console.log(makeConfigValidator(configTypeMap)('postgres')(envVarMap).port)

Full Playground

It might make sense to store the intermediate values to avoid repeating yourself. For example:

const configValidator = makeConfigValidator(configTypeMap);
const jwtSecretValidator = configValidator("jwtSecret");
const secret: string = jwtSecretValidator(envVarMap);