旧来のクラス
JavaScriptに元々classはなく、コンストラクタとして使えるようなfunctionをクラスとして使っていました。 (現在のclassは、わずかな違いを除けば、昔からある方法の糖衣構文です)
TypeScriptでも、一部の型をごまかせば、この方法でクラスを作ることができます。1
const Foo: {
new (greeting?: string): Foo;
} = function (this: Foo, greeting?: string) {
this.greeting = greeting || "Hello";
} as any;
Foo.prototype.greet = function () {
console.log(`${this.greeting}, world!`);
};
interface Foo {
greeting: string;
greet(): void;
}
new Foo().greet(); // => Hello, world!
この場合、 Foo
はTypeScript的にはクラスかどうかは不明で、単に「new
シグネチャを持つオブジェクト」として認識されています。
旧来のクラスの継承
JavaScriptではこのような旧来のクラスに対してclass構文で継承を行うことができます。そしてTypeScriptもそれに型をつけることができます。
class Bar extends Foo {
constructor() {
// ここにも型がつく
super("Good evening");
}
}
// この呼び出しにもちゃんと型がつく
new Bar().greet(); // => Good evening, world!
construct signatureの戻り値に対する制約
construct signatureの戻り値自体にはこれといった制約はないので、変な型をもったクラスを継承させられるかと思いきや、さすがにそんなことはないようです。 (TypeScript 4.1.2時点)
interface User {
type: "user";
userId: number;
}
interface Admin {
type: "admin";
adminId: number;
}
type Entity = User | Admin;
const Entity: new () => Entity = function (): Entity {
return { type: "user", userId: 1 };
} as any;
// Error: Base constructor return type 'Entity' is not an object type or
// intersection of object types with statically known members. (2509)
class ExtendedEntity extends Entity {}
要するに、継承を行おうとした段階で、construct signatureの戻り値が「interface と同じような型」である必要があるようです。
また、construct signatureのオーバーロードがある場合も、その型は全て等しい必要があります。
const WeirdClass: {
new (isAdmin: false): { type: "user" };
new (isAdmin: true): { type: "admin" };
} = function (isAdmin: boolean): { type: "user" } | { type: "admin" } {
if (isAdmin) {
return { type: "user" };
} else {
return { type: "admin" };
}
} as any;
// Error: Base constructors must all have the same return type. (2510)
class SubClass extends WeirdClass {}
多相コンストラクタ
construct signatureもcall signatureと同様、多相にすることができます。多相コンストラクタ関数をそのまま継承しようとすると型エラーになります。
const IdClass: {
new<T>(value: T): T;
} = function<T>(value: T): T {
return value;
} as any;
// Error: No base constructor has the specified number of type arguments. (2508)
class ExtendedUser extends IdClass {}
このような場合は、 extends
直後の式にジェネリクス引数を付与することで、型を確定させる必要があるようです。構文的には通常の継承、例えば class MyComponent extends React.Component<Props>
で出てくるものと同じですね。
const IdClass: {
new<T>(value: T): T;
} = function<T>(value: T): T {
return value;
} as any;
class ExtendedUser extends IdClass<{ type: "user" }> {}
ジェネリクス引数の個数が異なるconstruct signatureは共存可能で、 extends
で指定した個数にマッチするconstruct signatureが採択されるようです。
const WeirdlyOverloadedClass: {
new (): { greeting: string };
new <T>(value: T): T;
} = function<T>(value?: T): T | { greeting: string } {
if (value) return value;
return { greeting: "Hello" };
} as any;
class ExtendedGreeting extends WeirdlyOverloadedClass {}
class ExtendedUser extends WeirdlyOverloadedClass<{ type: "user" }> {}
使い道
本記事を書くきっかけは以下のような経緯です。
Wantedly社内にあるJavaScriptコードをTypeScript化するにあたって、immutable.jsの型定義の使い勝手が悪いのが問題になっていました。そこで、別のイミュータビリティーヘルパーへの書き換えを想定しつつ、当面の凌ぎとして、独自の型定義で上書きすることを試みていました。
Record
が Map<string, any>
のサブクラスということになっているものの、これは Record
の本質的な機能であるプロパティアクセサの型が全くついていないことを意味しています。これでは型をつける意味が半減してしまうため、Record
により適切な型をつける工夫をすることになりました。詳細は省きますが、 Record
はクラスを生成する関数であり、 Record(defaultValue)
を継承することで独自classを定義する、というような使い方をします。また、動的にプロパティアクセサが生成されることもあり、通常のclass宣言で Record
の型をつけるのは難しく、 construct signatureを自力で書く必要がありました。
なお、こういう仕事に興味があれば、ぜひこちらの募集をチェックしてみてください(宣伝)→ https://www.wantedly.com/projects/529734
まとめ
- JavaScriptは
class
で作られたクラス以外も継承できる。 - TypeScriptにも、そのような継承を扱うための仕組みがあり、construct signatureの戻り値がスーパークラスの型として採用される。
- ES2015のclassの制約に適合させるため、interfaceの範疇を逸脱する型を返すconstruct signature、オーバーロードされたconstruct signature、多相なconstruct signatureには、いくつかの制約がある。
-
もっとも、このような実装が必要な場合でも、型定義を分割して
declare class
で済ましてしまうことのほうが多いかと思いますが ↩