Home > OS >  Typescript check that all env variables are set based on interface (union type to tuple type issue)
Typescript check that all env variables are set based on interface (union type to tuple type issue)

Time:05-07

I want env.d.ts file to represent required application env variables and make sure that any change or addition to it will result in typescript error as well as runtime error for checkEnv function if value is not set.

To do that I create top level file env.d.ts file to extend process.env:

declare global {
    namespace NodeJS {
        interface ProcessEnv {
            PORT: string;
            HOST: string;
        }
    }
}
export { }; // required for declarations to work

And include this file in tsconfig.json:

{
  "include": [
    "./env.d.ts"
  ]
}

Because of typescript union to tuple type I found this workaround to do it, but is there a simpler way?

Typescript sandbox

// node process types
interface ProcessEnv {
    [key: string]: string | undefined
    TZ?: string;
}
declare var process: {
    env: ProcessEnv
}

// app env types from `env.d.ts`
interface ProcessEnv {
    PORT: string;
    HOST: string;
    // if something will be added to `env.d.ts` we need to ensure that `checkEnv` will show error
}

// https://github.com/sindresorhus/type-fest/blob/main/source/remove-index-signature.d.ts
type RemoveIndexSignature<ObjectType> = {
    [KeyType in keyof ObjectType as {} extends Record<KeyType, unknown>
    ? never
    : KeyType]: ObjectType[KeyType];
};

class UnreachableCaseError extends Error {
  constructor(unrechableValue: never) {
    super(`Unreachable case: ${unrechableValue}`);
  }
}

function checkEnv() {
    type AppEnv = Exclude<keyof RemoveIndexSignature<typeof process.env>, 'TZ'>  // PORT | HOST
    const envKeys: AppEnv[] = [
        'HOST',
        'PORT'
        // 'X' error - nice
        // no error if something will be added to `env.d.ts`
    ];

    for (const envKey of envKeys) {
        // use switch to ensure all keys from env union type are present in `envKeys` array
        switch (envKey) {
            case 'HOST':
            case 'PORT': {
                if (process.env[envKey] === undefined) {
                    throw new Error(`Env variable "${envKey}" not set`);
                }
                break;
            }
            default:
                throw new UnreachableCaseError(envKey); // ts will show error if something will be added to `env.d.ts` - nice
        }
    }
}
checkEnv();

CodePudding user response:

Maybe it would be simpler to use an enum? The values of the enum can be used both at compile-time for typing and at runtime.

enum EnvVariables {
    PORT = 'PORT',
    HOST = 'HOST'
}

We can use this to add the both PORT and HOST to the ProcessEnv interface.

type EnvVariablesType = {
    [K in keyof typeof EnvVariables]: string
}

declare global {
    namespace NodeJS {
        interface ProcessEnv extends EnvVariablesType {}
    }
}

Instead of having an array with all environment variables, we can just use Object.values to get all enum keys.

const envKeys = Object.values(EnvVariables).filter(value => typeof value === 'string') as (keyof typeof EnvVariables)[];

Also the switch seems unnecessary.

for (const envKey of envKeys) {        
  if (process.env[envKey] === undefined){
    throw new Error(`Env variable "${envKey}" not set`);
  }
}

Let me know if this works for you.

Playground


Edit:

On second thought: Why not make it even simpler and use an array?

// env.d.ts

const envVariables = ['HOST', 'PORT'] as const

type EnvVariablesType = {
    [K in typeof envVariables[number]]: string
}

declare global {
    namespace NodeJS {
        interface ProcessEnv extends EnvVariablesType {}
    }
}

// other file

function checkEnv() {
    for (const envKey of envVariables) {        
        if (process.env[envKey] === undefined){
            throw new Error(`Env variable "${envKey}" not set`);
        }
    }
}
checkEnv();

Playground

CodePudding user response:

Here's your types-only solution!

It does require an extra function to infer a few things for you, however, but I hope that isn't a big problem.

type HasAll<T extends ReadonlyArray<string>> = [T[number]] extends [AppEnv] ? [AppEnv] extends [T[number]] ? T : never : never;

Here we check if T, an array includes exactly the members in the union AppEnv with conditionals. The [] are there to prevent the conditional from being distributive and to prevent it from being a naked type.

We use this HasAll validator in this helper function:

function hasAll<T extends ReadonlyArray<string>>(t: HasAll<T>): T { return t as T; }

TypeScript is smart enough to infer T and pass it to HasAll. If it passes the checks, then the type of the parameter t is T, otherwise it is never.

This results in great type checking:

hasAll(["HOST"] as const); // error
hasAll(["HOST", "PORT"] as const); // fine
hasAll(["HOST", "PORT", "X"] as const); // error

However one limitation is that it does not error on duplicates:

hasAll(["HOST", "PORT", "HOST"] as const); // fine

Hopefully that isn't a big deal because while it is possible to check if there are duplicates, it's a lot of overhead and complexity for such a small thing.

You can see how this solution works with your use case below:

Playground

  • Related