Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dotenv): add the expectVars option for typing of return value of load() #4447

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
71 changes: 64 additions & 7 deletions dotenv/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export * from "./stringify.ts";
export * from "./parse.ts";

/** Options for {@linkcode load} and {@linkcode loadSync}. */
export interface LoadOptions {
export interface LoadOptions<V extends string = string> {
/**
* Optional path to `.env` file. To prevent the default value from being
* used, set to `null`.
Expand Down Expand Up @@ -60,18 +60,26 @@ export interface LoadOptions {
* @default {"./.env.defaults"}
*/
defaultsPath?: string | null;

/**
* Optional array of expected variable names.
* If set, the names are explicitly checked to be present in the loaded environment,
* and used for asserting the type of the return value of the loading functions.
*/
expectVars?: readonly V[];
}

/** Works identically to {@linkcode load}, but synchronously. */
export function loadSync(
export function loadSync<V extends string>(
{
envPath = ".env",
examplePath = ".env.example",
defaultsPath = ".env.defaults",
export: _export = false,
allowEmptyValues = false,
}: LoadOptions = {},
): Record<string, string> {
expectVars,
}: LoadOptions<V> = {},
): Record<V, string> {
const conf = envPath ? parseFileSync(envPath) : {};

if (defaultsPath) {
Expand All @@ -88,6 +96,8 @@ export function loadSync(
assertSafe(conf, confExample, allowEmptyValues);
}

assertHaveVars(conf, expectVars, allowEmptyValues);

if (_export) {
for (const [key, value] of Object.entries(conf)) {
if (Deno.env.get(key) !== undefined) continue;
Expand Down Expand Up @@ -207,6 +217,7 @@ export function loadSync(
* |examplePath|./.env.example|Path and filename of the `.env.example` file. Use null to prevent the .env.example file from being loaded.
* |export|false|When true, this will export all environment variables in the `.env` and `.env.default` files to the process environment (e.g. for use by `Deno.env.get()`) but only if they are not already set. If a variable is already in the process, the `.env` value is ignored.
* |allowEmptyValues|false|Allows empty values for specified env variables (throws otherwise)
* |expectVars|undefined|Forces an explicit check of the presence of the specific variables (and throws if those are absent), and infers the return value type
*
* ### Example configuration
* ```ts
Expand All @@ -218,6 +229,13 @@ export function loadSync(
* export: true,
* allowEmptyValues: true,
* });
*
* const { NAME, EMAIL } = await load({
* envPath: "./.env_prod",
* export: true,
* allowEmptyValues: true,
* expectVars: ["NAME", "EMAIL"] as const,
* }); // "confTyped" resolves to Record<"NAME" | "EMAIL", string>
* ```
*
* ## Permissions
Expand Down Expand Up @@ -270,15 +288,16 @@ export function loadSync(
* `KEY=${NO_SUCH_KEY:-${EXISTING_KEY:-default}}` which becomes
* `{ KEY: "<EXISTING_KEY_VALUE_FROM_ENV>" }`)
*/
export async function load(
export async function load<V extends string>(
{
envPath = ".env",
examplePath = ".env.example",
defaultsPath = ".env.defaults",
export: _export = false,
allowEmptyValues = false,
}: LoadOptions = {},
): Promise<Record<string, string>> {
expectVars,
}: LoadOptions<V> = {},
): Promise<Record<V, string>> {
const conf = envPath ? await parseFile(envPath) : {};

if (defaultsPath) {
Expand All @@ -295,6 +314,8 @@ export async function load(
assertSafe(conf, confExample, allowEmptyValues);
}

assertHaveVars(conf, expectVars, allowEmptyValues);

if (_export) {
for (const [key, value] of Object.entries(conf)) {
if (Deno.env.get(key) !== undefined) continue;
Expand Down Expand Up @@ -367,6 +388,42 @@ function assertSafe(
}
}

function assertHaveVars<V extends string>(
conf: Record<string, string>,
expectVars: LoadOptions<V>["expectVars"],
allowEmptyValues: boolean,
): asserts conf is Record<V, string> {
if (expectVars == undefined) return;

const missingEnvVars: string[] = [];

for (const item of expectVars) {
const value = conf[item] ?? Deno.env.get(item);

if (value === undefined || (value === "" && !allowEmptyValues)) {
missingEnvVars.push(item);
}
}

if (missingEnvVars.length === 0) return;

const errorMessages = [
`The following variables are expected but not present in the environment:\n ${
missingEnvVars.join(
", ",
)
}`,
`Make sure to add them to your env file.`,
!allowEmptyValues &&
`If you expect any of these variables to be empty, you can set the allowEmptyValues option to true.`,
];

throw new MissingEnvVarsError(
errorMessages.filter(Boolean).join("\n\n"),
missingEnvVars,
);
}

/**
* Error thrown in {@linkcode load} and {@linkcode loadSync} when required
* environment variables are missing.
Expand Down
45 changes: 45 additions & 0 deletions dotenv/mod_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,51 @@ Deno.test("load() loads .env and .env.defaults successfully from default file na
assertEquals(conf.DEFAULT1, "Some Default", "default value loaded");
});

Deno.test("load() checks that expected variables are found after loading from the filesystem", async () => {
const loadOptions = {
envPath: path.join(testdataDir, "./.env"),
examplePath: path.join(testdataDir, "./.env.example"),
defaultsPath: path.join(testdataDir, "./.env.defaults"),
expectVars: ["GREETING", "DEFAULT1"] as const,
} satisfies LoadOptions;
const loadOptionsToFail = {
envPath: path.join(testdataDir, "./.env"),
examplePath: path.join(testdataDir, "./.env.example"),
defaultsPath: path.join(testdataDir, "./.env.defaults"),
expectVars: ["GREETING", "DEFAULT1", "WHERE_AM_I"] as const,
} satisfies LoadOptions;

const conf = loadSync(loadOptions);
assertEquals(conf.GREETING, "hello world", "loaded from .env");
assertEquals(
conf.DEFAULT1,
"Some Default",
"default value from the defaults file",
);

const confAsync = await load(loadOptions);
assertEquals(confAsync.GREETING, "hello world", "loaded from .env");
assertEquals(
confAsync.DEFAULT1,
"Some Default",
"default value from the defaults file",
);

const error: MissingEnvVarsError = assertThrows(
() => loadSync(loadOptionsToFail),
MissingEnvVarsError,
);

assertEquals(error.missing, ["WHERE_AM_I"]);

const asyncError: MissingEnvVarsError = await assertRejects(
async () => await load(loadOptionsToFail),
MissingEnvVarsError,
);

assertEquals(asyncError.missing, ["WHERE_AM_I"]);
});

Deno.test("load() expands empty values from process env expand as empty value", async () => {
try {
Deno.env.set("EMPTY", "");
Expand Down