TypeScriptでコンストラクタを書くのが面倒すぎる問題と一案

この記事は、TypeScript で Python の dataclasses や Go のメソッド付き構造体みたいなことがやりたいという動機で書かれた。

問題提起

構造体、レコード、あるいはデータクラスなどと呼ばれるものを TypeScript で表現するとき、まず選択肢になるのはオブジェクトリテラルだ。

type Player = {
  id: string;
  hp: number;
  maxHp: number;
};

const player: Player = { id: "hoge", hp: 15, maxHp: 20 };

だがメソッドを使いたいならこの書き方は適さない。

例えば、コード中に player.maxHp - player.hp の計算が頻出することに気づいたとする。この計算を player.damage() とメソッド呼び出しで書ければ書き心地が良さそうだ。

そのためにはクラスを宣言することになる。しかし……

class Player {
  id: string;
  hp: number;
  maxHp: number;

  constructor (props: {
    id: string;
    hp: number;
    maxHp: number;
  }) {
    this.id = props.id;
    this.hp = props.hp;
    this.maxHp = props.maxHp;
  }

  damage() {
    return this.maxHp - this.hp;
  }
}

const player = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player.damage()); // 5

クラスにすると随分と記述量が増えてしまった。

(コンストラクタ引数は positional arguments ではなくオブジェクトにまとめて受け取る。なぜなら hpmaxHp の順番を気にしたくないからだ)

クラス定義中の各インスタンスフィールド名 (hpmaxHp) の出現回数に注目してほしい。

  • (1) フィールドの型注釈
  • (2) コンストラクタ引数の型注釈
  • (3) 代入の左辺
  • (4) 代入の右辺

なんと1つのフィールドにつき最低4か所に同じ識別子を書いている。これは冗長すぎないか?

要するに、すべてのフィールドをコンストラクタ引数で初期化するクラスを TypeScript で作るとコンストラクタを書くのが面倒なのである。

これをいかに楽にするかが本記事のテーマだ。

提案

抽象クラス(のコンストラクタ)Struct を次のように定義する。

const Struct = class {
  constructor(props: object) {
    Object.assign(this, props);
  }
} as abstract new <P extends Record<string, unknown>> (props: P) => P;

Structはコンストラクタ引数をインスタンスにコピーするだけのクラスだ。したがって、これを継承すれば派生クラスにはコンストラクタを書かなくて良い。

上記のPlayerクラスもこの通り↓

class Player extends Struct<{
  id: string;
  hp: number;
  maxHp: number;
}> {
  damage() {
    return this.maxHp - this.hp;
  }
}

const player = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player.damage()); // 5

綺麗に書けた。もちろん型によるコード補完も効く。勝ったな。

型安全?

壊そうと思えば……

class Foo {
  f() {}
}

class Bar extends Struct<{
  f: () => void
}> {}

const foo = new Foo();
const bar = new Bar(foo);
bar.f(); // 実行時型エラー! bar.f is not a function 

このように Object.assign でコピーできないプロパティが混入すると壊れる。

とはいえ、コンストラクタ引数に与えるのが「オブジェクトリテラル」か「同じクラスのインスタンス」である限り大丈夫なので、実用上は困らないと思う。

型の解説

定義を再掲:

const Struct = class {
  constructor(props: object) {
    Object.assign(this, props);
  }
} as abstract new <P extends Record<string, unknown>> (props: P) => P;
  • as(型アサーション)で無理やり型を上書きしている。
    • class 宣言 で定義したクラスの型を後から捻じ曲げる方法を私は知らないので、代わりにclass 式というあまり見ない構文を利用した。
  • 型変数 P は object literal type で具体化されることを想定している。
    • P が class や interface で具体化されるのは望ましくない。この制約は P extends Record<string, unknown> によって実現するが、如何なる原理でそうなるのか私は把握していない。

代替案

提案したStructを使わなくとも、データクラスの類を多少楽に書く方法はある。

案0: Parameter Properties

TypeScriptには parameter properties という構文がある。

class Player {
  constructor (
    public id: string,
    public hp: number,
    public maxHp: number,
  ) {}

  damage() {
    return this.maxHp - this.hp;
  }
}

const player = new Player("hoge", 15, 20);  // positional arguments
console.log(player.damage()); // 5

シンプルに書けた。だがコンストラクタ引数が positional arguments になることに留意してほしい。

思うに、フィールドが多い場合や、同じ型のフィールドがある場合は苦しいのではないか。私は既に maxHphp の順番がわからない。

案1: コンストラクタを Object.assign で書く

type PlayerProps = {
  id: string,
  maxHp: number,
  hp: number
};
class Player implements PlayerProps {
  id!: string;
  maxHp!: number;
  hp!: number;

  constructor(props: PlayerProps) {
    Object.assign(this, props)
  }

  damage() {
    return this.maxHp - this.hp;
  }
}

const player = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player.damage()); // 5

普通に書くよりは楽か。implements を使うので typo の心配もない。しかしフィールドに ! (non-null assertion) を使う必要があるのが嬉しくない。

案2: メソッドを諦める

コンパニオンオブジェクトパターンという選択肢がある。

type Player = {
  id: string;
  maxHp: number;
  hp: number;
};

const Player = {
  damage(player: Player) {
    return player.maxHp - player.hp;
  }
}

const player: Player = { id: "hoge", maxHp: 20, hp: 15 };
console.log(Player.damage(player));

これで十分な場合も多いのではないか。メソッドが使えないから、メソッドチェーンしたいとか、オブジェクトが何らかのインターフェースを満たす必要があるとか、メソッドじゃないと書き心地が悪いとか、そういった用途には適さない。

デフォルト値が欲しい

コンストラクタ引数の一部を省略可能にしたい。さらに、省略されたフィールドは undefined ではなくデフォルト値で初期化されるようにしたい。可能だろうか?

先ほどの Player クラスで maxHp を省略したときにデフォルト値 100 で初期化するという例題を考える。

意外と厄介な問題だ。残念ながら次の書き方はできない↓

class Player extends Struct<{
  id: string;
  hp: number;
  maxHp?: number;
}> {
  maxHp = this.maxHp ?? 100;  // any
}

const player1 = new Player({ id: "hoge", hp: 15 });
const player2 = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player1.maxHp, player2.maxHp);  // 100, 20

パブリックインスタンスフィールドの初期化子は基底クラスの初期化後に評価されるため JavaScript としては正しい。しかし TypeScript では型がつかない。

なお thissuper に変えると型チェックが通るが、super からはインスタンスフィールドを参照できないため動かない。型チェックが通ってしまうのがタチが悪いな。

案A: 諦めてコンストラクタを書く

class Player extends Struct<{
  id: string;
  hp: number;
  maxHp: number;
}> {
  constructor(props: {
    id: string;
    hp: number;
    maxHp?: number;
  }) {
    super({ ...props, maxHp: props.maxHp ?? 100 });
  }
}

const player1 = new Player({ id: "hoge", hp: 15 });
const player2 = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player1.maxHp, player2.maxHp);  // 100, 20

お上品なコードだ。最低2か所に同じフィールド名を書く必要があるので気に入らないが、それでも Struct なしよりは楽かもしれない。

案B: 頑張る

コンストラクタを書くだと? 冗談じゃない。本気を出せばこの程度……

const Struct = class {
  constructor(props: object) {
    Object.assign(this, props);
  }
  get $() {
    return this;
  }
} as abstract new <P extends Record<string, unknown>> (props: P) => P & { get $(): P };

class Player extends Struct<{
  id: string;
  hp: number;
  maxHp?: number;
}> {
  maxHp = this.$.maxHp ?? 100;  // 外から見ても undefined を含まない number 型になる
}

const player1 = new Player({ id: "hoge", hp: 15 });
const player2 = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player1.maxHp, player2.maxHp);  // 100, 20

どうだ!?

まあいいのだが、$ なる謎の getter メソッドが生えることで可読性を損なっている。上品な書き方ではないな。

案C: デフォルト値付き構造体クラスを生成する

function StructWithDefaults<D extends object>(
  defaults: () => D
): new <P extends D>(props: Omit<P, keyof D> & Partial<D>) => P {
  return class StructWithDefaults {
    constructor(props: object) {
      Object.assign(this, props);
      const t = this as Record<string, unknown>;
      for (const [key, value] of Object.entries(defaults())) {
        t[key] ??= value;
      }
    }
  } as never;
}

class Player extends StructWithDefaults(() => ({
  maxHp: 100
}))<{
  id: string,
  hp: number,
  maxHp: number,
}> {}

const player1 = new Player({ id: "hoge", hp: 15 });
const player2 = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player1.maxHp, player2.maxHp);  // 100, 20

なんだこれは。正気か?

ということで、省略可能なフィールドにデフォルト値を設定するのは不可能ではないが、流暢に書くのは少々難しいという結論になった。うまくいかないものだな

以上

抽象クラス Struct を定義してすべてのフィールドをコンストラクタ引数で初期化するクラスを楽に書く方法を提案した。もしよければ使ってみてくださいね。

参考記事

ここまで述べた内容は下記記事をリスペクトして発展させたものだ。

やっていることは同じだが、あちらは Structコンストラクタを返す generic な無引数関数 である(extends Struct<{}>()と書く)のに対して、こちらは Struct が案Cを除いて generic なコンストラクタである (extends Struct<{}>と書く)点が異なる。