TypeScript | 関数・クラス・ジェネリクス:クラス設計 – クラスの初期化順序

TypeScript TypeScript
スポンサーリンク

ゴール:「クラスが new されたとき“何がどの順番で動くか”をイメージできるようになる」

クラス設計がうまくいくかどうかは、
「初期化の順番」をちゃんと理解しているかにかなり左右されます。

  • フィールド初期化子(x = 1
  • コンストラクタの処理
  • 継承しているときの super()
  • static とインスタンス

これらがどの順番で動くのかを知らないと、

「なんでここ undefined なんだ…?」
「この時点ではまだ初期化されてないのか…」

みたいな“モヤッとバグ”にハマりやすくなります。

ここでは、TypeScript(=中身は JavaScript)のクラスが
「new された瞬間に何が起きているか」を、
初心者向けに順番でかみ砕いていきます。


まずはシンプルなクラスの初期化の流れ

フィールド初期化子とコンストラクタの関係

一番シンプルなクラスから見てみます。

class User {
  id = 0;
  name = "no name";

  constructor(id: number, name: string) {
    console.log("constructor start");
    this.id = id;
    this.name = name;
    console.log("constructor end");
  }
}

const u = new User(1, "Taro");
console.log(u.id, u.name);
TypeScript

このとき、実際の「初期化の順番」はこうなります。

  1. new User(1, "Taro") が呼ばれる
  2. インスタンスが「空っぽの箱」として作られる
  3. フィールド初期化子 id = 0;name = "no name"; が実行される
  4. そのあとでコンストラクタ本体が実行される(constructor の中身)

つまり、フィールド初期化子は「コンストラクタの前」に走ります。

だから、上の例では最終的に idname はコンストラクタで上書きされます。

ここで大事なのは、

「フィールド初期化子は“デフォルト値”であり、
コンストラクタで上書きされることが前提」

という感覚です。

フィールド初期化子を使わない場合との違い

同じクラスを、フィールド初期化子なしで書くとこうなります。

class User {
  id: number;
  name: string;

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }
}
TypeScript

この場合は、

  1. インスタンスが作られる(プロパティはまだ undefined
  2. コンストラクタ本体が実行される
  3. this.id = id; などで初期化される

という流れになります。

strict モードでは「コンストラクタでちゃんと全部初期化してね」と怒られるのは、
このタイミングの話です。


継承が絡むときの初期化順序

親クラスと子クラスがある場合の流れ

継承が入ると、順番が少し複雑になります。

class Base {
  baseValue = "base field";

  constructor() {
    console.log("Base constructor");
  }
}

class Child extends Base {
  childValue = "child field";

  constructor() {
    console.log("Child constructor start");
    super();
    console.log("Child constructor middle");
    console.log(this.baseValue, this.childValue);
    console.log("Child constructor end");
  }
}

const c = new Child();
TypeScript

このときの流れを、ざっくり言葉で追うとこうなります。

  1. new Child() が呼ばれる
  2. まず「Base 部分」の初期化が行われる
    2-1. Base のフィールド初期化子(baseValue = "base field")が実行される
    2-2. Base のコンストラクタ本体が実行される
  3. 次に「Child 部分」の初期化が行われる
    3-1. Child のフィールド初期化子(childValue = "child field")が実行される
    3-2. Child のコンストラクタ本体の残りが実行される

ここで重要なのは、
「子クラスのコンストラクタの先頭で super() を呼ばないと this が使えない」
というルールです。

class Child extends Base {
  childValue = "child field";

  constructor() {
    // console.log(this.childValue); // ここで this を触るとエラー
    super();
    console.log(this.childValue);   // ここなら OK
  }
}
TypeScript

super() が呼ばれる前は、
「まだ親クラスの初期化が終わっていない」状態なので、
this に触ることが禁止されています。

「継承しているクラスのコンストラクタでは、
必ず super() を一番最初に呼ぶ」
というのは、この初期化順序のルールから来ています。

子クラスのフィールド初期化子は「super() のあと」に走る

もう一つ、よくハマるポイントがあります。

class Base {
  baseValue = "base";

  constructor() {
    console.log("Base:", this.baseValue);
  }
}

class Child extends Base {
  childValue = "child";

  constructor() {
    super();
    console.log("Child:", this.childValue);
  }
}

const c = new Child();
TypeScript

このときのログは、だいたいこんなイメージになります。

Base: base
Child: child

つまり、

  • Base のフィールド初期化子 → Base のコンストラクタ
  • そのあとで Child のフィールド初期化子 → Child のコンストラクタ本体

という順番です。

「子クラスのフィールド初期化子は super() のあとに実行される」
というのを覚えておくと、

「このタイミングではまだ childValue が初期化されてないのか…」

みたいな混乱を避けやすくなります。


static の初期化順序:インスタンスとは別世界

static フィールドと static メソッドは「クラス読み込み時」に初期化される

static はインスタンスとは別のタイミングで初期化されます。

class Example {
  static value = Example.init();

  static init(): number {
    console.log("static init");
    return 42;
  }

  constructor() {
    console.log("constructor");
  }
}

console.log("before new");
const e = new Example();
console.log("after new");
TypeScript

このときのログのイメージは、

static init
before new
constructor
after new

のようになります(実際の順番は環境によって多少違うこともありますが、
「static はクラスが評価されたときに一度だけ」というのがポイント)。

つまり、

  • static フィールドの初期化子
  • static メソッドの定義

は、「クラス定義が読み込まれたタイミング」で一度だけ実行される
(インスタンスを何回 new しても、static の初期化は一度きり)

という性質を持っています。

インスタンスの初期化順序とは完全に別枠なので、
頭の中で「static の世界」と「インスタンスの世界」を分けて考えると整理しやすいです。


初期化順序を意識した安全な書き方

フィールド初期化子の中で「他のフィールド」に依存しすぎない

例えば、こんなコードは危険です。

class Bad {
  a = this.initA();
  b = this.initB();

  private initA(): number {
    return 1;
  }

  private initB(): number {
    return this.a + 1;
  }
}
TypeScript

この場合はまだマシですが、
継承やオーバーライドが絡むと「この時点で本当に初期化済みか?」が
一気に分かりづらくなります。

特に継承関係で、
「親クラスのフィールド初期化子の中から、
オーバーライドされたメソッドを呼んでしまう」
みたいなパターンはかなり危険です。

初心者のうちは、

  • フィールド初期化子は「単純な値」か「コンストラクタ引数だけ」を使う
  • 複雑な初期化ロジックはコンストラクタ本体に寄せる

くらいのルールにしておくと、安全側に倒せます。

コンストラクタの中で「何がもう初期化済みか」を意識する

継承ありのクラスで、子クラスのコンストラクタを書くときは、

  • super() の前:何も触れない(this 禁止)
  • super() の直後:親クラスのフィールドは初期化済み、子クラスのフィールド初期化子はまだ
  • 子クラスのフィールド初期化子のあと:親も子もフィールド初期化子は完了

という3段階をイメージしておくと、
「このタイミングで何に触っていいか」が分かりやすくなります。

実務では、
「コンストラクタの中であまりごちゃごちゃやらず、
必要なら専用の初期化メソッドを呼ぶ」
というスタイルにすることも多いです。


まとめ:クラスの初期化順序を自分の言葉で整理すると

最後に、あなた自身の言葉でこう整理してみてください。

クラスが new されたとき、

インスタンスについては、

  1. インスタンスが作られる
  2. 親クラスのフィールド初期化子 → 親コンストラクタ
  3. 子クラスのフィールド初期化子 → 子コンストラクタ本体

という順番で動く。

static については、

  • クラス定義が評価されたタイミングで、一度だけ初期化される
  • インスタンスの生成回数とは関係ない

という別枠の世界がある。

そして、コードを書くときは、

  • フィールド初期化子は「デフォルト値」としてシンプルに使う
  • 継承クラスのコンストラクタでは、必ず super() を最初に呼ぶ
  • 「このタイミングで本当に初期化済みか?」を意識して設計する

今書いているクラスを1つ選んで、
「このフィールドはどのタイミングで値が入るんだっけ?」
「親子クラスがあるなら、どっちが先に初期化されるんだっけ?」
と、実際に console.log を仕込んで確かめてみてください。

一度「目で見て」順番を体感すると、
クラスの初期化順序は一気に腹落ちして、
そのあとクラス設計がかなり楽になります。

タイトルとURLをコピーしました