0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScript / TypeScriptのstatic:クラス側の責務を設計する

0
Posted at

static は、メソッドやフィールドをインスタンスではなくクラスオブジェクト側へ定義する構文です。「どの個体にも属さず、クラスという名前から呼ぶ責務か」を表せます。

実務上の結論を先にまとめます。

  • 個体ごとに異なる状態や振る舞いはinstance member、生成前に呼ぶ操作やクラス全体の定数はstatic memberに置く
  • static method内の this は、定義元ではなく呼び出しに使ったクラスになるため、継承時は動的に変わる
  • mutableなstatic fieldは実質的な共有グローバル状態であり、テスト、並行処理、SSR、Hot Reloadで問題になりやすい
  • 状態を持たない処理は、static methodより通常のexport関数の方が単純な場合が多い
  • static factoryは生成意図を名前で表すのに有効だが、非同期処理や複数実装では独立したfactoryも比較する
  • private static fieldは継承先へ「protectedな共有状態」として引き継がれるわけではない

この記事ではclass object側の設計に集中します。constructorの初期化責務、new の生成規則、prototypeによるinstance method共有、DIによる依存の組み立ては別の論点です。

instance memberとの配置の違い

次のclassには、三種類の配置があります。

class User {
  static readonly serviceName = "user-service";

  constructor(public readonly name: string) {}

  greet(): string {
    return `Hello, ${this.name}`;
  }

  static createGuest(): User {
    return new User("Guest");
  }
}

const user = new User("Taro");

console.log(user.name);          // Taro
console.log(user.greet());       // Hello, Taro
console.log(User.serviceName);   // user-service
console.log(User.createGuest()); // User { name: 'Guest' }

概念的な配置は次のようになります。

User(クラスオブジェクト)
├─ serviceName
├─ createGuest()
└─ prototype
   └─ greet()

user(インスタンス)
├─ name
└─ [[Prototype]] ──> User.prototype

user.createGuest() は呼べず、User.greet() も呼べません。staticかどうかはアクセス制御の強さではなく、処理の受け手がクラスか個体かを決めています。

判定するときは、「この処理に特定インスタンスの状態が必要か」を最初に問います。必要ならinstance methodです。不要でも、自動的にstaticにする必要はありません。classの概念と密接ならstatic、独立した変換なら通常関数、外部依存を持つなら注入可能なオブジェクト、と比較します。

staticはクラス評価時に定義される

instance fieldはインスタンス生成のたびに初期化されますが、static fieldはclass定義が評価されたときに一度初期化されます。

let sequence = 0;

class Ticket {
  static category = "support";
  static initialSequence = ++sequence;

  id = ++sequence;
}

console.log(Ticket.initialSequence); // 1

const first = new Ticket();
const second = new Ticket();

console.log(first.id);  // 2
console.log(second.id); // 3

static initializerはインスタンスを作らなくても実行されます。そのため、モジュールimport時にclassが評価されるだけで重い計算、通信、ログ送信などが起きる設計は避けます。importの副作用は起動順序やテスト分離を難しくします。

同じclass内ではstatic fieldが上から順に初期化されるため、前に定義したstatic memberを後続の初期化式から参照できます。

class Limits {
  static minimum = 1;
  static maximum = Limits.minimum * 100;
}

console.log(Limits.maximum); // 100

宣言順へ依存する複雑な式は、並べ替えだけで壊れます。単純な定数関係を超えるなら、明示的な関数や初期化処理へ分けた方が読みやすくなります。

static methodのthisは呼び出したクラス

static method内の this はインスタンスではなく、method呼び出しのreceiverとなったクラスオブジェクトです。継承すると、この違いが表面化します。

class Model {
  static tableName = "models";

  static describe() {
    return `table: ${this.tableName}`;
  }
}

class UserModel extends Model {
  static tableName = "users";
}

console.log(Model.describe());     // table: models
console.log(UserModel.describe()); // table: users

describeModel から継承されていますが、UserModel.describe() のreceiverは UserModel です。そのため this.tableName は派生側の値を読みます。

定義元へ常に固定したい処理なら、this ではなくclass名を明示します。

class Protocol {
  static version = "1";

  static baseVersion() {
    return Protocol.version;
  }
}

ただし、class名を直接参照すると派生classによるカスタマイズは効きません。どちらが正しいかは、polymorphicに上書きさせたい契約か、定義元固有の定数かで決まります。何となく this を使うと、継承後に意図しない値へ切り替わります。

static methodを変数へ切り離す場合もreceiverを失います。

const describe = UserModel.describe;

// describe(); // TypeError: this is undefined(class methodはstrict mode)
console.log(describe.call(UserModel)); // table: users

頻繁に単独コールバックとして渡す処理なら、this に依存しない関数として設計する方が安全です。

static側の継承を理解する

class Child extends Parent は、instance method側だけでなくclass object側にもprototype関係を作ります。そのため、ChildはParentのpublic static memberを探索できます。

class Parser {
  static encoding = "utf-8";

  static parse(text) {
    return text.trim();
  }
}

class CsvParser extends Parser {}

console.log(CsvParser.encoding);      // utf-8
console.log(CsvParser.parse(" a,b ")); // a,b
console.log(Object.getPrototypeOf(CsvParser) === Parser); // true

継承されたstatic fieldは、Child自身へコピーされたわけではありません。Parentからprototype chain経由で読んでいます。

console.log(Object.hasOwn(CsvParser, "encoding")); // false

CsvParser.encoding = "shift_jis";

console.log(Object.hasOwn(CsvParser, "encoding")); // true
console.log(CsvParser.encoding); // shift_jis
console.log(Parser.encoding);    // utf-8

代入後はChildのown propertyがParentの値をshadowします。プリミティブならこの挙動は分かりやすい一方、mutable objectを継承すると注意が必要です。

class Repository {
  static cache = new Map();
}

class UserRepository extends Repository {}
class TaskRepository extends Repository {}

UserRepository.cache.set("1", { name: "Taro" });

console.log(TaskRepository.cache.has("1")); // true

両サブクラスが同じMapを継承しているため、変更が共有されます。クラスごとのキャッシュが必要なら、各派生classでstatic fieldを明示するか、class外のcache managerへ責務を分けます。継承を「値がコピーされる仕組み」と考えないことが重要です。

superで基底側のstatic処理を呼ぶ

派生classのstatic methodやstatic blockでは、super から基底classのstatic memberへアクセスできます。

class JsonParser {
  static parse(text) {
    return JSON.parse(text);
  }
}

class UserParser extends JsonParser {
  static parse(text) {
    const value = super.parse(text);
    if (typeof value.name !== "string") {
      throw new TypeError("name is required");
    }
    return { name: value.name.trim() };
  }
}

console.log(UserParser.parse('{"name":" Taro "}'));
// { name: 'Taro' }

super.parse は基底側からmethodを見つけますが、その呼び出しの this は派生classのままです。基底methodが this.someStaticField を読む設計なら、派生側の値が使われます。継承フックとして便利な一方、基底と派生の暗黙契約が増えるため、単純な変換処理なら関数合成も比較します。

named constructorとしてのstatic factory

static methodの代表的な用途は、生成方法へ名前を付けることです。

class Duration {
  private constructor(readonly milliseconds: number) {
    if (!Number.isFinite(milliseconds) || milliseconds < 0) {
      throw new RangeError("duration must be a non-negative finite number");
    }
  }

  static fromSeconds(seconds: number): Duration {
    return new Duration(seconds * 1_000);
  }

  static fromMilliseconds(milliseconds: number): Duration {
    return new Duration(milliseconds);
  }
}

const timeout = Duration.fromSeconds(5);
console.log(timeout.milliseconds); // 5000

new Duration(5) では単位が分かりませんが、factory名なら呼び出しが自己説明的です。private constructorと組み合わせると、外部の生成経路をfactoryに限定できます。

ただし、static factoryは必ず新しい同classインスタンスを返す必要はありません。Promise、Result、別実装、キャッシュ済み値も返せます。戻り値型を明記し、呼び出し側が名前だけで「必ず新規」と誤解しないようにします。

継承可能なfactoryでは new this() を使う実装もあります。

class Record {
  static create() {
    return new this();
  }
}

class UserRecord extends Record {}

console.log(UserRecord.create() instanceof UserRecord); // true

短い例では便利ですが、派生constructorが必須引数を追加すると契約が崩れます。TypeScriptで正確に型付けするにもconstructorの this 型が必要になり、APIが複雑になります。派生可能性が実要件でなければ、class名を明示したfactoryの方が堅牢です。

private static fieldは継承先の共有privateではない

# で宣言したprivate static fieldは、宣言したclassだけが参照でき、実行時にもbrand checkが行われます。

class Token {
  static #prefix = "app";

  static create(value) {
    return `${Token.#prefix}:${value}`;
  }
}

console.log(Token.create("123")); // app:123

ここでは Token.#prefix とclass名で固定しています。もし this.#prefix と書き、継承したmethodを派生classから呼ぶと、this は派生classですが、その派生classは基底classのprivate static brandを持たないため TypeError になります。

class BaseCounter {
  static #count = 0;

  static increment() {
    BaseCounter.#count += 1;
    return BaseCounter.#count;
  }
}

class ChildCounter extends BaseCounter {}

console.log(ChildCounter.increment()); // 1

この例は基底classの状態を全派生classで共有する設計です。派生classごとに独立したprivate counterを自動生成する仕組みではありません。classごとの状態が必要なら WeakMap をclass objectで引く、各classでfieldを宣言する、外部registryを使うなど、要件を明示します。

TypeScriptの private static は、通常は型検査上のアクセス制限です。JavaScriptの #private と同じ実行時機構ではない点にも注意します。

static initialization blockを使う場面

static initialization blockは、複数文、分岐、例外処理を伴うstatic初期化をclass内へまとめます。class評価時に上から実行され、一つのclassに複数書けます。

class MimeTypes {
  static values;

  static {
    const entries = [
      ["json", "application/json"],
      ["html", "text/html"],
    ];

    MimeTypes.values = Object.freeze(Object.fromEntries(entries));
  }
}

console.log(MimeTypes.values.json); // application/json

blockは独自のスコープを持ち、private static memberにもアクセスできます。派生classのstatic blockから super.someMethod() を呼ぶこともできます。

一方、await はstatic blockで直接使えません。非同期初期化が必要なら、Promiseをstatic fieldへ保持する方法もありますが、import時に通信を始める設計になりがちです。アプリ起動処理やasync factoryで明示的に待つ方が、失敗と再試行を管理しやすくなります。

単純な式なら通常のstatic field initializerで十分です。static blockは「書けるから使う」のではなく、class内部のprivate状態を含む複数文の同期初期化を一か所に閉じ込めたいときに限定します。

mutable static stateが生む問題

次の連番生成は小さなスクリプトでは動きますが、長寿命アプリケーションではglobal stateと同じ問題を持ちます。

class OrderNumber {
  static next = 1;

  static issue() {
    return OrderNumber.next++;
  }
}

主な問題は次のとおりです。

  • テストの実行順によって初期値が変わる
  • 同一プロセス内のユーザーやリクエストが状態を共有する
  • SSRで別リクエストのデータが混ざる
  • Hot Module Replacement後に状態が保持または再初期化され、挙動が環境依存になる
  • 同じpackageが複数copy読み込まれると「一つのはず」の状態が複数存在する
  • 複数プロセスや複数サーバー間では共有されず、採番の一意性を保証できない

不変な定数はstaticに向いていますが、更新可能な業務状態、cache、現在ユーザー、接続などは、寿命と所有者を明示できるinstanceや外部storeへ置きます。

class OrderNumberIssuer {
  #next: number;

  constructor(initialValue = 1) {
    this.#next = initialValue;
  }

  issue(): number {
    return this.#next++;
  }
}

const issuer = new OrderNumberIssuer(100);
console.log(issuer.issue()); // 100

instance化すれば、テストごと、tenantごと、request scopeごとに状態を分けられます。本当にプロセス全体で一つにしたい場合も、composition rootで一つだけ生成する方が、その決定を変更しやすくなります。

通常関数との比較

状態を使わない純粋な変換をclassへ所属させる理由が弱いなら、moduleから関数をexportするだけで十分です。

export function normalizeUserName(value: string): string {
  return value.trim().replaceAll(/\s+/g, " ");
}
class UserName {
  static normalize(value: string): string {
    return value.trim().replaceAll(/\s+/g, " ");
  }
}

後者は UserName という概念の発見性を高める場合がありますが、classが一度もinstance化されず、static utilityだけが増え続けるなら、module自体がnamespaceとして十分です。通常関数には次の利点があります。

  • importした依存が直接見える
  • tree shakingや単体テストの対象が明確になる
  • this と継承を考えなくてよい
  • 必要なら関数を引数として簡単に差し替えられる

一方、private static状態、named constructor、継承可能なclass-level APIなど、class objectであることに意味があるならstaticが自然です。

TypeScriptでのstatic側の注意点

TypeScriptでは、class宣言からinstance側の型とstatic側の値が同時に作られます。

class User {
  static fromName(name: string): User {
    return new User(name);
  }

  constructor(readonly name: string) {}
}

type UserInstance = User;
type UserClass = typeof User;

const userClass: UserClass = User;
const user: UserInstance = userClass.fromName("Taro");

User を型位置で使うとinstance型、typeof User はconstructorやstatic memberを含むstatic側の型です。factoryやregistryでclassそのものを引数に取るとき、この違いが必要になります。

また、classの implements は基本的にinstance側を検査し、static memberの契約までは検査しません。static factoryを持つclassの契約を表したいなら、constructor signatureを含む別interfaceへ typeof SomeClass を代入して確認します。

interface NamedEntityClass<T> {
  new (name: string): T;
  fromName(name: string): T;
}

class Team {
  constructor(readonly name: string) {}

  static fromName(name: string): Team {
    return new Team(name.trim());
  }
}

const TeamClass: NamedEntityClass<Team> = Team;

generic classの型パラメーターはinstanceごとの型を表すため、static memberから直接使えません。static fieldは全instanceで一つだからです。型ごとのstatic cacheが欲しい設計は、runtimeでは型情報が消えることも踏まえ、明示的なkeyや外部registryを検討します。

テストで所有と共有を確認する

staticの継承やshadowingは、標準APIで確認できます。

import assert from "node:assert/strict";

class BaseConfig {
  static region = "global";
}

class JapanConfig extends BaseConfig {}

assert.equal(JapanConfig.region, "global");
assert.equal(Object.hasOwn(JapanConfig, "region"), false);

JapanConfig.region = "jp";

assert.equal(JapanConfig.region, "jp");
assert.equal(BaseConfig.region, "global");
assert.equal(Object.hasOwn(JapanConfig, "region"), true);
assert.equal(Object.getPrototypeOf(JapanConfig), BaseConfig);

mutable static stateをどうしても使う場合は、テスト用reset methodを安易に公開するより、状態をinstanceへ移せないか先に検討します。resetが必要という事実は、状態の寿命がclass全体では広すぎる兆候です。

まとめ

static の本質は「便利な関数置き場」ではなく、memberの所有者をinstanceからclass objectへ変えることです。

  • static fieldとstatic methodはclass objectのown memberとして定義される
  • static initializerとstatic blockはclass評価時に実行される
  • 継承されたstatic memberはコピーではなく、class object側のprototype chainから見つかる
  • static methodの this は呼び出したclassであり、super を使ってもreceiverは派生classのままになる
  • private static fieldは宣言classのbrandに属し、派生class共通のprotected stateではない
  • named constructor、class固有の定数、privateな同期初期化はstaticと相性がよい
  • mutable state、外部I/O、独立した純粋関数はinstance、注入するservice、通常関数も比較する
  • TypeScriptではinstance側の型と typeof Class で表すstatic側を区別する

「クラス名から呼ぶと見栄えがよい」ではなく、値の寿命、共有範囲、継承時の契約を説明できるかで判断すると、staticの過剰利用を避けながら必要な場所で活用できます。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?