🤔

TypeScriptでの、値や関数を保持するenumの作り方を考えてみた

2022/03/10に公開

はじめに

普段TypeScriptを使っていて、enumを実装したいときはどうされていますか?

ユニオン型やオブジェクトリテラルを使用して実装されていると思います。

私も、これらの書き方をよく使っていますが、しばしば、Java等でのenumと同様に、enumにプロパティや共通のメソッドを持たせたいと感じる事がありました。

本記事では、enumでプロパティ、メソッドを保持させるための案を考えたので、紹介したいと思います。
私もこれで正解だ!という確信はないので、より良い書き方を知っている方がいらっしゃれば教えていただけると幸いです。

今回は会員ランクを表すRankというenumを作成すると仮定して説明をしていきます。

ちなみに、「TypeScriptでのenumの書き方とは?」という方は、私が以前書いた記事をご覧ください。
https://zenn.dev/mongolyy/articles/7a29ec7b611c0b

上記方法の問題点

ただ、これらの方法だと、enumにプロパティ、メソッドを持たせるのが難しいという点があります。

本題 ~値とメソッドをenumに持たせる~

何をしたいか?

Javaでいうと以下のようなコードを、TypeScriptで書きたかったとします。

public enum Rank {
  Premium("Premium", 5, false),
  Standard("Standard", 0, false),
  Free("Free", 0, true);
  
  private final String name;
  private final int discountRate;
  private final boolean needsPostage;
  
  private Rank(string name, int discountRate, boolean needsPostage) {
    this.name = name;
    this.discountRate = discountRate;
    this.needsPostage = needsPostage;
  }
  
  public String displayLabel(string name) {
    Rank rank = Rank.valueOf(name);
    return "rank: " + rank.name
  }
}

つまり、enumにname等のプロパティを保持させ、共通のメソッドとしてdisplayLabel等をもたせたいとします。

どうした?

namespaceを利用することで、enumがメソッドを持っているような振る舞いを作れるのでは?と考え、実装を行いました。
こんな感じです。

rank.ts
export type Rank = {
  readonly name: string
  readonly discountRate: number
  readonly needsPostage: boolean
}

export class IllegalArgumentError extends Error {
  constructor() {
    super('引数に誤りがあります。')
    this.name = 'IllegalArgumentError'
  }
}

export namespace Rank {
  export const Premium: Rank = {
    name: 'Premium',
    discountRate: 5,
    needsPostage: false
  }

  export const Standard: Rank = {
    name: 'Standard',
    discountRate: 0,
    needsPostage: false
  }

  export const Free: Rank = {
    name: 'Free',
    discountRate: 0,
    needsPostage: true
  }

  export const values = (): Rank[] => [
    Premium,
    Standard,
    Free
  ]

  export const valueOf = (name: string): Rank | Error => {
    return Rank.values().find((c) => c.name.toUpperCase() === name.toUpperCase()) ?? new IllegalArgumentError()
  }

  export const displayLabel = (name: string): string => {
    const rank = valueOf(name)
    if (rank instanceof Error) {
      return `${rank.name}: ${rank.message}`
    } else {
      return `${rank.name} rank, discountRate: ${rank.discountRate}%, needsPostage: ${rank.needsPostage}`
    }
  }
}

呼び出し側

console.log(Rank.displayLabel('standard')) // Standard rank, discountRate: 0%, needsPostage: false
console.log(Rank.displayLabel('super')) // IllegalArgumentError: 引数に誤りがあります。

Javaっぽく実装したら、汎用的に使えるのでは?と考え、valueOfやvaluesを実装していますが、ここは好みの問題だと思います。
また、カスタムエラーはthrowでもいいですが、returnの方が自分の好みにマッチするのでそうしました。ここも好みの問題だと思います。

解決のポイントとデメリットについて

  • namespaceを使うことで、擬似的にenumの共通のメソッドを作った
  • 列挙子をオブジェクトリテラルにすることで、プロパティを作った

前者はいいと思うのですが、後者はデメリットがあります。
私が今把握しているものとしては、switch-case文において、網羅していたとしても、コンパイル時にエラーが出ます。とりあえず、defaultのcaseを書くことで回避はできますが、無駄なコードなので本当であればやりたくないです。(オブジェクトリテラルではそういうことはない)
型の制約をつけることはできるので、網羅性のチェックは妥協できるという場合は有効だと思います。

終わりに

今回検討してみて、namespaceを使用してメソッドをもたせる方法は意味がありそうだということがわかりました。
一方で、プロパティを保持する方法は無理矢理感があるので、まだ検討の余地がありそうだなと思っています。

こういう書き方がいいのでは?みたいなのがあれば、コメントいただけるとありがたいです

Discussion