⚒️

色んなところを型で壊そう、matchType

2023/12/17に公開4

自己紹介

Web開発・スタートアップ1年目、アセンド株式会社でプロダクトエンジニアをしているまっつんです。

Typescriptの歴はそこまで長くはない状態で、会社にJoinし、フロントもバックエンドもFullTypescriptの環境になりました。Typescriptが静的にコンパイルエラーを出してくれることがかなり開発効率を上げているなぁと感じていて、TSがどんどん好きになっています!

始めに

今回は会社のプロダクトでよく使っていて、好きなユーティリティ関数、matchTypeを紹介します。

課題感

複数の状態に合わせて、その状態を表した日本語を表示してあげたい、なんて事が良くありますよね。

例えば、税率をシステムで表す時、消費税がかかるものとかからないものの二つの状態があり、コードとしては以下のように書くかもしれません。

type TaxRateEnumLiterals = "taxable_10" | "untaxable";

function display(v: TaxRateEnumLiterals) {
  switch (v) {
    case "taxable_10":
      return "課税 10%";
    case "untaxable":
      return "非課税";
  }
}

もちろんこれだけでなく、実際のお金の計算ロジックにも使いたいので、このようなswitch文は色んなところで使われそうです。

このような時に困るのが、税率が10%でない場合をシステムで扱わなくてはいけなくなった時に、全てのswitch文のロジックを追加する必要があり、実行時でないと追加してないことに気づけない点です。そもそもswitch文だと以下のようにコメントアウトしてもエラーは出ません。

type TaxRateEnumLiterals = "taxable_10" | "untaxable" | "taxable_8"; // 追加

function display(v: TaxRateEnumLiterals) {
  switch (v) {
    case "taxable_10":
      return "課税 10%";
    case "untaxable":
      return "非課税";
    // taxable_8の場合の対処を忘れているが静的解析ではエラーが出ない
  }
}

新しく状態を追加した時に修正漏れが出やすく、上記の例だと意図しない返り値(undefined)を受け取ったことにより、アプリケーション上の表示が崩れるなどの悪影響を生じてしまいます。

<div>
{display("taxable_8")} //undefinedが文字列として表示!
</div>

解決方法

以下のような簡単な関数を定義して利用しています。

export const matchType = <T extends string, R>(
  type: T,
  matcher: Record<T, R>
): R => {
  return matcher[type];
};

Tに文字列のユニオンで作った型などを使用すると便利に使えます。

使用例

先程の例を書き直してみます。

function display(v: TaxRateEnumLiterals) {
  return matchType(v, {
    taxable_10: "課税 10%",
    untaxable: "非課税",
  });
}

この時、TaxRateEnumLiteralsに追加があった場合、静的解析によりエラーを生じるため、修正漏れなく対応を行うことができます。

型で壊れるところを見るのはとてもありがたいですね...!うっかりGithubにPushしても、ActionsのCIでこけてくれるので安心...! Web新入生の自分でも安心してPushできます。(ローカルで気付け)

現在の課題

システムの各所で利用しているのですが、課題もあり、以下のようなケースで型のnarrowingが効かない点です。

// 会社で開発している勤怠のシステムの例
// 勤怠のパターンを数種類用意
const AttendancePatternTypes = [
  "Work",
  "Holiday",
  "AnnualPaidLeave",
  "Custom",
] as const;
export type AttendancePatternTypeLiterals =
  (typeof AttendancePatternTypes)[number];

// 自由にユーザーが作れるパターンにだけはidを振りたい
export type AttendancePatternTypeValues =
  | {
      type: Exclude<AttendancePatternTypeLiterals, "Custom">;
    }
  | {
      type: Extract<AttendancePatternTypeLiterals, "Custom">;
      id: string;
    };

const display = (v: AttendancePatternTypeValues) => {
  return matchType(v.type, {
    Work: "出勤",
    Holiday: "公休",
    AnnualPaidLeave: "有給休暇",
    Custom: v.id, // typeがCustomの時は、idを持つはずだがnarrowingされずエラー!(idを表示させたいわけではないですが例です)
  });
};

もしこれを解決できるようなライブラリや方法を知っている方はぜひ教えてください🙇

最後に

僕みたいな新人が急に状態を追加しても、matchTypeが使われてると各所でちゃんと壊れてくれるので対応しなきゃと気付け、自分の変更を取り込む際の安全性が高まりました。
変更容易性の高いコードを書くことができるけど、型のnarrowingに関しては課題があるので、今後も改善すべく勉強を続けたいです。

参考

https://github.com/gvergnaud/ts-pattern

調べていくと、ts-patternという同じことを実現したい便利なライブラリがありました。

使い方と解決したい課題はほぼ一緒ですが、便利だなと思ったのは以下のように引数を複数取り、それらの複合したケースでの記述ができる点です。

// リポジトリのREADMEから
const fn = (org: Plan, user: Permission) =>
  match([org, user])
    .with(['basic', 'viewer'], () => {})
    .with(['basic', 'editor'], () => {})
    .with(['pro', 'viewer'], () => {})
    // Fails with `NonExhaustiveError<['pro', 'editor']>`
    // because the `['pro', 'editor']` case isn't handled.
    .exhaustive();

便利だ・・・。また、クラスやオブジェクトなども柔軟に引数に取れそうなところも魅力的でした。

逆に一点気になったのは、充足してることを条件づけるには、必ず exhaustive() を呼ばないといけない点です。以下のコードのようにorangeを後から追加した場合、exhaustiveを呼んでいないとコンパイルエラーで検知できません。

type Food = "apple" | "banana" | "orange";
const eat = (food: Food) => {
  return match(food)
    .with("apple", () => console.log("りんごを食べました"))
    .with("banana", () => console.log("バナナを食べました"));
};

型のnarrowingについても試しましたが、こちらも無理そうでした。

https://zenn.dev/link/comments/cf94343a321441
こちらは誤りで問題なくnarrowingできました!自分の調査不足でしたが、公開したことで @kirike さんに教えていただけてとてもありがたいです。

アセンドプロダクトチーム テックブログ

Discussion

kirkekirke

間違ってたらすいません

with({type: "Custom", (v) => v.id)

ってやったら取得できませんでしたっけ?

MattznMattzn

試して見たところ、問題なくnarrowingできました!!!
ありがたいです!!