Reputation: 8365
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
Reputation: 33901
By defining the environment variables as string literals, you can both validate their presence and the desired type at once:
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:
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
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