🐶

TypeScriptでclassのプロパティ名のみの型を定義する

2021/11/05に公開

こんにちは、shunです。

  • TypeScriptでclassのプロパティ名のみの型の定義方法
  • 型定義説明
  • なぜプロパティ名のみの型を定義したいのか

を順を追って書いていきます。

TypeScriptでclassのプロパティ名のみの型の定義方法

以下にフロント(今回はReactで例を挙げています)でModelを使用し、Modelのプロパティ名を画面表示する簡単な例を使用し説明していきます。
BaseModel.ts、PersonModel.ts、DogModel.ts、Detail.tsxの4つのファイル構成となります。

BaseModel.ts
type Attributes<T> = {
  [P in keyof T]: T[P] extends (...args: any[]) => any ? never : P;
}[keyof T];

export class BaseModel<T> {
  getLabel(key: Attributes<T>): string {
    const attributes = classToPlain(this);
    return attributes[key as string] || '登録されていません';
  }
}
PersonModel.ts
export class PersonModel extends BaseModel<PersonModel>{
  name: string;

  address: number;

  greet() {
    return `こんにちは。私は${this.name}です`; 
  }
}
DogModel.ts
export class DogModel extends BaseModel<DogModel>{
  name: string;

  greet() {
    return 'わんわん(俺っちは人間より偉いんだぞ)'; 
  }
}
Page/Detail.tsx
interface Props {
  person: PersonModel;
  dog: DogModel;
}
const Detail: React.VFC<Props> = ({ person, dog }) => {
.
.
.
  return (
    <>
      <p>{person.getLabel('name')}</p>
      <p>{dog.getLabel('greet')}</p>      // エラー
      <p>{dog.getLabel('age')}</p>       // エラー
    </>
}

親class(ここではBaseModel)にジェネリクスTを定義します。
ここに子class(ここではPersonModelとDogModel)の型が入ってきます。
BaseModelのgetLabelの引数の型にAttributes<T>を指定し、子classのプロパティ名のみしか入れれないようにしています。
person.getLabel('name')とすると、personのnameが取得できる形ですね。
Page/Detail.tsxの<p>{dog.getLabel('greet')}</p><p>{dog.getLabel('age')}</p>のようにメソッドや存在しないプロパティを指定するとエディタがエラーと教えてくれます。

型定義説明

では、プロパティのみの型定義を説明していきます。
BaseModelの以下の部分ですね。

type Attributes<T> = {
  [P in keyof T]: T[P] extends (...args: any[]) => any ? never : P;
}[keyof T];

これは何をしているのかと言うと、

  // この部分で、プロパティとメソッドの型を抽出します。
  [P in keyof T]: T[P] 

personModelの場合、以下のようになります。

name: string;
address: number;
greet: () => string;

次にこの部分ですね。

  // この部分でメソッドである場合はneverを、メソッドでなければプロパティ名を抽出します。
  extends (...args: any[]) => any ? never : P

以下のようになります。

name: "name";
address: "address";
greet: never;

そして最後に

  [keyof T]

でプロパティの型を抽出します。

"name" | "address"

neverを省き、プロパティ名のみ抽出できます。

なぜプロパティ名のみの型を定義したいのか

BaseModelのgetLabelの引数の型をkeyof Tとしてもバグを生むことはほぼないかと思いますが、BaseModel.tsのみを見た際にkeyof Tの場合、メソッドも入ってくると判断できてしまい、また、dog.getLabel('greet')とメソッド名を指定してもエディタではエラーを知らせてくれません。もう少し型制限を加えて、型を見てプロパティ名のみしか入らないと判断でき、getLabelにメソッド名を指定した際にエディタでエラーを知らせてくれるようにした方が、保守性は上がるためプロパティ名のみの型を定義しました。

雑談

少し話はそれますが、Detail.tsxで以下のように

Page/Detail.tsx
    <>
      <p>{person.name || '登録されていません'}</p>
      <p>{dog.name || '登録されていません'}</p>
    </>
}

と、直接プロパティ名を指定したら良いのでは?と思うかもしれませんが、私の考えではview側の責務としては画面表示であり、プロパティに値が入っているかどうかを判断するのはなるべくview側ではなくModelが判断すべきと考えています。
また、以下のように「登録されていません」から「-」に変更になった場合、これぐらいのコード量なら問題ないですが、規模が大きくなったら大変ですよね。

this.name || '登録されていません';this.name || '-';

それと同じ理由で、以下のようにget nameLabelなどとModelにgetter持たせていくのもナンセンスですね。

PersonModel.ts
export class PersonModel {
  name: string;

  address: number;

  get nameLabel() {
    return this.name || '登録されていません';
  }
  
  get addressLabel() {
    return this.address || '登録されていません';
  }
  
  greet() {
    return `こんにちは。私は${this.name}です`; 
  }
}

以上から、簡単なgetterであれば親classにgetメソッドを持たせて共通化し、画面表示する際にプロパティ名のみを指定する方法がベストかなと思いました。

Discussion