くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログです

ts-pattern という OSS のコードリーディングしてみた

はじめに

こんにちは!バックエンドエンジニアのハラノです。 最近開発をしていて、try catchを使ったエラーハンドリングに少し不満を持つようになりました。
型だけを見ても発生しうる Error がわからないため、エラーハンドリングを行うときに、実際の処理を追う必要がある上、エラーハンドリングが網羅されていなくてもコンパイルが失敗しないからです。

そのため、より適切にエラーハンドリングをする方法がないか探していたところ、Result 型を提供するneverthrowとパターンマッチングを行うts-patternというライブラリがあることを知りました。

組み合わせることで、Result 型で発生するエラーがわかる上に、ts-pattern のパターンマッチングを使うことによって、発生するエラーが網羅されていない場合コンパイルエラーにすることができます。

もともと OSS のコードを読んでみてして新しい学びを得たかったのと、サイズも大きすぎずコードリーディングするのに適していると感じたので、今回はts-patternの読んで、内部構造を調べてみました!

どのようにコードリーディングしたのか、今回得られた知見について、今回共有いたします。

どのようにコードリーディングしたのか

cursorというエディタが前々から気になっており、コードを clone し、コードの処理を追いかけつつ、cursor の chat で質問し、わかったことについて Notion でメモしていました。
AI の回答は正しいとは限らないため、生成された回答について正しそうか?というのを確認しつつ進めました!
精度が高く、また同じようなことをするなら絶対使おうと思えるレベルでした!

以下、一部の回答例です。

cursorの回答_1

cursorの回答_2

今回のサンプルコードについて

以下のコードが内部でどのようにハンドリングされ、hello world が出力されるのかを調べていきます。

このコードはどのような値でも マッチ するP.any を使っており、結果 マッチ してhello world が出力されます。

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

type Input = {
  text: string;
};

const input: Input = {
  text: "hello world",
};

const result = match(input)
  .with(P.any, (i) => i.text)
  .exhaustive();

console.log(result);
// hello world

それぞれの関数を簡単に説明すると以下のようになります。

  • match(input) でパターンマッチングのビルダーを作成
  • .with() でパターンを追加、パターンマッチした場合に実行する関数を追加
  • .exhaustive() でパターンの結果を返す

それぞれの処理について具体的に見ていきます!

match()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/match.ts#L31

export function match<const input, output = symbols.unset>(
  value: input
): Match<input, output> {
  return new MatchExpression(value, unmatched) as any;
}

こちらを見てみると match は単純に MatchExpression を new して返しているだけのようです。

MatchExpression の実装は以下になっており、with() の実装についても MatchExpression にあります。

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/match.ts#L46

以下の記述があり、MatchExpression は入力とマッチングの状態を持ち、入力に対してパターンマッチする処理の責務を持つクラスになっています。

/**
 * This class represents a match expression. It follows the
 * builder pattern, we chain methods to add features to the expression
 * until we call `.exhaustive`, `.otherwise` or the unsafe `.run`
 * method to execute it.
 *
 * The types of this class aren't public, the public type definition
 * can be found in src/types/Match.ts.
 */
class MatchExpression<input, output> {
  constructor(private input: input, private state: MatchState<output>) {}

  // 以下省略
}

with()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/match.ts#L49

  with(...args: any[]): MatchExpression<input, output> {
    if (this.state.matched) return this;

    const handler: (selection: unknown, value: input) => output =
      args[args.length - 1];

    const patterns: Pattern<input>[] = [args[0]];
    let predicate: ((value: input) => unknown) | undefined = undefined;

    if (args.length === 3 && typeof args[1] === 'function') {
      // case with guard as second argument
      predicate = args[1];
    } else if (args.length > 2) {
      // case with several patterns
      patterns.push(...args.slice(1, args.length - 1));
    }

    let hasSelections = false;
    let selected: Record<string, unknown> = {};
    const select = (key: string, value: unknown) => {
      hasSelections = true;
      selected[key] = value;
    };

    const matched =
      patterns.some((pattern) => matchPattern(pattern, this.input, select)) &&
      (predicate ? Boolean(predicate(this.input)) : true);

    const selections = hasSelections
      ? symbols.anonymousSelectKey in selected
        ? selected[symbols.anonymousSelectKey]
        : selected
      : this.input;

    const state = matched
      ? {
          matched: true as const,
          value: handler(selections, this.input),
        }
      : unmatched;

    return new MatchExpression(this.input, state);
  }

今回は第 1 引数にP.any ,第 2 引数に マッチ したときに実行される関数を渡した時の流れを説明します。 コードとして表すと以下になります。

match(input).with(P.any, (i) => i.text);
  1. handler に パターンがマッチしたときに実行する関数を格納する
  2. patterns に P.any が代入される
  3. それぞれの pattern について matchPattern()関数が実行され、パターンがマッチ しているかの boolean が格納される
    1. matchPattern 内では Matcher(P.any)の場合 P.any[Symbol.for("@ts-pattern/matcher")]().match(value) で パターンがマッチ しているかを比較しています
  4. マッチしている場は、{ matched: true, value: handler() } として新しい MatchExpression を作成して return する

P.any

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/patterns.ts#L660

export const any: AnyPattern = chainable(when(isUnknown));

以下の関数を使用しているようです。

  • isUnknown()
  • when()
  • chainable()

isUnknown()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/patterns.ts#L612

function isUnknown(x: unknown): x is unknown {
  return true;
}

unknown かどうかを判定する関数で、常に true を返す関数のようです。

when()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/patterns.ts#L526

export function when<input, predicate extends (value: input) => unknown>(
  predicate: predicate
): GuardP<
  input,
  predicate extends (value: any) => value is infer narrowed ? narrowed : never
>;
export function when<input, narrowed extends input, excluded>(
  predicate: (input: input) => input is narrowed
): GuardExcludeP<input, narrowed, excluded>;
export function when<input, predicate extends (value: input) => unknown>(
  predicate: predicate
): GuardP<
  input,
  predicate extends (value: any) => value is infer narrowed ? narrowed : never
> {
  return {
    [matcher]: () => ({
      match: <UnknownInput>(value: UnknownInput | input) => ({
        matched: Boolean(predicate(value as input)),
      }),
    }),
  };
}

Symbol.for("@ts-pattern/matcher") の key に与えられた関数に input を渡してマッチしているかの関数を提供する object を返す関数のようです。

文章だけだと分かりづらいので、それぞれを console に出力して確認します。

このようにP.anyなどの Pattern は、マッチ するかの関数を提供しています。

const matcherSymbol = Symbol.for("@ts-pattern/matcher");
const matcher = (P.any as any)[matcherSymbol]();

console.log(P.any);
/*
{
  [Symbol(@ts-pattern/matcher)]: [Function],
  optional: [Function: optional],
  and: [Function: and],
  or: [Function: or],
  select: [Function: select],
}
*/

console.log(matcher);
/*
{
  match: [Function: match],
}
*/

console.log(matcher.match());
/*
{
  matched: true,
}
*/

chainable()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/patterns.ts#L118

function chainable<pattern extends Matcher<any, any, any, any, any>>(
  pattern: pattern
): Chainable<pattern> {
  return Object.assign(pattern, {
    optional: () => optional(pattern),
    and: (p2: any) => intersection(pattern, p2),
    or: (p2: any) => union(pattern, p2),
    select: (key: any) =>
      key === undefined ? select(pattern) : select(key, pattern),
  }) as Chainable<pattern>;
}

P.any.select()などができるように、select などを元の object に追加しているようです。

exhaustive()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/match.ts#L114

  exhaustive(): output {
    if (this.state.matched) return this.state.value;

    let displayedValue;
    try {
      displayedValue = JSON.stringify(this.input);
    } catch (e) {
      displayedValue = this.input;
    }

    throw new Error(
      `Pattern matching error: no pattern matches value ${displayedValue}`
    );
  }

マッチ している場合は、value を返し、マッチ するパターンがなければ Error を実装するようになっています。

実際は型でのガードによってケースが網羅されていない場合は型エラーになるため、実行時に Error になることはそこまで多くなさそうです。

まとめ

改めて最初のコードを再掲します。

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

type Input = {
  text: string;
};

const input: Input = {
  text: "hello world",
};

const result = match(input) // MatchExpressionのインスタンスを生成する
  .with(P.any, (i) => i.text) // 何にでもMatchするP.anyを渡し、matchした場合は(i) => i.textを実行、内部のステートを更新する
  .exhaustive(); // matchしていない場合、Errorをthrowする

console.log(result);
// マッチしているため、 hello world が出力される

内部で何が行われているのかイメージがついたのではないでしょうか?

今回調べてみて、{ text: P.string }P.string.select()をパターンとして渡した場合などについても、学びを深めていきたいと感じました!

最後にみんなのマーケットでは、くらしのマーケットのサービス開発を一緒に盛り上げてくれるエンジニアを募集しています! 詳しくは、こちらをご覧ください。