YakovL
YakovL

Reputation: 8365

Can I convert a { [key: string]: string | undefined }-like type into { [key: string]: string } type in TypeScript?

I'm using dotenv for ENV variables. To make autosuggestions work, I'm using

const {
    SECRET1,
    SECRET2,
    ...
} = process.env
const secrets = {
    SECRET1,
    SECRET2,
    ...
} as const satisfies { [key: string]: string | undefined }

type secretKey = keyof typeof secrets

for(const key in secrets) if(!secrets[key as secretKey]) throw new Error(`Expected ${key} to be set in .env`)

after this point, all secrets should be set (otherwise, the app throws and goes down), but secrets is still of type

{
    readonly SECRET1: string | undefined;
    readonly SECRET2: string | undefined;
    ...
}

I'd like to cast it to

{
    readonly SECRET1: string;
    readonly SECRET2: string;
    ...
}

but keep it DRY. I've tried

const checkedSecrets = secrets as Required<typeof secrets>

but that doesn't help (checkedSecrets still has those "undefined" as SECRET1 is not optional, it is string | undefined instead – I guess Required doesn't help with that). I've checked Utility Types, but haven't found a solution there. Could you suggest one?

Upvotes: 0

Views: 333

Answers (2)

jsejcksn
jsejcksn

Reputation: 33901

By defining the environment variables as string literals, you can both validate their presence and the desired type at once:

TS Playground

function validateEnv<Keys extends readonly string[]>(
  keys: Keys,
): string[] extends Keys ? Record<never, never> : Record<Keys[number], string> {
  const result = {} as Record<Keys[number], string>;

  for (const key of keys) {
    const value = process.env[key];
    if (!value) throw new Error(`Expected environment variable "${key}" not set`);
    result[key as Keys[number]] = value;
  }

  return result;
}

const requiredEnvVars = ["SECRET_1", "SECRET_2"] as const;

const secrets = validateEnv(requiredEnvVars);
    //^? const secrets: Record<"SECRET_1" | "SECRET_2", string>

// OR:

const {
  SECRET_1,
  //^? const SECRET_1: string
  SECRET_2,
  //^? const SECRET_2: string
  SECRET_3, /* Error (as expected)
  ~~~~~~~~
  Property 'SECRET_3' does not exist on type 'Record<"SECRET_1" | "SECRET_2", string>'.(2339) */
} = validateEnv(requiredEnvVars);

As you can see in the code above, the return type of the function is an object having only keys that correspond to the string literals and string values at those keys. Attempting to destructure other keys will cause a compiler error.


The one minor annoyance of this technique is the necessity of using a const assertion on the input array so that the compiler infers the string elements in the array as literals:

const requiredEnvVars = ["SECRET_1", "SECRET_2"] as const;
//                                               ^^^^^^^^

Failing to do so will prevent the compiler from being able to infer the key literals, causing a return type that has no entries:

TS Playground

const secrets = validateEnv(["SECRET_1", "SECRET_2"]);
    //^? const secrets: Record<never, never>

On the bright side: the release of TypeScript 5.0 is just around the corner, and the new const type parameters feature will allow you to influence the inference behavior, making it unnecessary to use the const assertion on the argument value:

TS Playground (using nightly compiler)

// Same implementation as above
declare function validateEnv<const Keys extends readonly string[]>(
//                           ^^^^^
  keys: Keys,
): string[] extends Keys ? Record<never, never> : Record<Keys[number], string>;

const secrets = validateEnv(["SECRET_1", "SECRET_2"]);
    //^? const secrets: Record<"SECRET_1" | "SECRET_2", string>

// OR:

const {
  SECRET_1,
  //^? const SECRET_1: string
  SECRET_2,
  //^? const SECRET_2: string
  SECRET_3, /* Error (as expected)
  ~~~~~~~~
  Property 'SECRET_3' does not exist on type 'Record<"SECRET_1" | "SECRET_2", string>'.(2339) */
} = validateEnv(["SECRET_1", "SECRET_2"]);

Upvotes: 1

Godric
Godric

Reputation: 819

You can construct mapped type and use Exclude to remove undefined from each property.

type secretsType = typeof secrets;
type secretKey = keyof secretsType
const checkedSecrets = secrets as { readonly [KEY in secretKey]: Exclude<secretsType[KEY], undefined>; }

that will give you:

const checkedSecrets: {
    readonly SECRET1: string;
    readonly SECRET2: string;
}

Upvotes: 1

Related Questions