TS Pattern을 파헤쳐봅시다.
https://github.com/gvergnaud/ts-pattern
이 글은 ts-pattern github repository의 readme 일부를 개인적 용도로 번역한 것입니다.
오역된 부분이 있을 수 있으며, 자세한 spec은 반드시 공식 문서를 확인하시기 바랍니다.
The exhaustive Pattern Matching library for TypeScript with smart type inference.
스마트 유형 추론이 포함된 TypeScript 용 철저한 패턴 매칭 라이브러리
ts-pattern을 이용하여 더 안전하고 나은 조건을 작성해보세요. 패턴 매칭을 사용하면 복잡한 조건을 간결한 단일 조건식으로 표현할 수 있습니다. 코드가 더 짧아지고, 가독성도 높일 수 있습니다. 또한 철저한 검사를 통해 모든 경우의 수를 놓치지 않도록 도와줍니다.
.exhaustive()를 통해 모든 가능한 경우의 수를 매칭할 수 있도록 강제합니다. isMatching과 함께 패턴을 사용하여 데이터의 형태를 검증합니다.P._, P.string, P.number 등...)P.select(name) 기능을 통해 속성 선택을 지원합니다.패턴 매칭은 선언적인 방식으로 값의 구조를 조사할 수 있는 코드 분기 기술입니다. 함수형 프로그래밍 언어로부터 나왔습니다. 패턴 매칭은 특히 복잡한 데이터 구조나 여러 값에서 분기를 할 때 명령형 대안 (if/else/switch문)보다 덜 장황하고 더 강력합니다.
패턴매칭은 Haskell, Rust, Swift, Elixir 및 기타 여러 언어로 구현되었습니다. ES 사양에 패턴 매칭을 추가하기 위한 제안이 존재하지만, 아직 1단계에 있으며 빠른 시일 내에 ES사양에 정식으로 합류하기는 어려운 상황입니다. 하지만 운 좋게도 패턴 매칭은 사용자 영역에서 구현이 가능하고, ts-pattern라이브러리는 타입세이프한 패턴 매칭의 구현을 제공합니다.
# via npm
$ npm install ts-pattern
# via yarn
$ yarn add ts-pattern
참고: TS-Pattern은 tsconfig.json파일에서 Strict Mode가 활성화되어 있다고 가정합니다.
| ts-pattern | TypeScript v4.5+ | TypeScript v4.2+ | TypeScript v4.1+ |
|---|---|---|---|
| v4.x (Docs) (Migration Guide) | ✅ | ❌ | ❌ |
| v3.x (Docs) | ✅ | ✅ | ⚠️ |
| v2.x (Docs) | ✅ | ✅ | ✅ |
.with().예시로 HTTP 요청을 사용하여 일부 데이터를 가져오는 프론트엔드 애플리케이션을 위한 상태 리듀서를 만들어보겠습니다.
애플리케이션에 다음과 같은 상태가 존재한다고 가정해보겠습니다.
type State =
| { status: 'idle' }
| { status: 'loading'; startTime: number }
| { status: 'success'; data: string }
| { status: 'error'; error: Error };
type Event =
| { type: 'fetch' }
| { type: 'success'; data: string }
| { type: 'error'; error: Error }
| { type: 'cancel' };
애플리케이션은 현재 fetch,success,error,cancel 와 같이 4개의 이벤트를 처리할 수 있습니다. 하지만 cancel 요청이 loading 상태일 때만 유효한 것처럼 각 상태에서 이 4개의 이벤트 중 일부의 이벤트만 유효할 수 있습니다. 버그로 이어질 수 있는 원치 않는 상태 변경을 막기 위해 우리는 각 상태와 이벤트를 매칭시키는 리듀서를 작성할 수 있습니다.
아래 예시는 match가 유용하게 쓰이는 예시입니다. 중첩된 switch 문을 쓰는 대신 같은 일을 아주 선언적인 방식으로 해낼 수 있습니다.
import { match, P } from 'ts-pattern';
const reducer = (state: State, event: Event): State =>
match<[State, Event], State>([state, event])
.with(
[{ status: 'loading' }, { type: 'success' }],
([, event]) => ({
status: 'success',
data: event.data,
})
)
.with(
[{ status: 'loading' }, { type: 'error', error: P.select() }],
(error) => ({
status: 'error',
error,
})
)
.with(
[{ status: P.not('loading') }, { type: 'fetch' }],
() => ({
status: 'loading',
startTime: Date.now(),
})
)
.with(
[
{
status: 'loading',
startTime: P.when((t) => t + 2000 < Date.now()),
},
{ type: 'cancel' },
],
() => ({
status: 'idle',
})
)
.with(P._, () => state)
.exhaustive();
이 내용을 조금씩 살펴봅시다.
match는 값을 취하고, 패턴 매칭 케이스를 추가할 수 있는 빌더를 반환합니다.
match<[State,Event]>, State>([state, event]);
여기에서 상태 및 이벤트 객체를 배열로 감싸고, 타입을 명시적으로 지정하여 TypeScript에서 Tuple[State, Event]로 해석되게끔 하므로 각 값에 대해 개별적으로 매칭시킬 수 있습니다.
하지만 대부분의 경우 match가 두 유형을 모두 추론할 수 있으므로, 입출력 유형을 위에서와 같이 match<Input, Output>(...) 형식으로 지정할 필요가 없습니다.
첫 번째 with절을 살펴봅시다.
.with(
[{ status: 'loading' }, { type: 'success' }],
([state, event]) => ({
// `state` is inferred as { status: 'loading' }
// `event` is inferred as { type: 'success', data: string }
status: 'success',
data: event.data,
})
)
두 번째 with절에서 사용된 P.select 함수를 살펴봅시다.
.with(
[
{ status: 'loading' },
{ type: 'error', error: P.select() }
],
(error) => ({
status: 'error',
error,
})
)
P.select()를 이용하면 입력 값의 일부를 추출하여 핸들러에 주입할 수 있습니다. 핸들러에서 입력을 구조화하는 번거로움을 피할 수 있어 깊은 데이터 구조에서 유용하게 사용될 수 있습니다.
여기에서는 P.select()에 아무 이름도 넘겨주지 않았으므로, event.error 프로퍼티는 핸들러함수에 대한 첫번째 인수로 주입될 것입니다. 이때, 핸들러 함수의 두 번째 인수로 앞서 좁혀진 유형이 들어가기 때문에 여기에서도 입력 값에 정상적으로 접근할 수 있습니다.
.with(
[
{ status: 'loading' },
{ type: 'error', error: P.select() }
],
(error, stateAndEvent) => {
// error: Error
// stateAndEvent: [{ status: 'loading' }, { type: 'error', error: Error }]
}
)
P.select()에 아무 이름도 넘겨주지 않는 익명 selection은 단 하나만 가질 수 있습니다. 입력 자료구조에서 더 많은 속성을 선택해야한다면, 이름을 지정해야합니다.
.with(
[
{ status: 'success', data: P.select('prevData') },
{ type: 'error', error: P.select('err') }
],
({ prevData, err }) => {
// Do something with (prevData: string) and (err: Error).
}
)
명명된 각 selection은 핸들러 함수의 첫번째 인자인 selections 객체에 주입됩니다.
.with(
[
{ status: 'success', data: P.select('prevData') },
{ type: 'error', error: P.select('err') }
],
({ prevData, err }) => {
// Do something with (prevData: string) and (err: Error).
}
)
특정 값을 제외한 모든 값에 매칭시켜야한다면, P.not(<pattern>)을 사용할 수 있습니다. 이 함수는 패턴을 인자로 받고, 그 반대를 리턴합니다.
입력한 값이 패턴으로 표현할 수 없는 조건에 해당하는지 확인해야하는 경우 가드 함수를 사용할 수 있습니다. 예를 들어 다음과 같이 숫자가 양수인지 확인해야할 때 값을 받아 양수인지 여부를 boolean으로 반환하는 가드함수가 존재할 수 있습니다.
가드 함수는 다음과 같은 두 가지 방식으로 사용할 수 있습니다.
1. P.when(<guard function>) 형태
2. .with(...)의 두 번째 파라미터로 넘겨주는 형태
먼저 1. P.when(<guard function>) 형태를 봅시다.
.with(
[
{
status: 'loading',
startTime: P.when((t) => t + 2000 < Date.now()),
},
{ type: 'cancel' },
],
() => ({
status: 'idle',
})
)
다음 2. .with(...)의 두 번째 파라미터로 넘겨주는 형태입니다.
.with(
[{ status: 'loading' }, { type: 'cancel' }],
([state, event]) => state.startTime + 2000 < Date.now(),
() => ({
status: 'idle'
})
)
패턴은 가드 함수가 true를 리턴할 때만 매칭이 될 것입니다.
P._는 어떤 값과도 매칭될 수 있습니다. 최상위 수준 또는 다른 패턴 내부에서 사용할 수 있습니다.
.with(P._, () => state)
// You could also use it inside another pattern:
.with([P._, P._], () => state)
// at any level:
.with([P._, { type: P._ }], () => state)
.exhaustive()는 패턴 매칭 표현식을 실행하고 결과를 반환합니다. 또 철저한 검증이 가능하여 입력 값에서 가능한 모든 케이스들을 놓치지 않게 해줍니다. with에서 커버하지 못한 케이스가 발생한다면 에러를 발생시킵니다. 이러한 추가적인 type safty는 우리가 흔히 할 수 있는, 특정 케이스를 놓치는 실수를 막아주기 때문에 아주 유용합니다.
참고로 exhaustive 패턴 매칭은 필수가 아닙니다. 타입 체커가 해야할 일이 더 많아져 컴파일 시간이 길어질 수 있다는 단점또한 가지고 있습니다.
위 함수 대신 default 값을 반환하는 .otherwise()를 사용할 수도 있습니다.
.otherwise(handler)는 .with(P._, handler).exhausetive()와 동일하게 동작합니다.
.exhaustive()를 사용하고 싶지 않고, .otherwise()를 이용하여 default 값을 제공하고 싶지도 않다면, 대신 .run()을 사용할 수 있습니다. .exhaustive()와 비슷하지만, unsafe하고 입력 값과 일치하는 분기가 없으면 런타임 오류가 발생할 수 있습니다.
switch문을 사용한 다음과 같은 분기 처리가 있다고 한다면,
switch (type) {
case 'text':
case 'span':
case 'p':
return 'text';
case 'btn':
case 'button':
return 'button';
}
우리는 같은 작업을 ts-pattern을 통해 선언적으로 수행할 수 있습니다.
const sanitize = (name: string) =>
match(name)
.with('text', 'span', 'p', () => 'text')
.with('btn', 'button', () => 'button')
.otherwise(() => name);
sanitize('span'); // 'text'
sanitize('p'); // 'text'
sanitize('button'); // 'button'
지금까지 ts-pattern에 대해 알아보았습니다.
이외 자세한 API 스펙이 궁금하다면 아래 링크를 참고하세요.
https://github.com/gvergnaud/ts-pattern