Node.js で型安全な環境変数を扱うスニペットを作りました。

next devのようなアプリケーションの起動、Playwright でのテストなどコマンドごとに渡したい環境変数のセットが異なるケースがあります。 この場合に環境変数をまとめたものを定義して、それをコマンドごとに読み込むセットを変えたいことがあります。

次のようにベタ書きしてもいいのですが、渡したい環境変数が増えると管理が大変になります。

NEXT_PUBLIC_LOCALHOST_URL=http://localhost:3000 NEXT_PUBLIC_API_URL=http://localhost:3001 NEXT_PUBLIC_IS_TEST_MODE=false FOO="bar" next dev

そのため、.envのような環境変数をまとめたファイルを使いたくなります。 Node.js は--env-fileフラグで.envファイルを読み込むことができますが、.envファイルは型安全ではありません。

環境変数名の Typoや必須の環境変数が設定されていないなどの問題が発生する可能性があります。 そのため環境変数をの定義自体も TypeScript で型安全に定義したいです。

これをやるための 50 行ほどのスニペットを書いたので、使い方を紹介します。

  • 📝 主な用途はローカルやCIでの開発用で、実際にデプロイするサーバなどには利用しない想定です
  • 📝 ライブラリとかにしてないのは、この仕組み自体が外部パッケージの依存もなく短いスニペットだからです
    • JSからTSを参照する都合上、ライブラリにはちょっとしにくい気がします

サンプルリポジトリ

次の場所にサンプルリポジトリがあります。

使い方

大きく分けて、環境変数の型を定義するdefineEnv関数と、環境変数をセットするsetEnv関数があります。

環境変数の型を定義する

defineEnv関数を次のような受け取りたい環境変数の型定義をするUtilityです。

export type BaseEnvRecord = Record<
    string,
    {
        value: string | undefined;
        required: boolean;
        defaultValue?: string;
    }
>;
export type ReturnTypeOfCreateEnv<T extends BaseEnvRecord> = {
    // If the value is required, it should be a string, otherwise it should be a string or undefined
    [K in keyof T]: T[K]["required"] extends true ? string : string | undefined;
};
/**
 * Define environment variables and create them
 */
export const defineEnv = <T extends BaseEnvRecord>(envs: T): ReturnTypeOfCreateEnv<T> => {
    const result = new Map<string, string | undefined>();
    Object.entries(envs).forEach(([key, { value, required, defaultValue }]) => {
        if (required && !value && !defaultValue) {
            throw new Error(
                `Missing required environment variable: ${key}, value: ${value === undefined ? "undefined" : `"${value}"`}`
            );
        }
        result.set(key, value || defaultValue);
    });
    return Object.fromEntries(result) as ReturnTypeOfCreateEnv<T>;
};
  • env.ts: アプリケーション用の環境変数を定義する

env.tsでは、defineEnv関数を使ってアプリケーションで受け取りたい環境変数の型を定義します。

import { defineEnv } from "./defineEnv";

export const env = defineEnv({
  /**
   * Localhost URL
   */
  LOCALHOST_URL: {
    value: process.env["LOCALHOST_URL"],
    required: true,
    defaultValue: "http://localhost:3000",
  },
  /**
   * Is test mode?
   */
  IS_TEST_MODE: {
    value: process.env["IS_TEST_MODE"],
    required: true,
    defaultValue: "false",
  },
  /**
   * Optional value
   */
  OPTIONAL_VALUE: {
    value: process.env["OPTIONAL_VALUE"],
    required: false,
  },
});

このときに、Node.js ならprocess.envを使って環境変数を取得します。 値ごとにrequiredで必須かどうかの指定や、defaultValueでデフォルト値を指定できます。 具体的には、required: truedefaultValueが指定されていなくて、process.env.*の値がない場合はエラーになります。

この defineEnv関数で定義したenvはアプリケーションが使う環境変数をまとめたものです。

次のようにアプリケーションからはenvを import して使います。 defineEnv関数で定義した環境変数を型安全に使うことができます。

// use env
import { env } from "../env/env";
// type-safe
console.log("localhost url", env.LOCALHOST_URL);
console.log("Is Test Mode", env.IS_TEST_MODE);
console.log("OPTIONAL_VALUE", env.OPTIONAL_VALUE); // string or undefined

一方で、process.envに設定する環境変数自体も型安全に設定したいです。

環境変数をセットする

次のsetEnv関数を使って、プロセスに対して環境変数をセットします。

  • setEnv.js: 環境変数をセットする Utility

例として、次のようなenv.local.jsenv.test.jsのような環境変数をまとめたファイルを作ります。

  • env.local.js: ローカル開発用の環境変数をセットする
import { setEnv } from "./src/env/setEnv";

// local環境用の環境変数をセット
setEnv({
  LOCALHOST_URL: "http://localhost:3500",
  IS_TEST_MODE: "false",
});
  • env.test.js: テスト用の環境変数をセットする
import { setEnv } from "./src/env/setEnv";

// test環境用の環境変数をセット
setEnv({
  LOCALHOST_URL: "http://localhost:3500",
  IS_TEST_MODE: "true",
});

あとは、このenv.*.jsNODE_OPTIONSを使って読み込むことで、環境変数をセットできます。

例えば、env.local.jsを使って開発サーバーを起動する場合は次のようにします。

NODE_OPTIONS='--import ./env.local.js' npm run dev

テストを実行する場合は、env.test.jsを使って次のようにします。

NODE_OPTIONS='--import ./env.test.js' npm test

これで、npm run devnpm testなどのコマンドごとに異なる環境変数をセットできるようになります。

仕組み

defineEnv.tsの方はただのTypeScriptなのであまり問題ないと思います。

setEnv.jsの方は、TypeScriptではなくJavaScriptで書いていますが、checkJsを使って型チェックを行っています。

具体的には、次のようなtsconfig.jsonを使って、env.*.jsを型チェックしています。 allowJsを有効化していますが、通常はなんでも.jstscでは扱いたくないので、env.*.jsだけをincludesに指定しています

{
  "compilerOptions": {
    // ....
    // Type Check for env.*.js
    "allowJs": true,
    "checkJs": true
  },
  "include": [
    "**/*.ts",
    "**/*.tsx",
    // allow to check js files
    "./src/env/setEnv.js",
    "env.*.js"
  ],
  "exclude": [
    ".git",
    "node_modules"
  ]
}

setEnv.jsの方は、JSDocを使って受け取れる環境変数の型を定義しているのでcheckJsが有効になっていると型チェックが行われます。

import process from "node:process";

/**
 * set env util
 * @param {Partial<typeof import("./env").env>} env
 */
export const setEnv = (env) => {
    if (process.env["PRINT_ENV"] === "true") {
        console.table(env);
    }
    Object.entries(env).forEach(([key, value]) => {
        process.env[key] = value;
    });
};

FAQ

.envの代わりにenv.*.jsを使う理由

.envファイルは型安全ではないです。 env.*.jsはTypeScriptのcheckJs機能で型チェックされるため型安全です。

Node.js は--env-fileフラグで.envファイルを読み込むことができます。

しかし、NODE_OPTIONS="--env-file=.env"は許可されていません。

env.*.tsの代わりにenv.*.jsを使う理由

Node.js の--experimental-strip-typesはまだ実験的な機能です。

ts-nodeやtsxなどを使えば.tsでも書けますが、あえてTypeScriptの変換を入れるほどでもないのでenv.*.jsを使っています。

直接--importフラグを使わずにNODE_OPTIONSを使う理由

pnpmのようなパッケージマネージャはパッケージのbinをシェルスクリプトとしてインストールします。

例えば、node_modules/.bin/viteはシェルスクリプトとしてインストールされます。

そのため、nodeコマンドを使ってviteコマンドを実行できません。

node --import=./env.local.js node_modules/.bin/vite
# エラーになる

NODE_OPTIONS=optionsを使うことで、Node.jsプロセスにオプションを渡すことができます。

NODE_OPTIONS='--import ./env.local.js' node_modules/.bin/vite
# これなら動く

まとめ

Node.js で型安全な環境変数を扱うスニペットを作りました。

  • defineEnv関数で環境変数の型を定義
  • setEnv関数で環境変数をセット
  • NODE_OPTIONSenv.*.jsを読み込むことで、コマンドごとに異なる環境変数をセット

50行ほどのスニペットですが、環境変数を型安全に扱うことができるので結構便利でした。

参考