1

I'm attempting to write a typescript helper library to strongly type process.env so you can do the following. The library with throw if the variable is missing or can't be converted to the correct type.

import { getEnv, num, str } from '@lib/env'

const env = getEnv({
  TABLE_NAME: num(),
})

// typeof env is
// const env: Readonly<{
//     TABLE_NAME: number;
// }>

I currently have the following code which works

type Validator<T> = (
  defaultValue?: T,
) => (processEnvKey: string, processEnvValue?: string) => T

type Validators<T> = { [K in keyof T]: ReturnType<Validator<T[K]>> }

export const str: Validator<string> = (defaultValue) => (
  processEnvKey,
  processEnvValue,
) => {
  if (processEnvValue != null) return processEnvValue
  if (defaultValue != null) return defaultValue

  throw new Error(
    `Environment variable '${processEnvKey}' is required and no default was provided`,
  )
}

export const num: Validator<number> = (defaultValue) => (
  processEnvKey,
  processEnvValue,
) => {
  if (processEnvValue != null) {
    const processEnvValueAsNumber = Number(processEnvValue)
    if (Number.isNaN(processEnvValueAsNumber)) {
      throw new Error(
        `Environment variable '${processEnvKey}' is required to be numeric but could not parse '${processEnvValue}' as a number`,
      )
    }

    return processEnvValueAsNumber
  }

  if (defaultValue != null) return defaultValue

  throw new Error(
    `Environment variable '${processEnvKey}' is required and no default was provided`,
  )
}

export const getEnv = <T>(
  validators: Validators<T>,
  environment = process.env,
): Readonly<T> => {
  const result: Partial<T> = {}

  for (const processEnvKey in validators) {
    const validator = validators[processEnvKey]
    result[processEnvKey] = validator(processEnvKey, environment[processEnvKey])
  }

  return result as Readonly<T>
}

I now have a new requirement where I know in advance all the environment keys that are available as an interface

interface Env {
  API_ENDPOINT: any
  TABLE_NAME: any
}

So I'm trying the change getEnv so that the object passed in can only contain the keys found in Env.

I tried changing getEnv but I'm getting stuck

export const getEnv = <T extends { [K in keyof Env]?: ????>(

If I change it to

export const getEnv = <T extends { [K in keyof Env]?: unknown>(

I can pass additional keys without the compiler complaining. The following doesn't cause a compiler error even though NON_EXISTING_KEY is not a key of Env

const env = getEnv({
  TABLE_NAME: num(),
  NON_EXISTING_KEY: str(),
})
kimsagro
  • 15,513
  • 17
  • 54
  • 69

1 Answers1

1

Nice idea!

This behavior is due to the way generic constraints are designed. The constraint T extends { [K in keyof Env]?: unknown } enforces the minimum requirements for T. Naturally, any additional properties are allowed.

What you need is a proper exact type, which would prevent any excess properties from being defined. User jcalz has a really great answer that walks through one of these:

type Exactly<T, U> = T & Record<Exclude<keyof U, keyof T>, never>;

This takes a type T and a candidate type U that we want to ensure is "exactly T". It returns a new type which is like T but with extra never-valued properties corresponding to the extra properties in U. If we use this as a constraint on U, like U extends Exactly<T, U>, then we can guarantee that U matches T and has no extra properties.


With this type available, you can declare your getEnv as follows:

export const getEnv = <T extends Exactly<{ [K in keyof Env]?: unknown }, T>>(
  validators: Validators<T>,
  environment = process.env,
): Readonly<T> => {};

Thus, T now must only match keys in { [K in keyof Env]?: unknown } and you have the restriction you desire.

Stackblitz reproduction.


PS: Due to the way Validators is defined, there is an edge-case where a property of type () => never can bypass the constraint. Now, I can't think of a reason someone would do this, but you can block this by updating the definition of Validators:

// from
type Validators<T> = { [K in keyof T]: ReturnType<Validator<T[K]>> }
// to
type Validators<T> = { [K in keyof T]: T[K] extends never ? never : ReturnType<Validator<T[K]>> };

The Stackblitz demonstrates this if you switch comments on line 17/18 and refresh.

Zack Ream
  • 2,956
  • 7
  • 18