はじめに
こんにちは!バックエンドエンジニアのハラノです。
最近開発をしていて、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);
それぞれの関数を簡単に説明すると以下のようになります。
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 は入力とマッチングの状態を持ち、入力に対してパターンマッチする処理の責務を持つクラスになっています。
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' ) {
predicate = args[1 ];
} else if (args.length > 2 ) {
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);
handler に パターンがマッチしたときに実行する関数を格納する
patterns に P.any が代入される
それぞれの pattern について matchPattern()関数が実行され、パターンがマッチ しているかの boolean が格納される
matchPattern 内では Matcher(P.any)の場合 P.any[Symbol.for("@ts-pattern/matcher")]().match(value) で パターンがマッチ しているかを比較しています
マッチしている場は、{ 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);
console .log (matcher);
console .log (matcher.match ());
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)
.with(P.any, (i ) => i.text)
.exhaustive();
console .log (result);
内部で何が行われているのかイメージがついたのではないでしょうか?
今回調べてみて、{ text: P.string } やP.string.select()をパターンとして渡した場合などについても、学びを深めていきたいと感じました!
最後にみんなのマーケットでは、くらしのマーケット のサービス開発を一緒に盛り上げてくれるエンジニアを募集しています!
詳しくは、こちら をご覧ください。