この記事は @suin 氏の『TypeScript: メソッドが上書きできてしまう仕様を回避する小技』に対するアンサー記事です。
元記事ではメソッドがミュータブルなので上書きされてしまうという問題に対して、インスタンスを Readonly にするという解決策を取っていました。その方針は理にかなっていますので、この記事でも同じ方向を探ります。
元記事ではコンストラクタを private
にし、戻り値が Readonly なファクトリメソッドを使ってインスタンスを作成することで目的を達成しています。もちろん、実用上これで問題はありません。
ただ、この方法だと、既存のクラスに対し同じことを行おうとすると、そのクラスのコンストラクタを呼び出している全ての箇所を変更しなければなりません。
TypeScript の型チェックは単純なコンストラクタ呼び出しでコンストラクタが private
になっていたらエラーを出してくれますが、例えばライブラリでこの変更を取り入れた場合は全ての利用者に修正を強いることになるので、なるべく互換性を保ちたいといったケースもあります。
そこで、一つの解決策として、「コンストラクタが直接 Readonly なインスタンスを生成できるようにする」という方法を考えてみたいと思います。
TypeScriptにおけるクラスの扱い
TypeScript はクラスに型を付けるためにそこそこ複雑なことをやっています。
class X {
f() {}
static g() {}
}
という例を見てみましょう。 X
というクラス名は値の世界と型の世界でそれぞれ別の意味を持ちます。つまり、 X
は値の世界ではクラスオブジェクトを表します。一方で、型の世界では X
はインスタンスオブジェクトの型になります。そして、値の世界の X
の型は typeof X
になります。以下の表のような関係です。
値 | 型 |
---|---|
X |
typeof X |
new X() |
X |
X
が値と型で別の行に現れているのがややこしいところですね。
型の世界の X
はおおむね Object
を継承しメンバ変数とメソッドが定義されたインターフェイスとして振る舞います。つまり、この例だと
interface X extends Object {
f(): void;
}
と同じようなものとして扱えます。一方、 typeof X
は Function
を継承し静的メンバ変数と静的メソッドが定義され、prototypeとして型 X
を持ち、 new
でインスタンス生成が可能なインターフェイスとして振る舞います。この例だと、
interface typeof X extends Function { // こんな書き方はできないけど
new (): X;
prototype: X;
g(): void;
}
このような物になるわけです。
コンストラクタの戻り値を変更する
ここで注目するのが new (): X;
の部分です。これは construct signature と呼ばれる文法で、「この型のオブジェクトは new
でインスタンス生成をすることができる」ということをTypeScriptコンパイラはこのシグネチャで判断します。ちなみに、 { new (): X }
は new () => X
とアロー関数のようにも書けます。
new X()
の型が X
になるのは、 typeof X
が { new (): X }
を持つ型だからです。であれば、 typeof X
が { new (): Readonly<X> }
を持つ型になれば、 new
の結果を Readonly<X>
にすることができます。そのようなインターフェイスを定義するのは簡単ですね。
type XConstructor = typeof X;
interface ReadonlyXConstructor extends XConstructor {
new (): Readonly<X>;
}
値 X
は既に typeof X
という型であることが確定していますから、これを変更することはできません。
しかし、 X
を他の変数に代入し、型強制を行うことは可能です。つまり、
const ReadonlyX = X as ReadonlyXConstructor
とします。すると、
const x = new ReadonlyX();
x.f = () => {}; // エラー
となります。値の世界ではクラスオブジェクトが一旦他の変数に代入されただけなので、問題なく X
のインスタンスが作られ、 ReadonlyX
は X
と置き換えても(インスタンスに対する代入操作がエラーになることを除けば)同じように振る舞います。
もうすこし汎用的に
さて、タイトルの目的は達成しましたが、クラスごとに毎回このような定義を生成するのも面倒だし、使い回しができるユーティリティも考えてみます。
typeof X
が { new (): X }
を拡張した型ですから、任意のコンストラクタ引数と戻り値を持つ型に対して同じことが出来るジェネリクスを作ります。
type ReadonlyClass<Cls extends new (..._: never[]) => unknown> = {
new (..._: ConstructorParameters<Cls>): Readonly<InstanceType<Cls>>;
} & {
[K in keyof Cls]: Cls[K]
};
construct signature は Mapped Types で列挙されないので、 construct signature だけの型と Mapped Types を利用して construct signature を除いた型の intersection を作っています。あとはこの型を利用して、
function readonlyClass<
Cls extends new (..._: never[]) => unknown
> (cls: Cls): ReadonlyClass<Cls> {
return cls as ReadonlyClass<Cls>;
}
このような型強制を行う関数を用意します。
先程の ReadonlyX
をこれを使って作成すると、
const ReadonlyX = readonlyClass(X);
と書けます。
この方法の弱点として、コンストラクタがオーバーロードされていたり、ジェネリクスだったりするとうまく動作させることができません。その場合は残念ながら手動で型を定義して型強制を行う方式を使う必要があるでしょう。