⚠️

TypeScriptで、未初期化のインスタンス変数を意図せず参照してしまってもコンパイルエラーにはならない場合がある

2024/03/28に公開

例えば「コンストラクタのメソッド利用で注意すること」という記事でも解説されているとおり、Javaなど他の言語でもありふれた話だと思うのですが、TypeScriptでも油断してハマってしまったので、注意喚起のために共有します。

こちら👇のとおりTypeScriptのIssueにも一応報告しました

...が、Duplicateだったようです。ミスった!ちゃんと検索したはずなのになぁ😞

Uninitialized instance properties can be accessed via initializers · Issue #57984 · microsoft/TypeScript

次のTypeScriptのコードは特にコンパイルエラーを起こしませんが、実行時にthis.aundefinedになり、結果としてTypeError: Cannot read properties of undefined (reading 'toUpperCase')が発生します:

class Example {
  a: string;
  b = this.useA();
  constructor(a: string) {
    this.a = a;
  }

  useA(): string {
    return this.a.toUpperCase();
  }
}

console.log(new Example('example').b);

理由は簡単です。b = this.useA();という行でthis.useA()が呼ばれるのですが、その時点ではthis.aは初期化されていないためundefinedになるからです。

次のように、bの初期化時にaを明示的に参照すれば、コンパイルエラーが発生します。インスタンスメソッドを挟むと、aを使っているかどうか分からなくなってしまうようですね。

class Example {
  a: string;
  b = this.a.toUpperCase();
  constructor(a: string) {
    this.a = a;
  }
}

エラーメッセージ:

Property 'a' is used before its initialization.

対策

次のような、より簡潔なclassの書き方をしていれば、この問題に出遭うことはないでしょう:

class Example {
  b = this.useA();
  constructor(public a: string) {}

  useA(): string {
    return this.a.toUpperCase();
  }
}

console.log(new Example("hello"));

しかし、この方法はいわゆる「hard private」なインスタンス変数では使えません。public a: stringのように、コンストラクターの引数でhard privateなインスタンス変数を宣言する方法がないからです:

class Example {
  b = this.useA();

  constructor(#a: string) {}
  //          ^^ これはエラー!

  useA(): string {
    return this.#a.toUpperCase();
  }
}

というわけで、hard privateなインスタンス変数を使う場合、愚直にコンストラクター内で初期化しましょう:

class Example {
  #a: string;
  b: string;
  constructor(a: string) {
    this.#a = a;
    this.b = this.useA();
  }

  useA(): string {
    return this.#a.toUpperCase();
  }
}

console.log(new Example('example').b);

私が最初にこの問題にハマったのもhard privateなインスタンス変数を使っていたからなので、書いておきました。

余談

😓そもそもこの問題にハマるような設計がまずいのでは?

GitHubで編集を提案

Discussion