🐹

TypeScript の override で親クラスのメソッドの型を利用する

2024/03/22に公開

ドワンゴのニコニコ生放送でフロントエンドを担当している misuken です。

今回はTypeScriptの型の話をしたいと思います。

はじめに

TypeScriptで親クラスのメソッドを override するとき、親クラスと同じ引数や戻り値の型を書くのを面倒に感じる方もいらっしゃるのではないでしょうか?

voidstring のように自明なものや単純なものは良いとして、ジェネリクスを使うなど複雑な型を親クラスのメソッドから調べ、同じようにサブクラスにも記述する作業は地味に面倒ですし、場合によってはそのためだけに何個も import を追加する必要が生じます。

複雑な型は記述も長くなるため、レビュー時はCIで型が検査されていても、親クラスと同じ記述になっているかの判断がつきにくくなったりします。

かといって、型を書かないと挙動が少し変化するため、型が通るはずのところで通らなくなったり、追加で as const が必要になったりするため、モヤモヤすることもあります。

そんなときは Parameters<親クラス["メソッド名"]> ReturnType<親クラス["メソッド名"]> の記述を検討してみてはいかがでしょうか。

戻り値の型の有無による挙動の違い

override するとき、戻り値の型を書くか書かないかで若干挙動に変化が起こります。

以下の三つのメソッドの書き方の例を見てみましょう。

// 親クラス
abstract class Base {
  protected abstract methodA(): "foo" | "bar" | "baz";
  protected abstract methodB(): "foo" | "bar" | "baz";
  protected abstract methodC(): "foo" | "bar" | "baz";
}

// サブクラス
class _ extends Base {
  // Property 'methodA' in type 'B' is not assignable to the same property in base type 'A'.
  //   Type '() => string' is not assignable to type '() => "foo" | "bar" | "baz"'.
  //     Type 'string' is not assignable to type '"foo" | "bar" | "baz"'.(2416)
  protected override methodA() {
    // 戻り値の型が無いとメソッドの戻り値の型は return の内容から推論されるが、
    // return に "" で書かれた文字列は widening されるのでstring型を返すメソッドとなる。
    // その後、親クラスのメソッドとの関係を評価される際に戻り値の型を満たさないため、メソッドがエラーになる。
    return "foo";
  }

  protected override methodB() {
    // as const を書くと、widening されず "foo" と評価されるため"foo"型を返すメソッドになる。
    // 親クラスのメソッドとの関係を評価される際に戻り値の型を満たすため、メソッドがエラーにならない。
    // しかし、Pull Request でのレビュー時に as const を消せそうに見える。
    return "foo" as const;
  }

  protected override methodC(): ReturnType<Base["methodC"]> {
    // 戻り値の型を書くとメソッドの return の部分で型が評価されるため、
    // return に "" で書かれた文字列は widening されず型が通る。
    // Pull Request でのレビュー時にも親クラスの型に準ずる意図の読み取れる実装になる。
    return "foo";
  }
}

メソッドに戻り値の型を書いたほうが、メソッド内で戻り値の型を評価できるので、つまらない型エラーに遭遇しにくくなることや、レビュー時も親クラスの型に準ずるという意図が把握しやすくなります。

また、戻り値の型が書かれていると、 return から型が推論されるため、エディタの補完が効くなど、実装効率の向上にも寄与します。

引数で親クラスの定義を利用

親クラスのメソッドの引数の型は Parameters<親クラス["メソッド名"]>[n番目] で得られます。

abstract class Base {
  protected abstract methodA(value: "foo" | "bar" | "baz"): "foo" | "bar" | "baz";
}

class _ extends Base {
  protected override methodA(value: Parameters<Base["methodA"]>[0]): ReturnType<Base["methodA"]> {
    return value;
  }
}

複数の引数を簡潔に書きたい場合はこう書くこともできますが、型のために実装を変えるのは良いことではないのであまりオススメはできません。

class _ extends Base {
  protected override methodA(...[value, value2]: Parameters<Base["methodA"]>): ReturnType<Base["methodA"]> {
    return value;
  }
}

記述を簡潔にする方法はなさそう

親クラスが MethodReturnType<"methodA"> のように使える型を公開してくれると継承する側は便利なのですが、 現状 TypeScript ではクラスのメソッド名を型として得る方法がないため、これ以上記述を簡潔にする方法はなさそうです。

例えば、以下の例は一見問題ないように見えますが keyof Base にはメソッド名が含まれないので、MethodReturnType<"methodA"> などと書いても、メソッド名のところでエラーになります。

export type MethodReturnType<T extends keyof Base> = ReturnType<Base[T]>;

// MethodReturnType<"methodA">
//                  ^^^^^^^^^
// Type 'string' does not satisfy the constraint 'never'.(2344)

さらに、次の例も Base[T] の部分でエラーになります。強引に @ts-expect-error を使用すると結果としては望ましいものを得られるものの、こういった用法に問題がないかどうかはわかりません。

export type MethodReturnType<T extends "methodA" | "methodB" | "methodC"> =
  // Type 'T' cannot be used to index type 'Base'.(2536)
  // @ts-expect-error クラスから強引にメソッド名を得るため
  Base[T] extends () => infer R ? R : never;

// MethodReturnType<"methodA"> は "foo" | "bar" | "baz" になる

override でよしなにしてくれたら便利

そもそも、override を記述した時点で引数と戻り値の型は自動で書いてあるものとして動作してくれれば、とても簡潔に記述できそうです。

TypeScriptのissueを色々見回ってみたところ、inherit というキーワードで実現する提案 もあるようです。

応用

abstract なメソッドだけでなく、getter や interface でも同様の記述が行えます。

getter の場合

getter の場合は関数ではないので 親クラス名["メソッド名"] で同様のことが可能です。

interface の場合

interface でも ReturnType<インタフェース名["メソッド名"]> で同様のことが可能です。

まとめ

override したメソッドに引数や戻り値の型を書く際、親クラスのメソッドの型をそのまま利用したい場合は Parameters<親クラス["メソッド名"]>ReturnType<親クラス["メソッド名"]> という記述を使うことで実現できます。

上手に利用して効率的な実装に活かしてみてはいかがでしょうか。

株式会社ドワンゴでは、様々なサービス、コンテンツを一緒につくるメンバーを募集しています。 ドワンゴに興味がある。または応募しようか迷っている方がいれば、気軽に応募してみてください。

Discussion