これは、なにをしたくて書いたもの?
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.
管理上ファイル別に分割しても、最終的には統合して使うことを想定しているようです。
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
)、変数(let
、var
)に対しても使えますが、いずれも
定義のみで実装(初期値も)を持つことはできません。
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における「モジュール」はトップレベルにimport
やexport
を含むものを指すようです。
In TypeScript, just as in ECMAScript 2015, any file containing a top-level import or export is considered a module.
TypeScript: Documentation - Modules
トップレベルにimport
やexport
の宣言がないものは「スクリプト」と呼ぶようです。
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; }
そして、ProcessEnv
インターフェースの定義はNodeJS
名前空間内に含まれています。
namespace NodeJS {
NodeJS
名前空間は他の場所にも宣言があり、Node.jsの型定義内でもNodeJS
名前空間がマージされていることがわかりますね。
declare namespace NodeJS {
実際に参照している変数は、というと。
env
はこちらに定義があります。
env: ProcessEnv;
ではprocess
はというと、以下あたりに宣言されています。
global { var process: NodeJS.Process;
declare var process: NodeJS.Process;
特になにもimport
していないのにいきなりprocess.env
と入力して型の情報が解決できるのはこれらの宣言があるからで、
今回はこの宣言内に自分で宣言した内容がマージされたことになりますね。
というわけで、およそ追えたのではないかなと思います。
参考)
グローバル変数の宣言 - TypeScript Deep Dive 日本語版
global.d.ts(globals.d.ts)
こういった情報を調べていると、よくglobal.d.ts
(globals.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という言語自体を見ないといけないな、という気分になりました。