CLOVER🍀

That was when it all began.

TypeScriptを使ってNode.jsの環境変数を型定義する仕組みを見て、名前空間と宣言のマージを確認する

これは、なにをしたくて書いたもの?

TypeScriptを使ってNode.jsで環境変数を型定義する方法を調べると、だいたい以下のような記述が見つかるように思います。

declare namespace NodeJS {
  interface ProcessEnv {
    readonly MY_ENV: string;
  }
}

ここでMY_ENVは定義したい環境変数名とします。こちらをglobal.d.tsといったファイルとして作成する、といった感じですね。

なんとなく雰囲気はわかるのですが、結果だけを書いたものがよく見つかるのでこれはどういうことなのかちょっと調べてみることに
しました。

環境

今回の環境は、こちら。

$ node --version
v18.16.0


$ npm --version
9.5.1

TypeScript+Node.jsプロジェクトを作成する

まずはTypeScript+Node.jsのプロジェクトを作成します。

$ npm init -y
$ npm i -D typescript
$ npm i -D prettier
$ npm i -D jest @types/jest
$ npm i -D esbuild esbuild-jest
$ mkdir src test

TypeScriptやテスト用のJestなどをインストール。

Node.jsの型定義情報もインストールします。

$ npm i -D @types/node@v18

追加した依存関係のバージョンは、こちら。

  "devDependencies": {
    "@types/jest": "^29.5.2",
    "@types/node": "^18.16.18",
    "esbuild": "^0.18.4",
    "esbuild-jest": "^0.5.0",
    "jest": "^29.5.0",
    "prettier": "^2.8.8",
    "typescript": "^5.1.3"
  }

設定ファイルは、tsconfig.jsonだけ先に載せておきます。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "moduleResolution": "node",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

テストでの型チェック用。

tsconfig.typecheck.json

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "baseUrl": "./",
    "noEmit": true
  },
  "include": [
    "src", "test"
  ]
}

その他は、また最後に。

環境変数を参照するTypeScriptソースコードを書く

では、お題にあるようにTypeScript+Node.jsで環境変数を参照するソースコードを書いていきましょう。

参照する環境変数は、MY_ENVとします。

$ export MY_ENV=test

まずはこんな定義を書いてみます。

src/lookup-env.ts

export function lookupEnv(): string {
  return process.env.MY_ENV;
}

このソースコードは、コンパイルエラーになります。

$ npx tsc
src/lookup-env.ts:4:3 - error TS2322: Type 'string | undefined' is not assignable to type 'string'.
  Type 'undefined' is not assignable to type 'string'.

4   return process.env.MY_ENV;
    ~~~~~~

src/lookup-env.ts:4:22 - error TS4111: Property 'MY_ENV' comes from an index signature, so it must be accessed with ['MY_ENV'].

4   return process.env.MY_ENV;
                       ~~~~~~


Found 2 errors in the same file, starting at: src/lookup-env.ts:4

仕方ないので、ひとまず以下のようにするとコンパイルが通るようになります。

src/lookup-env.ts

export function lookupEnv(): string {
  return process.env['MY_ENV']!;

  // または
  //return process.env['MY_ENV'] as string;
}

テストコードを書いて確認しましょう。

test/lookup-env.test.ts

import { lookupEnv } from '../src/lookup-env';

test('lookup environemt variable', () => {
  expect(lookupEnv()).toBe('test');
});

このテストは成功します。

$ npm test

> lookup-environmet-variable@1.0.0 test
> jest

 PASS  test/lookup-env.test.ts
  ✓ lookup environemt variable (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.195 s, estimated 2 s
Ran all test suites.

これで環境変数は参照できたわけですが、できれば最初に書いたように以下のような記述をしたいものです。

export function lookupEnv(): string {
  return process.env.MY_ENV;
}

ここで、以下のようなTypeScriptファイルを作成するとprocess.env.MY_ENVという記述のままコンパイルを通すことができます。

src/process.d.ts

declare namespace NodeJS {
  interface ProcessEnv {
    readonly MY_ENV: string;
  }
}

テストも成功します。

$ npm test

> lookup-environmet-variable@1.0.0 test
> jest

 PASS  test/lookup-env.test.ts
  ✓ lookup environemt variable (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.268 s, estimated 1 s
Ran all test suites.

で、これはどういうことなのかを見ていきたいと思います。

以下の定義に向き合っていくことになりますね。

declare namespace NodeJS {
  interface ProcessEnv {
    readonly MY_ENV: string;
  }
}

結論を言うと、Node.jsの型宣言情報で書かれた名前空間とインターフェースの定義とこの記述がマージされていることになります。

名前空間

まず、以下の定義は名前空間の宣言です。

declare namespace NodeJS {

名前空間に関するドキュメントは、こちら。

TypeScript: Documentation - Namespaces

名前空間は過去に「内部モジュール」と呼ばれていたもののようで、TypeScript独自のもののようです。

名前空間の説明を見ると、名前の衝突を避けるための仕組みとなっていますね。

we’re going to want to have some kind of organization scheme so that we can keep track of our types and not worry about name collisions with other objects.

Namespaces / Namespacing

管理上ファイル別に分割しても、最終的には統合して使うことを想定しているようです。

Once there are multiple files involved, we’ll need to make sure all of the compiled code gets loaded.

Alternatively, we can use per-file compilation (the default) to emit one JavaScript file for each input file.

Namespaces / Multi-file namespaces

declareというのはアンビエント宣言というもので、TypeScriptコンパイラーに定義情報を伝えるために使うもののようです。
この記述からはJavaScriptコードは生成されず、実装を持つことができません。

Namespaces / Ambient Namespaces

declare名前空間だけではなく、モジュール、関数、定数(const)、変数(letvar)に対しても使えますが、いずれも
定義のみで実装(初期値も)を持つことはできません。

TypeScript: Documentation - Modules

参考)
- アンビエント宣言(declare) - TypeScript Deep Dive 日本語版 - 型定義ファイル - TypeScript Deep Dive 日本語版

また、型宣言ファイル(d.ts)でトップレベルに宣言するものはdeclareまたはexportで開始する必要があるようです。

たとえば、先程の定義を以下のように変更すると

src/process.d.ts

namespace NodeJS {
  interface ProcessEnv {
    readonly MY_ENV: string;
  }
}

コンパイルエラーになります。

$ npx tsc
src/process.d.ts:1:1 - error TS1046: Top-level declarations in .d.ts files must start with either a 'declare' or 'export' modifier.

1 namespace NodeJS {
  ~~~~~~~~~


Found 1 error in src/process.d.ts:1

名前空間とモジュール

名前空間を見ていると、「モジュール」と似たようなものなのでは?と思うのですが、違いはこちらに書かれていました。

TypeScript: Documentation - Namespaces and Modules

ちなみに、TypeScriptにおける「モジュール」はトップレベルにimportexportを含むものを指すようです。

In TypeScript, just as in ECMAScript 2015, any file containing a top-level import or export is considered a module.

TypeScript: Documentation - Modules

トップレベルにimportexportの宣言がないものは「スクリプト」と呼ぶようです。

Conversely, a file without any top-level import or export declarations is treated as a script whose contents are available in the global scope (and therefore to modules as well).

名前空間とモジュールの違いは、以下の記述が端的なように思います。

Namespaces are simply named JavaScript objects in the global namespace.

Namespaces and Modules / Using Namespaces

名前空間は、グローバルな領域のJavaScriptオブジェクトに名前をつけたもの、ということです。

なお、モジュールとして提供する場合に名前空間は不要だとされています。

Namespaces and Modules / Needless Namespacing

モジュールの時点でスコープが独立しているので、そこからさらに名前空間を使っても煩雑になるだけ、ということのようです。

つまり、一般的には名前空間ではなくモジュールを使うことになります。名前空間は既存のパッケージに型宣言を行う場合に留める
といった使い方になるようです。

宣言のマージ

TypeScriptでは、別々の場所に宣言された内容をひとつにマージする機能があります。これを宣言のマージと呼ぶようです。

TypeScript: Documentation - Declaration Merging

マージされる対象は、以下になります。

名前空間やインターフェースも含まれます。

マージの例はこちら。

// interface
interface Cloner {
  clone(animal: Animal): Animal;
}
interface Cloner {
  clone(animal: Sheep): Sheep;
}

// interface(マージ後)
interface Cloner {
  clone(animal: Dog): Dog;
  clone(animal: Cat): Cat;
}


// namespace
namespace Animals {
  export class Zebra {}
}
namespace Animals {
  export interface Legged {
    numberOfLegs: number;
  }
  export class Dog {}
}

// namespace(マージ後)
namespace Animals {
  export interface Legged {
    numberOfLegs: number;
  }
  export class Zebra {}
  export class Dog {}
}

というわけで、以下はNodeJS名前空間ProcessEnvインターフェースが、別の宣言にマージされていることになります。

declare namespace NodeJS {
  interface ProcessEnv {
    readonly MY_ENV: string;
  }
}

Node.jsの型定義での宣言場所

では、いったいどこの宣言とマージされているのかというわけですが、以下のProcessEnvインターフェースですね。

            interface ProcessEnv extends Dict<string> {
                /**
                 * Can be used to change the default timezone at runtime
                 */
                TZ?: string;
            }

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/66dc15f0144d7e359ecf0046bc38f2e2abcf499e/types/node/v18/process.d.ts#L112-L117

そして、ProcessEnvインターフェースの定義はNodeJS名前空間内に含まれています。

        namespace NodeJS {

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/66dc15f0144d7e359ecf0046bc38f2e2abcf499e/types/node/v18/process.d.ts#L6

NodeJS名前空間は他の場所にも宣言があり、Node.jsの型定義内でもNodeJS名前空間がマージされていることがわかりますね。

declare namespace NodeJS {

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/66dc15f0144d7e359ecf0046bc38f2e2abcf499e/types/node/v18/globals.d.ts#L127

実際に参照している変数は、というと。

envはこちらに定義があります。

                env: ProcessEnv;

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/66dc15f0144d7e359ecf0046bc38f2e2abcf499e/types/node/v18/process.d.ts#L552

ではprocessはというと、以下あたりに宣言されています。

    global {
        var process: NodeJS.Process;

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/66dc15f0144d7e359ecf0046bc38f2e2abcf499e/types/node/v18/process.d.ts#L5

declare var process: NodeJS.Process;

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/66dc15f0144d7e359ecf0046bc38f2e2abcf499e/types/node/v18/globals.d.ts#L27

特になにもimportしていないのにいきなりprocess.envと入力して型の情報が解決できるのはこれらの宣言があるからで、
今回はこの宣言内に自分で宣言した内容がマージされたことになりますね。

というわけで、およそ追えたのではないかなと思います。

参考)
グローバル変数の宣言 - TypeScript Deep Dive 日本語版

global.d.ts(globals.d.ts)

こういった情報を調べていると、よくglobal.d.tsglobals.d.ts)に定義を書く、といった情報を見かけるのですが、
その例がドキュメントに書かれていました。

TypeScript: Documentation - Global .d.ts

調べているとファイル名に特別な意味があるかのようにも見えるのですが、その情報は公式ドキュメントからは見つけられませんでした…。

lib.d.ts - TypeScript Deep Dive 日本語版

global.d.ts - TypeScript Deep Dive 日本語版

その他のファイル

あとは、今回作成した主要なファイルを載せておきます。

ソースコード

src/lookup-env.ts

export function lookupEnv(): string {
  //return process.env['MY_ENV']!;
  //return process.env['MY_ENV'] as string;

  return process.env.MY_ENV;
}

src/process.d.ts

declare namespace NodeJS {
  interface ProcessEnv {
    readonly MY_ENV: string;
  }
}

test/lookup-env.test.ts

import { lookupEnv } from '../src/lookup-env';

test('lookup environemt variable', () => {
  expect(lookupEnv()).toBe('test');
});

package.json

依存関係。

  "devDependencies": {
    "@types/jest": "^29.5.2",
    "@types/node": "^18.16.18",
    "esbuild": "^0.18.4",
    "esbuild-jest": "^0.5.0",
    "jest": "^29.5.0",
    "prettier": "^2.8.8",
    "typescript": "^5.1.3"
  }

scripts

  "scripts": {
    "build": "tsc --project .",
    "build:watch": "tsc --project . --watch",
    "typecheck": "tsc --project ./tsconfig.typecheck.json",
    "typecheck:watch": "tsc --project ./tsconfig.typecheck.json --watch",
    "test": "jest",
    "format": "prettier --write src test"
  },

設定ファイル。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "moduleResolution": "node",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

tsconfig.typecheck.json

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "baseUrl": "./",
    "noEmit": true
  },
  "include": [
    "src", "test"
  ]
}

.prettierrc.json

{
  "singleQuote": true,
  "printWidth": 120
}

jest.config.js

module.exports = {
  testEnvironment: 'node',
  transform: {
    "^.+\\.tsx?$": "esbuild-jest"
  }
};

まとめ

TypeScriptを使ってNode.jsの環境変数を型定義をすることを行ってみつつ、名前空間とモジュールの違いや宣言の宣言のマージといった
内容を確認してみました。

最初は軽い気持ちで調べてみようと思っていたのですが、調べることが次々と出てきてまあ大変でしたが、良い学びにはなったかなと
思います。

もうちょっとTypeScriptという言語自体を見ないといけないな、という気分になりました。