TypeScriptでclassじゃないものを継承する

## 旧来のクラス

JavaScriptに元々classはなく、コンストラクタとして使えるようなfunctionをクラスとして使っていました。 (現在のclassは、わずかな違いを除けば、昔からある方法の糖衣構文です)

TypeScriptでも、一部の型をごまかせば、この方法でクラスを作ることができます。[^d-ts]

[^d-ts]: もっとも、このような実装が必要な場合でも、型定義を分割して `declare class` で済ましてしまうことのほうが多いかと思いますが

```typescript
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もそれに型をつけることができます。

```typescript
class Bar extends Foo {
  constructor() {
    // ここにも型がつく
    super("Good evening");
  }
}

// この呼び出しにもちゃんと型がつく
new Bar().greet(); // => Good evening, world!
```

## construct signatureの戻り値に対する制約

construct signatureの戻り値自体にはこれといった制約はないので、変な型をもったクラスを継承させられるかと思いきや、さすがにそんなことはないようです。 (TypeScript 4.1.2時点)

```typescript
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のオーバーロードがある場合も、その型は全て等しい必要があります。

```typescript
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と同様、多相にすることができます。多相コンストラクタ関数をそのまま継承しようとすると型エラーになります。

```typescript
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>` で出てくるものと同じですね。

```typescript
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が採択されるようです。

```typescript
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には、いくつかの制約がある。