🦄

ts-patternでTypeScriptにパターンマッチングを持ち込み、より型安全な世界へ

2022/06/08に公開
5

0. はじめに

現代のWebアプリケーションの開発言語として、TypeScriptはファーストチョイスの一つです。特殊なケースを除き、フロントエンドの開発言語にはTypeScriptが選ばれるため、言語を統一するメリットを優先し、バックエンドにもTypeScriptが採用されるケースはよく見られます。

またReactがClass Componentを捨てFunction Componentを採用した事件が象徴するように、現代のプログラミングパラダイムのトレンドとして関数型プログラミングがあります。そもそもJavaScriptの出自は、関数型言語をブラウザに搭載できると聞いてNetscape社にやってきたブレンダンアイク氏が、オブジェクト指向言語の権化であるJavaのような言語にしろと上司に言われ生み出したというものです[1]。そのためか、JavaScriptは未だ関数型言語としては未成熟で、関数型プログラミングの中でも特に重要なパターンマッチングを持っていません[2]

しかし最近のTypeScriptの型推論の進化には目覚ましいものがあり、ユーザーランドでパターンマッチングを実装したts-patternというライブラリが存在します。この記事ではts-patternの基本から応用的な使い方までを概説し、パターンマッチングによりコードベースをより型安全に保つ技法について紹介します

※サンプルコードは全て ts-pattern@4.0.2, typescript@4.4.2 で実行しています。

1. パターンマッチングがないと何故困るか

パターンマッチングは関数型プログラミングの技法の一つで、文ではなく式を用いることで、より宣言的かつ型安全な分岐処理を記述できます。

ts-pattern の最も簡単なサンプルを見てみましょう。

ts-patternを使った分岐処理 (1)
import { match } from "ts-pattern";

type Animal = "Cat" | "Dog";

const say = (animal: Animal) => {
  return match(animal)
    .with("Cat", () => "Meow")
    .with("Dog", () => "Bow")
    .exhaustive(); // 全てのパターンが網羅されているかチェックする
};

say("Cat"); // "Meow"
say("Penguin"); // Type Error: '"Penguin"' is not assignable to parameter of type 'Animal'.

say("Penguin") が型エラーになります。say() 関数の引数 である Animal 型が Penguin を持っていないからです。

switch文 を使って書き直すと下記のようになります。

switch文を使った分岐処理 (1)
type Animal = "Cat" | "Dog";

const say = (animal: Animal) => {
  switch(animal) {
    case "Cat":
      return "Meow";
    case "Dog":
      return "Bow";
    default:
      throw new Error(`Invalid animal: ${animal}.`);
  }
};

say("Cat"); // "Meow"
say("Penguin"); // Type Error: '"Penguin"' is not assignable to parameter of type 'Animal'.

同じく say("Penguin") が型エラーになりました。ここまでは問題ありません。

問題になるのは、Animal 型に Penguin など新たな型を加えたにも関わらず、分岐処理を書き漏らした場合です。switch文のサンプルから見てみましょう。

switch文を使った分岐処理 (2)
type Animal = "Cat" | "Dog" | "Penguin"; // "Penguin" を加えた

const say = (animal: Animal) => {
  switch(animal) {
    case "Cat":
      return "Meow";
    case "Dog":
      return "Bow";
    // case "Penguin": が抜けている
    default:
      throw new Error(`Invalid animal: ${animal}.`);
  }
};

say("Cat"); // "Meow"
say("Penguin"); // !!! 型エラーは出ず、実行すると "Invalid animal: Penguin" エラーになる

switch文のサンプルでは、"Penguin"に対応する分岐処理がないことを型エラーとして検知できません。

このように既存のUnion型に何らかの型を加えるケースは頻出であるにも関わらず、switch文では分岐処理が漏れることがあります[3]。if-else文にも同じことが言えます。

一方、ts-patternでは次のようになります。

ts-patternを使った分岐処理 (2)
import { match } from "ts-pattern";

type Animal = "Cat" | "Dog" | "Penguin"; // "Penguin" を加えた

const say = (animal: Animal) => {
  return match(animal)
    .with("Cat", () => "Meow")
    .with("Dog", () => "Bow")
    // .with("Penguin", () => ...) が抜けている
    .exhaustive(); // Type Error: NonExhaustiveError<"Penguin"> 🎉
};

say("Cat"); // "Meow"
say("Penguin");

.exhaustive() というメソッドをコールしておくことで、Penguin に対応するパターンがないという型エラーが出ました。一見地味なようですが、コードベースが大きくなるほど、安全にUnion型を変更できるというメリットは経験則として大きいです。型エラーが出ない場合は、コードベース全体にgrepを掛けて手作業で探す羽目になるからです。静的解析で防げることは何でも防ぐべきです。

なおサンプルコードの通り、.with()メソッドの第一引数にはパターンを渡し、第二引数にはそのパターンがマッチしたときに実行する関数を渡します。以後、第一引数を条件部、第二引数をハンドラ関数と呼びます。

2. 応用

基本的な使い方は前章で述べたので、ここではより実践的な応用例について紹介します。

2-1. 組み合わせを網羅する

import { match } from "ts-pattern";

type User = "student" | "general";
type Sale = "summer" | "winter";
type Input = [User, Sale];

const price = (input: Input): number => {
  return match(input)
    .with(["student", "summer"], () => 1000)
    .with(["student", "winter"], () => 900)
    .with(["general", "summer"], () => 1500)
    .exhaustive(); // Type Error: "general" + "winter" の組がないので型エラー
}

条件部に配列を渡すことで、組み合わせを網羅することができます。配列ではなくObjectでも、似た書き方で組み合わせを網羅する分岐処理が可能です。

2-2. プリミティブ型で分岐する

import { match, P } from "ts-pattern";

interface User {
  age: number | string;
}

const test = (user: User): string => {
  return match(user)
    .with({ age: P.number }, (narrowedUser) => {
      // typeof narrowedUser.age === "number"
      return `age is number: ${narrowedUser.age}`;
    })
    .with({ age: P.string }, (narrowedUser) => {
      // typeof narrowedUser.age === "string"
      return `age is string: "${narrowedUser.age}"`;
    })
    .exhaustive();
};

test({ age: 32 }); // age is number: 32
test({ age: "32" }); // age is string: "32"

条件部に含まれる P.number, P.string は、プリミティブ型を表現しています。if文などを使う場合、typeof 構文で分岐処理を書いていましたが、より洗練された方法で記述できます。ほか P.boolean, P.symbol, P.nullish のようなプリミティブ型が用意されています。

またハンドラ関数には、型が絞り込まれた状態で引数(サンプルではnarrowedUser)が渡される点も便利です。

2-3. クラスで分岐する

プリミティブ型ではなくクラスでも分岐可能です。

import { match, P } from "ts-pattern";

class Admin {}
class User {}

type Post = { author: Admin | User };

const test = (post: Post): string => {
  return match(post)
    .with({ author: P.instanceOf(Admin) }, () => 'author is "Admin"')
    .with({ author: P.instanceOf(User) }, () => 'author is "User"')
    .exhaustive();
};

test({ author: new Admin() }); // author is "Admin"
test({ author: new User() }); // author is "User"

2-4. データ構造から値をキャプチャする

import { match, P } from "ts-pattern";

interface Input {
  type: "A";
  user: {
    name: string;
  };
}

const pickName = (input: Input): string => {
  return match(input)
    .with({ type: "A", user: { name: P.select() } }, (name) => name)
    .exhaustive();
};

pickName({ type: "A", user: { name: "Yuki" } }); // Yuki

条件に用いるデータから値を取得したいケースがあります。その場合、P.select() を使うことで、ハンドラ関数の引数に値が渡されます。サンプルコードのようにネストされた構造をもつObjectでは特に便利です。

複数の値を取得したい場合、P.select("name") のように名前を付けることで、ハンドラ関数にはObjectとして値が渡されます。

import { match, P } from "ts-pattern";

interface Input {
  type: "A";
  user: {
    name: string;
    age: number;
  };
}

const pickName = (input: Input): string => {
  return match(input)
    .with({
      type: "A",
      user: { name: P.select("name"), age: P.select("age") }
    }, ({ name, age }) => `I'm ${name} (${age})`)
    .exhaustive();
};

pickName({ type: "A", user: { name: "Yuki", age: 32 } }); // I'm Yuki (32)

3. おわりに

ts-pattern のAPIのうち、利用頻度が多いものだけを紹介しましたが、他にも便利なAPIが多数用意されています。

静的型付けと動的型付けの趨勢は潮の満ち引きのように繰り返すという意見もありますが、ソフトウェア開発が複雑化し続けている現代では、静的型付けのトレンドはまだまだ維持されるように思います。

TypeScriptは型パズルとも言われる通り、初学者殺しの型定義も散見されますが、ライブラリのような形でコードベースから型パズルを排除した上でメリットだけを教授できる方法は、積極的に採用していきたいところです。

4. こちらも

https://qiita.com/aki202/items/b279fa8097dde82e2730
https://qiita.com/aki202/items/bd5a22813352d1834a93
https://zenn.dev/aki202/articles/8e1bc896a2f6f8


@aki202:良ければフォローしてください。

脚注
  1. https://brendaneich.com/2008/04/popularity/ ↩︎

  2. 2022年6月現在、パターンマッチングはTC39でstage:1として議論の最中です。つまりリリースされるまでは数年間を要するということです。https://github.com/tc39/proposal-pattern-matching ↩︎

  3. もちろんswitch文と複雑な型定義を組み合わせることで、型エラーを出すことは可能です。しかしそれはもうts-patternの別実装に過ぎないものです。 [追記:2022/06/09 21:40] default文を書かなかったり、default文の中でneverを書くなどの方法で、型エラーを出すことは可能です。ただ、シンプルな記述で、かつ他者に意図が伝わりやすいコードを書くのであれば、やはり ts-pattern の構文を推奨したいです(コメント欄参照)。 ↩︎

Discussion

dqndqn

通常のswitch文の書き方では、分岐処理の漏れを防ぐ方法がありません

複雑な型定義をせずとも、switch 文の default 節で never 型の値に代入したり never 型を引数にとる関数に渡したりすることで網羅チェックをするテクニックが知られていますね。

type Animal = "Cat" | "Dog" | "Penguin"; // "Penguin" を加えた

const say = (animal: Animal): string => {
  switch(animal) {
    case "Cat":
      return "Meow";
    case "Dog":
      return "Bow";
    // case "Penguin": が抜けている
    default:
      const _: never = animal;
      // Type 'string' is not assignable to type 'never' .
  }
};
リーダブル秋山リーダブル秋山

コメントありがとうございます、勉強になりました。本文の方も編集させていただきました。

自分の書いたような簡単なケースであれば、単にdefault文を書かない方法でも良さそうですね。

type Animal = "Cat" | "Dog" | "Penguin"; // "Penguin" を加えた

const say = (animal: Animal): string => { // ここで string 型を返り値にしているので、型エラーになる
  switch(animal) {
    case "Cat":
      return "Meow";
    case "Dog":
      return "Bow";
    // case "Penguin": が抜けている
  }
};

switch文単体で分岐漏れを防げないとか、Linterでdefault文を追加させない工夫が必要など、考慮する点はありますが、有効そうです。

jintzjintz

めっちゃ便利そうなライブラリですね。こういうライブラリを探してました。
1点だけ、記述ミスらしきものを見つけたので報告いたします。

2-2. プリミティブ型で分岐する

// (略)
test({ age: "32" }); // age is number: "32"

ここでの出力は age is string: "32"になるかと思います。

nap5nap5

zodでバリデーションをユースケースとしてneverthrowライブラリのResult型とts-patternを使いながらデモ作ってみました。

デモコードです。
https://codesandbox.io/p/sandbox/charming-rumple-d6obeh?file=%2Fsrc%2Findex.ts

import { safeParseUsersData, UserData } from "@/features/user/types/user";
import { Chance } from "chance";
import { getFalsyValue } from "@/utils";
import { validateAge } from "@/features/user/validations/age";
import { validateEmail } from "@/features/user/validations/email";
import { Result } from "neverthrow";
import { distinct, tidy } from "@tidyjs/tidy";
import { match } from "ts-pattern";

const validateUser = (data: UserData) => {
  return Result.combine([validateAge(data, 40), validateEmail(data)]);
};

const createUser = (data: UserData) => {
  validateUser(data).match(
    (v) => {
      const neatData = tidy(safeParseUsersData(v), distinct(["id"])).pop();
      console.log(neatData);
      // call create user service
    },
    // @ts-ignore
    (e) => {
      match(e.errorCode)
        .with("E01", () => {
          console.log(`[E01]${e.message}`);
          // call sentry service
        })
        .with("E02", () => {
          console.log(`[E02]${e.message}`);
          // call sentry service
        })
        .otherwise(() => {
          console.log(`[XXX]Unhandling errorCode.`);
          // call sentry service
        });
    }
  );
};

(() => {
  const seed = 1;
  const data: UserData = Chance(seed).bool()
    ? {
        id: 1,
        name: "Spike",
        age: 39,
        company: "Cowboy Bebop",
        email: "spike@cowboy.bebop",
      }
    : getFalsyValue();

  createUser(data);
})();

簡単ですが、以上です。

ログインするとコメントできます