🗺️

詳細解説 TypeScript の真のグローバル変数の書き方

に公開

TypeScript/JavaScript のグローバル変数だと思っていたものは実は ファイル スコープ の変数だった

JavaScript や TypeScript では トップ レベル で変数宣言すると、その変数はグローバル変数になる感覚がありますが、厳密には ファイル スコープ(または モジュール スコープ)の変数になります。 ファイル スコープ ですが、import すれば、どこからでも参照することができるので実質グローバル変数として扱うことができます。 なお、ES modules の JavaScript や TypeScript であれば import ですが、CommonJS のそれであれば require です。

ES modules の TypeScript:

import gDefaultSize from './lib';

console.log(gDefaultSize);

CommonJS の JavaScript:

const { gDefaultSize } = require('./lib.js');

console.log(gDefaultSize);

import した変数に値を上書き代入することができません。 これは仕様です。

💡 import した変数に値を上書き代入できないことは、「静的な import 宣言は、他のモジュールによってエクスポートされた read-only の動的バインドをインポートするために使用します。」(MDN)と書いてあるように、import の仕様になっています。 ちなみに CommonJS では require の返り値を var で宣言して変数に代入した場合、代入先の変数は read-only ではありませんが、ファイル スコープ の変数の値を変更することは ES modules 同様にできません。

しかし、真のグローバル変数なら、上書き代入することができます。

ではどのようにグローバル変数を書けばいいでしょうか。 詳しい方なら聞いたことがあるのかもしれませんが、一言でいえば globalThis オブジェクトを使えばいいのです。 しかし、それだけで「すべてを理解した」つもりになっていたら、いろいろとハマることになるでしょう。 本記事では、JavaScript や TypeScript でどのようにグローバル変数を書くかについていくつかのパターンを説明します。 初期値の依存関係の解決方法も解説しています。 Jest を使う場合に特有の問題にも対応しています。

globalThis.gDefaultSize = 100;  // これだけ知っていても不十分であるし、実際こう書くべきではない

グローバル変数は アンチ パターン か

グローバル変数を使うな、という単純な考えで多くを台無しにする人がいますが、React の Context や Redux のように実質グローバル変数のようにどこからでも参照できるものは存在しますし、import を使えばどこでも参照できるので、ファイル スコープ の変数もグローバル変数と見てよいでしょう。

ただし、注意点があります。 よく言われるのがどこで変更されたのかが分からなくなるのではないかということです。 しかし、その問題はすでに対策されています。 さらに、グローバル変数であっても誰かが勝手に変更されることを避けることができます。 問題は別にあります。 グローバル変数の注意点や対処法の詳細は、本記事の後半を参照してください。 グローバル変数がすべて悪いわけではなく、適材適所に使っていくことで、アプリケーションのデータ構造の全体像が把握できるようになります。

動作確認用プロジェクト

本記事に書かれているコードは、こちら にあるプロジェクトで動作確認ができます。 執筆時点では Node.js 20.19.0 をインストールして動作確認しています。

グローバル変数の定義、宣言、初期化

グローバル変数の定義を書くには(宣言して初期化するには)、次のように書きます。

TypeScript の書式:

declare global {export const  __Variable__: __Type__} (globalThis as any).
__Variable__ = __InitialValue__;

TypeScript のサンプル1:

declare global {export const  gDefaultClientName: string} (globalThis as any).
gDefaultClientName = "user1";

TypeScript のサンプル2: (1b.global.ts)

declare global {export const  gDefaultSize: number} (globalThis as any).
gDefaultSize = 100;
  • 定義の前半は 1行で書くことを強く推奨します。定番ですし、declare global const と書いてありますから、なんとなく読めます。 詳しくは「グローバル変数の構造」の章で説明しています
  • 変数名は g から始めることを強く推奨します、import にあるという法則の対象外であることを、すぐに分かるようにするためです

💡 C言語全盛の時代から g_ から始める コーディング ルール が有名ですが、その慣習的なルールを踏襲して g から始めることを推奨します。 JavaScript は基本キャメルケースなのでアンダースコアは付けませんが、それでもグローバル変数であることは簡単に識別できます。 必ず globalThis. から書く コーディング ルール も考えられますが、グローバル変数の名前より長くて目立ってしまうため、あまり可読性がよくありません。

  • ファイル名(モジュール名)またはそれに固有の名前を変数名にすると、他のファイル(モジュール)と衝突することがほぼ無くなります
  • ライブラリにするタイミングで、グローバル変数を ファイル スコープ 変数 に変えます。 アプリケーションは衝突に関して深く考える必要はありません。 衝突したら簡単に修正できます

declare global でエラーになる場合

declare global 以外に 1つも export が無いと declare global の global でエラーになります。
エラーを回避するためには、export {} も書いてください。

7c.importGlobal.ts

declare global {export const  gDefaultTimeOut: number} (globalThis as any).
gDefaultTimeOut = 500;

export {}  // 1つも export が無いときに必要

グローバル変数の値や定義の参照

他のファイルで定義されたグローバル変数の値を取得するために、事前に「変数名」を import する必要はほとんどありません。 ただし稀にファイルを import する必要が出てくることがあります。 グローバル変数の名前が見つからないエラーになったらファイルの import を書いてください。 特に Jest を実行する場合は一部の ソース ファイル しかロードしないため、Jest を実行する場合だけ名前が見つからないエラーになることがあります。

main.ts:

import './lib'  // グローバル変数が見つからないときのみ書く

function  main() {
    console.log(gDefaultLength);
}

lib.ts:

declare global {export const  gDefaultLength: number} (globalThis as any).
gDefaultLength = 100;

下記のサンプルでは、OtherClass だけを import していますが、同時に gDefaultLength グローバル変数も使うことができるようになります。 一般にグローバル変数だけを参照することは少ないので、グローバル変数の定義があるファイルだけを import するコードを書くことは、ほとんどありません。

main.ts: (7a.importGlobal.ts)

import { OtherClass } from './lib';  // グローバル変数も使えるようになる

function  main() {
    console.log(gDefaultLength);
    const  object = new OtherClass();
}

lib.ts: (7b.importGlobal.ts)

declare global {export const  gDefaultLength: number} (globalThis as any).
gDefaultLength = 100;

export interface  OtherClass {
    ____
}

グローバル変数の定義があるファイルを直接 import する必要もありません。 上記の場合 main.ts → lib.ts という import ですが、main.ts → sub.ts → lib.ts という import になっていれば、main.ts には sub.ts の import だけ書けばよいです。 このため、一般的な import に比べて書く必要が出てくることは、ほとんどありません。

main.ts: (7a.importGlobal.ts)

import './sub'

function  main() {
    console.log(gDefaultTimeOut);
}

💡 import 文 に識別子 gDefaultTimeOut が書かれていないことが、可読性が下がる要因だと考えるかもしれません。 そのため、グローバル変数の名前を g から始めることを推奨します。

定義へジャンプする

VSCode などを使えば、変数の定義にジャンプるすことができます。 Ctrl キー を押しながらグローバル変数名をクリックすれば、定義にジャンプします。 さらに定義に書かれている型から、型の定義にジャンプすることもできます。

main.ts:

    console.log(gDefaultClientName);

Ctrl キー を押しながら gDefaultClientName をクリックすると、

lib.ts:

declare global {export const  gDefaultClientName: User} (globalThis as any).
gDefaultClientName = {
    name: "",
    id: 0,
};

が表示され、さらに Ctrl キー を押しながら User をクリックすると、

lib.ts:

export interface  User {
    ____
}

が表示されます。

grep コマンドでグローバル変数の定義を検索するときは、以下のようなコマンドになります。

grep -rn  "declare.*__Variable__"  "__Project__/src"

グローバル変数へ代入するメソッド

グローバル変数に値を上書き代入するには、値を代入するメソッド(設定するメソッド) Set__Module__.__Variable__(__Value__) を呼び出すようにします。

main.ts: 書式

import { Set__Module__ } from '__Path__';

function  main() {
    Set__Module__.__Variable__(__Value__);  // メソッドでグローバル変数に値を代入します
    __Variable__ = __Value__;  // const なので、代入はエラー
}

lib.ts: 書式

export class  Set__Module__ {
    static  __Variable__(x: __Type__) {
        (globalThis as any).__Variable__ = x;  // デバッグ時は、ここに console.log 呼び出すコードを書くか、ブレークポイントを張ります
    }
}

サンプル:

main.ts: (1a.main.ts)

import { SetBuffer } from './buffer';

function  main() {
    SetBuffer.gDefaultSize(120);  // メソッドでグローバル変数に値を代入します
    gDefaultSize = 120;  // const なので、代入はエラー
}

buffer.ts: (1b.global.ts)

export class  SetBuffer {
    static  gDefaultSize(x: number) {
        (globalThis as any).gDefaultSize = x;  // デバッグ時は、ここに console.log 呼び出すコードを書くか、ブレークポイントを張ります
    }
    static  gDefaultClientName(x: string) {  // 別のグローバル変数へ代入するメソッド
        (globalThis as any).gDefaultClientName = x;
    }
}

Set__Module__.__Variable____Variable__ は、実際はメソッド名です。 メソッド名はグローバル変数名と全く同じにしてください。 そうすることで全文検索するときに代入するコードもヒットします。 一般的には set__Variable__ という名前のメソッドがよく紹介されますが、それでは代入するコードがヒットしなくなる問題があります。 ファイル スコープ の変数に代入するときも、この形式で書くことを推奨します。

上書き代入するときに必ずメソッドを呼び出すようになっていれば、そのメソッドの中で console.log で表示したり、ブレークしたりすれば、値の変化を確実に見つけられます。 グローバル変数を const で定義することで、代入するメソッドが必ず呼ばれるようになります(any型に変えるというチートなことをしない限り)。 とはいえ、オブジェクトのプロパティでも値の変化を見つけるには同様の手順が必要になるため、グローバル変数だからダメという話ではありません

代入するコードの一覧とジャンプ

VSCode でメソッドの定義に書かれているメソッド名を Ctrl キー を押しながらクリックすれば、代入するコードがある場所を一覧できます。 さらにその場所にジャンプすることもできます。 グローバル変数の値を参照するコードは一覧されないので、検索ノイズがありません。

初期値の依存関係を解決する

グローバル変数を扱う上で最もハマるのは、初期値の依存関係です。 リテラルで初期値を書けば依存関係はありませんが、一元性を確保するためなど、別で定義したグローバルな定数を初期値に書くと、依存関係が発生するので、それなりの対応が必要です。

リテラルの場合

リテラルで初期値を書けば依存関係が無いため、特に気をつけることはありません。

declare global {export const  gDefaultUser: string} (globalThis as any).
gDefaultUser = "user1";

オブジェクトの初期値の場合、オブジェクト リテラル の中にリテラルの初期値を書けば依存関係が無いため、特に気をつけることはありません。

declare global {export const  gTarget: Target} (globalThis as any).
gTarget = {
    name: "user1",
    num: 111,
};

export interface  Target {
    ____
}

ただし、リテラルで書くと特別な値を一元化できない問題が発生することがあります。 たとえば、上記 gTarget.name の初期値を gDefaultUser にすれば、デフォルト ユーザー 名 "user1" の記述を gDefaultUser の定義に一元化できます。

同じファイルの場合

下記の場合、gDefaultClientName の初期値は gDefaultUser に依存しています。 依存先の定義を同じファイルに書く場合、依存先である gDefaultUser の定義を上に書かなければなりません。

(3.context.ts)

declare global {export const  gDefaultUser: string} (globalThis as any).
gDefaultUser = "user0";

declare global {export const  gDefaultClientName: string} (globalThis as any).
gDefaultClientName = gDefaultUser;  // gDefaultUser の定義はここより上に書く必要があります

上下を逆に書くとエラーになります。

declare global {export const  gDefaultClientName: string} (globalThis as any).
gDefaultClientName = gDefaultUser;  // ReferenceError: gDefaultUser is not defined

declare global {export const  gDefaultUser: string} (globalThis as any).
gDefaultUser = "user1";

別のファイルの場合

初期値とする変数が他のファイルで定義されている場合、その変数を参照するために import 文を使う必要があります。 これは前述したとおりグローバル変数を使えるように import 文を書くことと同じです。

なお、初期値には、グローバル変数、または ファイル スコープ の定数しか参照できません。(リテラル以外では)

別のファイルと import が循環依存している場合

初期値とする変数(グローバル変数、または ファイル スコープ の定数や変数)が他のファイルで定義されている場合、import 文を使う必要があり、その import に循環依存(circular dependency、循環参照、相互依存)が発生したら、共有部分を分ける編集が必要になります。 A → B に import して B → A に import している場合、値の代入など実行に関する共有部分を C に分けて、A → C に import して B → C に import することで循環依存を無くします。 ただ、これまでに説明したグローバル変数の定義は

  • 宣言と初期化
  • 代入メソッド

に分けることができ、そのうち、代入メソッドは実行前の解析で定義が完了するので共有部分として分ける対象ではありません。 一方で、初期化は実行時に行われるので import が循環依存していたら共有部分として分ける対象になり得ます。

以下で具体的に説明します。 まずは、問題があるコードです。

修正前 a.ts: __VariableA__ の定義

import { __IdentifierB__ } from 'b';

declare global {export const  __VariableA__: __Type__} (globalThis as any).
__VariableA__ = __InitialValueA__;

修正前 b.ts: __VariableB__ の定義

import { __IdentifierA__ } from 'a';

declare global {export const  __VariableB__: __Type__} (globalThis as any).
__VariableB__ = __VariableA__;

実は、初期化自体は循環依存にはなっていません。 __VariableB____VariableA____InitialValueA__ という依存関係なので循環していません。 循環しているのは import しているファイルですa.tsb.tsa.ts という依存関係は循環しています。 import に循環依存があるときのエラーは、変数や関数などの言語レベル(細かいレベル)で循環依存が無くても、ファイル レベル(広いレベル)で循環していることが原因であり、修正すべき部分です。

もし、言語レベルで循環していたら、まずそれを先に修正します。 以下は、ファイル レベル の循環依存だけあるときの対処法です。

共有部分が何であるかは、a.ts でも b.ts でも使われている識別子から判断できます。 上記のサンプルの場合、a.ts が定義していて、b.ts が参照している __VariableA__ です。 なので、__VariableA__ の定義を別の新しいファイルに分けます。 初期値は定数を書くべきなので、新しいファイルの名前の一部には Const を含めるのが良いと思います。 ちなみに、循環依存(circular dependency)という言葉は関係性を表す言葉なので、ファイルの名前に含めると違和感があります。 ファイル名は、ファイルとファイルの関係ではないので。

以下は、問題を解決したコードです。

修正後 a.ts: (8a.circularDependency.ts)

import { __IdentifierB__ } from 'b';
import 'b';  // __IdentifierB__ の種類によって、上記 import が import type の動きをするときは、これを書いてグローバル変数の初期化が必要

修正後 aConst.ts: (8aConst.circularDependency.ts)

import type { __Type__ } from 'a';  // 解析で定義される「型」を参照する必要がある場合は、import type を書きます。 import ではありません
export {}  // 1つも export が無いときなら必要

declare global {export const  __VariableA__: __Type__} (globalThis as any).
__VariableA__ = __InitialValueA__;
    // 書く内容は通常のグローバル変数の宣言と初期値のコードと変わりません

修正後 b.ts: (8b.circularDependency.ts)

import type { __IdentifierA__ } from 'a';
import 'aConst';  // 共有部分を import します

declare global {export const  __VariableB__: __Type__} (globalThis as any).
__VariableB__ = __VariableA__;

うまくいかないときは、import 文の前後に console.log を書いて動きを確認しながら対処します。

参考)グローバル変数の構造

TypeScript の文法の構造を重視したスタイルでグローバル変数の宣言を書くと次のようになります。

TypeScirpt:

declare global {
    export const  __Variable__: __Type__;
}
(globalThis as any).__Variable__ = __InitialValue__;

このスタイルは、TypeScript のグローバル変数の宣言文の構造に準じています。 今、ここで読むと、この書き方のほうが分かりやすいと感じる気持ちは分かりますが、それは構造を理解しようとしているときだからです。 普段は declare global 以外の不要な情報(globalThis as any って何だっけなど)が入らないように、定義の前半を 1行で書くスタイルを強く推奨します。

説明:

  • declare global は、グローバル変数の宣言が始まることを示すキーワードです
  • 1つのファイルの中に declare global のブロックをいくつ書いても構いません。 つまり、ファイルの中のすべてのグローバル変数を 1つの declare global のブロックの中に集める必要はありません
  • export は、どこからでもアクセスできることを示すキーワードです
  • const は、定数としてグローバル変数の値を参照できることを示す修飾子です。 var でも宣言できますが、どこで変更したかが分からない問題が発生してしまうため、const を書きます。 ただし、すぐ下に書いてあるような特殊な書き方をすることで const を無効化してグローバル変数の値を代入することができます
  • __Variable__ は、変数名です。 実際の変数名に書き換えます。 この記述によって、globalThis.__Variable__ でアクセスできるようになります
  • __Type__ は、変数の型です。 実際の型名に書き換えます。 初期値から型推論することができないため、明示的に書く必要があります
  • globalThis は、グローバル変数がすべて入ったオブジェクトです
  • (globalThis as any) は、静的型チェックをしない globalThis です。 any 型に型アサーション(型キャスト)することで、const を無効化してグローバル変数の値を代入することができます。 ただし、この書き方は、初期化時と代入メソッド(後で説明)の中だけに限定します
  • __InitialValue__ は、グローバル変数の初期値です。 これを書かないと初期値は undefined になりますが、型が null/undefined許容型 にならざるを得なくなり、それは扱いづらいので、undefined を初期値にすることは避けます。 初期値のまま変更しなくても問題ない値を代入します

💡 declare global のブロックの最後(globalThis の前)に ; を付けると、1行 1文原理主義者がうざいので、; は書きません。

グローバル変数は アンチ パターン か

グローバル変数は注意して設計しないと扱いづらくなるため、注意点を挙げておきます。 以下では、グローバル変数について説明していますが、JavaScript / TypeScript の ファイル スコープ の変数でも同様に注意が必要です。 グローバルに共有しているデータにアクセスする get〜関数や set〜関数やファイルやデータベースなどにアクセスする関数、環境変数も同様に注意が必要です。

グローバル変数の問題は、グローバル スコープ で参照できることが問題ではありません。 値の変化をグローバルに共有してしまうことが主な問題です。

どこで変更したかが分からないといった問題は既に示したように、グローバル変数に代入するメソッド Set__Module__.__Variable__ で解決しています。 ただ、オブジェクトのプロパティもどこで変更したのかが分からない問題があるため、グローバル変数だから問題だというのは偏見です。

最初からグローバル変数を使わないようにコードを書くことが良いわけではありません。 シンプルなシステムの場合、グローバル変数を扱うほうが簡単です。 多くのミドルウェアではシステムの設定変数があり、それに対応する環境変数(実質グローバル変数)で設定ができたりしています。 しかし、グローバル変数にすべきではないケースも確実にあります。

カレントやアクティブをグローバル変数にするのは悪い設計です。 その一方で、デフォルトはグローバル変数にして問題ありません。 デフォルトは、値を気にしなくてもいい状況では、値を設定したりしないというものです。 もちろん、デフォルトのつもりでも正しく動かないのであればグローバル変数を使わないようにする改修が必要になるかもしれません。 いずれにせよ、グローバル変数は全部禁止だーと雑に大声でぶちかまして、必要になる前に対策を指示して、過剰設計(オーバー エンジニアリング)にならないように注意しましょう。

良いグローバル変数:

  • 定数や不変データ
  • デフォルト
  • 全てのアクティブのコレクション

悪いグローバル変数:

  • カレント
  • アクティブ
  • コンテキスト

呼び出しているメソッドの引数と、それが呼び出しているメソッドの引数と、……と、多くのメソッドに引数を追加していってインターフェースを破壊しまくるより、グローバル スコープ から参照できるようにするほうが、ツリー構造でデータ構造を理解でき、プログラム全体の理解がしやすいです。

💡 グローバル変数を使うとテストができなくなるという主張が一部に見られますが、できなくなる場合もあり得る程度のものです。 データベースはグローバル変数と同様の性質を持っていますが、テストに統合できます。 グローバル変数の性質が気に入らないのか、データベースへのアクセスをしないようにして ユニット テスト を書く人もいますが、それではテスト不十分です。 グローバル変数の性質と向き合う必要があります。 ちなみに、jest のモックを使うか使わないかの設定は、jest.mock() の呼び出しがホイスティングされ、ファイルの先頭で実行されるため、グローバル変数と同じくすべてに影響します。 jest は複数のテストを並列して実行しますが、異なるプロセスで実行されるためグローバル変数であっても影響は受けません。 同じ名前で異なる内容のファイルを扱うと影響は受けます。

戻す処理が必要になる問題

グローバル スコープ で参照できるデータは、どこからでも参照できるので便利で分かりやすいですが、内容は変更しないようにすべきです。 内容が変更しやすい典型的なものは、カレントやアクティブなものを指すグローバル変数です。 何かの処理をするためにアクティブを切り替えてから処理を行う、というのはよくある処理ですが、アクティブがグローバル変数に格納される設定になっていると、処理が終わった後に元々アクティブだった値に戻す処理が必要になってしまう可能性が非常に高いです。 これがグローバル変数の主要な問題点です。 そしてこれに気づくと、全てのグローバル変数に対して戻す処理が必要になってしまうのではないかと考えてしまうかもしれませんが、実際はカレントやアクティブなものを指すグローバル変数だけです。 デフォルト値は戻す必要ありません。

2-NG.return.ts

declare global {export const  gCurrentPower: number} (globalThis as any).
gCurrentPower = 1;

function  main() {
    liftUp();  // gCurrentPower == 1
    sub();
    liftUp();  // gCurrentPower == 1
}

function  sub() {
    const  oldPower = gCurrentPower;

    gCurrentPower = 2;
    liftUp();  // gCurrentPower == 2

    gCurrentPower = oldPower;  // このように、戻す処理が必要になる
}

function  liftUp() {
    console.log(`liftUp: power=${gCurrentPower}`);
}

このような処理が必要になったときは、後で説明するグローバル変数を使わないようにする改修が必要になったと判断してよいでしょう。

このような戻す処理をしなくても、毎回設定すれば良いと考えることもできますが、それではグローバル変数にする意味がありません。 かといって、グローバル変数のまま使い続けるのも良くありません。 結局、グローバル変数を使わないようにする改修が必要になります。

💡 ハードウェアなどリソースが有限のものをモデリングしたオブジェクトの場合、グローバル変数を使わないようにすることができないことがあります。

不変データを設計する(イミュータブル データ モデリング)

グローバル変数の問題は、グローバル スコープ で参照できることが問題ではありません。 変化する値をグローバルに共有してしまうことが主な問題です。 なので、グローバルに値を共有しても定数であればグローバルに共有しても問題ありません

export const  bufferSize01 = 100;  // OK
export const  bufferSize02 = 102;  // OK
export const  bufferSize03 = 105;  // OK

プログラムを起動するときのオプションも、プログラム実行中は不変データになるので、グローバル変数にしても問題ありません。

9a.programOptions.ts

function  main() {
    parseProgramOptions();
    console.log(`gProgramOptions size: ${gProgramOptions.size}, client: ${gProgramOptions.client}`);
}

9b.programOptions.ts

declare global {export const  gProgramOptions: ProgramOptions} (globalThis as any).
gProgramOptions = {};

export function  parseProgramOptions() {
    commander.program
        .version('0.1.0')
        .option("-s, --size <i>")
        .option("-c, --client <s>", "Client name. --client system")
        .parse(process.argv);

    (globalThis as any).gProgramOptions = commander.program.opts<ProgramOptions>();
}

値が変化するグローバル変数がある場合、変数を定数に設計変更する方法でよくあるのは、時系列データにすることです。 たとえば、ファイル サーバー に エクセル ファイル を置くときに、ファイル名に日時を付けて、日時を付けたファイルは変更しないようにすることです(変更することはルール違反とする)。 他の人が編集中だった場合、内容に矛盾がある中途半端な状態のものを見てしまう可能性がありますが、日時の要素を名前(ID や番号など)に含めることで、中途半端な状態を見てしまうという問題がなくなります。

変化するデータを扱うのではなく、時系列などで分けて不変データを扱うように設計すること、つまり、イミュータブル データ モデリング(設計)をすることで共有できるようになります。

イミュータブル データ モデリング のメリットはざっくり言うと次のものがあります。

  • 状態管理(ReduxなどではStateを直接変更せず、新しいStateを生成)
  • 並行処理(競合状態の回避)
  • 時間旅行デバッグ(過去の状態を保持して再現可能)
  • 履歴追跡(状態の遷移を記録)

デメリットは、

  • コピーを作ることによるメモリー使用量の増加や処理速度の低下
  • 外部キーを使う場合に設計の工夫が必要

コンテキスト オブジェクト に配置する

ある一定の処理の間だけ値が変わらない場合、毎回設定するのではなく、コンテキスト オブジェクト(文脈)に値を格納します

グローバル変数だったものを コンテキスト オブジェクト のプロパティに移動するという、グローバル変数を禁止する人たちが回避策としてよく示される方法です。

💡 サービスをするオブジェクトが コンテキスト オブジェクト を持つようにしてもいいですし、何かのサービスをするオブジェクトのプロパティにコンテキストを構成する値を直接設定してもいいですが、コンテキスト オブジェクト に持つようにしたほうが、グローバル変数にデフォルトがあるものに対応するものとして、分かりやすいと思います。

コンテキスト オブジェクト を定義する場合: (3.context.ts)

export function  main() {
    const  service1 = new Service({power: 110, time: 40, activeUser: user1});

    service1.process();
}

class  Service {
    context: Context;

    constructor(context: Context) {
        this.context = {... context};  // [... ] is shallow copy.
    }

    process() {
        console.log(`${this.context.activeUser.name} のデータを処理中 power: ${this.context.power}, time: ${this.context.time} ...`);
    }
}

interface  Context {
    power: number;
    time: number;
    activeUser: User;
}

オブジェクトのプロパティに直接設定する場合: (4.objectWithContext.ts)

export function  main() {
    const  service1 = new Service({power: 110, time: 40, activeUser: user1});

    service1.process();
}

class  Service {
    power: number;     // コンテキストを構成する値1
    time: number;      // コンテキストを構成する値2
    activeUser: User;  // コンテキストを構成する値3

    constructor(parameters: Parameters = gDefaultParameters) {
        this.power = parameters.power || gDefaultPower;
        this.time = parameters.time || gDefaultTime;
        this.activeUser = parameters.activeUser || gDefaultActiveUser;
    }

    process() {
        console.log(`${this.activeUser.name} のデータを処理中 power: ${this.power}, time: ${this.time} ...`);
    }
}

interface  Parameters {
    power: number;
    time: number;
    activeUser: User;
}

ただ、コンテキスト オブジェクト のプロパティに移動しても、グローバル変数が無くなるとは限りません。 よく使う値であれば、パラメーターを指定せず、グローバル スコープ から参照できるデフォルト値を使うほうが便利です。 一方で、例外的な値をローカルで指定することもあります。 E2E テスト スイート にあるタイムアウト値がその分かりやすいサンプルだと思います。 グローバルかコンテキストかローカルかどれにすべきかではなく、全てがある仕様が最終的に、多くのミドルウェアで採用されています。

すべてのインスタンスのまとめたものはグローバルに配置して良い

グローバル変数の問題は、グローバル スコープ で参照できることが問題ではありません。 ファイルやデータベースはプログラムのどこからでもアクセスできるので データ本体は グローバル スコープ ですが、ファイル名や、レコードの ID などデータを指すものはローカルにしておけば、「戻す処理」が必要になることはありません。 この構造であれば、データがグローバルに配置されていても誰かに勝手に変更されるのではないかと目くじらを立てる必要はありません。

💡 「戻す処理」は必要ありませんが、複数のコンテキストで共有するデータの排他制御は必要です。 データベースのトランザクションと同じです。

グローバル変数をすべて禁止にしてしまうと、データのすべてをグローバルに配置することができなくなって、すべてをローカルに配置しなければならなくなってしまい、非常に不便です。

💡 ハードウェアなどリソースが有限のものをモデリングしたオブジェクトはこの形になります。

改修前: (5-NG.current.ts)

const  gCurrentLifter = {power: 100, time: 333};
liftUp();

gCurrentLifter.power = 110;
gCurrentLifter.time = 444;
liftUp();

改修後: (6.allInstances.ts)

export function  main() {
    liftUp();   // デフォルト値を使う
    liftUp(0);  // index (0) がグローバルでなければ、gCurrentLifters がグローバルでも良い
    liftUp(1);  // 並列動作もできる
}

const  gCurrentLifters = [
    {power: 110, time: 444},  // gCurrentLifters[0]
    {power: 140, time: 666},  // gCurrentLifters[1]
    {power: 100, time: 333},  // gCurrentLifters[2]
];

function  liftUp(index: number = defaultLifterIndex) {
    const  lifter = gCurrentLifters[index];
    console.log(`liftUp power: ${lifter.power}, time: ${lifter.time}`);
}

const  defaultLifterIndex = 2;

まとめ

以上で説明したように、グローバル変数がすべて悪いわけではなく、適材適所に使っていくことで、メイン関数の中の変数だけでなく、グローバル変数からもアプリケーションのデータ構造の全体像が把握できるようになります。

Discussion