🚀

TypeScriptで.envを脱却する話

2022/09/14に公開約5,400字3件のコメント

mutex CEO の熊澤です。

開発には「環境設定ファイル」がつきものです。プロジェクト直下などに.env という名前のファイルを置いて、コードから参照できるようにします。

.env
KEY1=VALUE1
KEY2=VALUE2
KEY3=VALUE3

最近では、環境別に.env.development や.env.production などのファイルを作って運用する形態も増えています。

弊社では、TypeScript+Node.js によるバックエンド開発をしていますが、node では process.env で環境変数を参照します。しかし、process.env の型は 以下のように定義されており、型付けが弱く、補完が効かないという難点があります。また、string しか表現できないため、数値などはパースする必要があるのも面倒です。

@types/node
interface ProcessEnv {
    [key: string]: string | undefined;
}

本稿では、環境設定ファイルを TypeScript ベースで書き、型付け可能にする方法を提案します。

https://zenn.dev/miruoon_892/articles/365675fa5343ed
https://www.typescriptlang.org/docs/handbook/decorators.html#introduction

1. 対象読者

  • TypeScript+Node.js+AWS でバックエンド開発をしている方
  • TypeScript の Decorator 機能を使用している方

2. 満たすべき要件

さて、環境変数の ts 化にあたり、満たすべき要件は以下の通りです。

  • 簡潔性
    記述に際して、.env ファイルと比べてそれほど記述量が多くならないこと
  • 実行可能性
    ランタイムで実行エラーを吐かないこと。具体的には、実行時に環境変数が最初に格納され、必ず定義した環境変数が存在する状態であること。(言い換えると、環境変数が格納されるより前に環境変数を参照することがないこと)

実は、二つ目の実行可能性を実現するのに、TypeScript の Decorator 機能が効いています。それについては後ほど説明します。

3. 完成形

実装の説明の前に、完成形をみていただきましょう。環境変数の設定ファイルは次のようになります。

export class Env extends BaseEnv {
  // 環境ごとに変数を設定することができる
  @EnvVar({ local: 'value1', dev: 'value2', stg: 'value3', prod: 'value4' })
  static readonly key1: string;

  // string以外の値も設定することができる
  @EnvVar({ local: 1, dev: 2, stg: 3, prod: 4 })
  static readonly key2: number;

  // 環境に依存しない変数はまとめて設定できる
  @EnvVar({ default: 'default value' })
  static readonly key3: string;
}

どうでしょうか。.env ファイルを環境ごとに複数用意するよりも簡潔に表現できているのではないかと思います。実際の使い方も非常に簡潔です。

console.log(`key1 is ${Env.key1}`)

デコレーターは定義時に実行されるので、Env クラスにアクセスしているということは既にデコレーターの中身が実行済みであり、環境変数は格納されているということになります。そのため、前章で述べた実行可能性を満たすわけです。

では、具体的な実装方法を見て行きます。

4. 基底となる環境変数の定義クラスの用意

プロジェクトに依存しない基底となる環境変数の定義クラスを用意します。以下で実装を説明します。この章の実装は、プロジェクトごとに毎回書く必要がない部分なので、パッケージ化しておくのが良いでしょう。

4.1. 環境を const object でまとめる

環境ごとに環境変数の使い分けをするために、環境を表すオブジェクトを用意します。弊社では次の命名規則をとっています。

  • local...ローカル環境
  • dev...開発環境
  • stg...ステージング環境
  • prod...本番環境

また、このような環境のことを、process.env などの env と区別するために stage(ステージ)と呼ぶことにします。

export const ApplicationStage = {
  local: 'local', // ローカル環境
  dev: 'dev', // 開発環境
  stg: 'stg', // ステージング環境
  prod: 'prod', // 本番環境
} as const;
export type ApplicationStageType = typeof ApplicationStage[keyof typeof ApplicationStage];

4.2. 基底となるクラスの定義

次に、基底となるクラスを準備します。以下が、基底となるクラスです。

export class BaseEnv {
  static get stage(): ApplicationStageType {
    if (process.env.stage == null) {
      throw new Error(`process.env.stage undefined`);
    // process.env.stageがApplicationStageのメンバであることを確認
    } else if (
      this.isEmpty(target) || !Object.values(enumObject).some((value) => target === value)
    ) {
      throw new Error(`process.env.stage is invalid. Set to local, dev, stg, or prod.`);
    }
    return process.env.stage;
  }

  static get isProd(): boolean {
    return this.stage === ApplicationStage.prod;
  }

  static get isStg(): boolean {
    return this.stage === ApplicationStage.stg;
  }

  static get isDev(): boolean {
    return this.stage === ApplicationStage.dev;
  }

  static get isLocal(): boolean {
    return this.stage === ApplicationStage.local;
  }
}

今現在のステージを簡単に確認できるようになっています。
TypeScript がランタイムで現在のステージを知る必要があるので、process.env.stage だけは 4.1 で説明したステージのいずれかが入っていることを前提にします。
そのため、deploy 時には stage のみ環境変数をセットする必要があります。
ts-node 等で実行する際は次のようにします。

stage=local ts-node test.ts

4.3. 環境変数を格納するデコレーター

最後に、実際に環境変数を格納するデコレーターを実装します。

type EnvOptions<T> = {
  [key in ApplicationStageType]?: T;
} & {
  default?: T;
};

export const EnvVar = <T>(options: EnvOptions<T>): PropertyDecorator => {
  if (process.env.stage == null) {
    throw new Error(`process.env.stage undefined`);
  } else if (
    !ValidationUtils.isEnumMember<ApplicationStageType>(process.env.stage, ApplicationStage)
  ) {
    throw new Error(`process.env.stage is invalid. Set to local, dev, stg, or prod.`);
  }

  const stage = process.env.stage;

  return (target: any, propertyKey: string | symbol) => {
    Object.defineProperty(target, propertyKey, {
      set: (_: any) => null,
      get: () => {
        if (options[stage] != null) return options[stage];
        if (options.default != null) return options.default;
        if (
          typeof propertyKey === 'string' &&
          process.env[snakeCase(propertyKey).toUpperCase()] != null
        )
          return process.env[snakeCase(propertyKey).toUpperCase()];
        throw new Error(
          `environment variable ${propertyKey.toString()} is not defined in ${stage} stage`,
        );
      },
    });
  };
};

前章でも説明した通り、実行時に process.env.stage にステージが入ってなければ即座にエラーを吐くようになっています。
また、Env クラスの返す値は次の順に決まるようになっています。

  1. デコレーターの現在の stage に格納されていればその値を返す。
  2. デコレーターの default に格納されていればその値を返す。
  3. process.env に格納されていればその値を返す。
  4. エラーを吐く

.env ファイルではアッパースネークケースを用いるのが一般的であるため、process.env に格納されている値を探しに行くときは Env クラスのプロパティ名を一度アッパースネークケースに変換してから探すようにしています。

5. まとめ

いかがだったでしょうか。私はコーディング中にタイポすることが多いので、補完が効くようになったことは個人的に結構助かっています。タイポが多い方や型付けされていないのが気持ち悪いと感じる方は是非使ってみてください。
TypeScript のデコレーター機能は非常に多様な用途があり、かなり便利な機能だと思っています。また、別の用途も紹介していきたいと思っています。

6. さいごに

不定期にはなりますが、株式会社 mutex はエンジニア向けの記事の継続的な投稿を目指しておりますので、興味のある方はぜひフォローよろしくお願いいたします!

また、mutex の開発に少しでもご興味を持っていただけたら、以下のリンクからお問い合わせいただければ幸いです!

https://www.mutex-inc.dev

Discussion

興味深い記事ありがとうございます!
実際に運用する場合は、

export class Env extends BaseEnv {
~~~

このファイルをgitignoreするイメージで合っていますか?

環境変数は環境によって変わるかもしれない値を差し替えるための機能とすると、.envを置き換えるというのは本質ではないかもしれません。
例えば、自分の環境の開発サーバのポートが被るから他と変えたいときなどです。
本記事は実行時の環境に応じて値を変えたいものを定義するのに有効で、そもそも.envには定義すべき値ではなかったことを示唆しており、.env.developmentや.dev.productionという仕組みがこの間違いを誘発したのではないかと考察できました。

つまり、本当に脱却したいのは.env.developmentであり、.envではなかったのではないかと感じました。

本記事に記載された仕組みはたいへん興味深く、このコメントをそれらを批判するものではなく、この記事を読んで関連する思ふとったことにすぎないです。

ログインするとコメントできます