TS-Pattern 튜토리얼

Dahee Kim·2022년 8월 10일

TS Pattern을 파헤쳐봅시다.
https://github.com/gvergnaud/ts-pattern

이 글은 ts-pattern github repository의 readme 일부를 개인적 용도로 번역한 것입니다.
오역된 부분이 있을 수 있으며, 자세한 spec은 반드시 공식 문서를 확인하시기 바랍니다.


About TS-Pattern

The exhaustive Pattern Matching library for TypeScript with smart type inference.
스마트 유형 추론이 포함된 TypeScript 용 철저한 패턴 매칭 라이브러리

ts-pattern을 이용하여 더 안전하고 나은 조건을 작성해보세요. 패턴 매칭을 사용하면 복잡한 조건을 간결한 단일 조건식으로 표현할 수 있습니다. 코드가 더 짧아지고, 가독성도 높일 수 있습니다. 또한 철저한 검사를 통해 모든 경우의 수를 놓치지 않도록 도와줍니다.

Features

  • 모든 자료구조에 대한 패턴 매칭을 지원합니다. (중첩 Object, Array, Tuple, Set, Map...등 모든 기본 유형)
  • 유용한 타입 추론을 포함하며, 타입세이프합니다.
  • 철저한 checking을 지원합니다. - .exhaustive()를 통해 모든 가능한 경우의 수를 매칭할 수 있도록 강제합니다.
  • isMatching과 함께 패턴을 사용하여 데이터의 형태를 검증합니다.
  • 유형별 와일드 카드를 가지고 있으며, 포괄적인 표현형 API를 가지고 있습니다. (P._, P.string, P.number 등...)
  • 중요하지 않은 케이스를 위한 술어, 합집합, 교차, 제외 패턴을 제공합니다.
  • P.select(name) 기능을 통해 속성 선택을 지원합니다.
  • 번들 사이즈가 작습니다. (1.6kB)

What is Pattern Matching?

패턴 매칭은 선언적인 방식으로 값의 구조를 조사할 수 있는 코드 분기 기술입니다. 함수형 프로그래밍 언어로부터 나왔습니다. 패턴 매칭은 특히 복잡한 데이터 구조나 여러 값에서 분기를 할 때 명령형 대안 (if/else/switch문)보다 덜 장황하고 더 강력합니다.

패턴매칭은 Haskell, Rust, Swift, Elixir 및 기타 여러 언어로 구현되었습니다. ES 사양에 패턴 매칭을 추가하기 위한 제안이 존재하지만, 아직 1단계에 있으며 빠른 시일 내에 ES사양에 정식으로 합류하기는 어려운 상황입니다. 하지만 운 좋게도 패턴 매칭은 사용자 영역에서 구현이 가능하고, ts-pattern라이브러리는 타입세이프한 패턴 매칭의 구현을 제공합니다.

Install

# via npm
$ npm install ts-pattern

# via yarn
$ yarn add ts-pattern

TS version 호환성

참고: TS-Pattern은 tsconfig.json파일에서 Strict Mode가 활성화되어 있다고 가정합니다.

ts-patternTypeScript v4.5+TypeScript v4.2+TypeScript v4.1+
v4.x (Docs) (Migration Guide)
v3.x (Docs)⚠️
v2.x (Docs)
  • ✅ Full support
  • ⚠️ Partial support, All features except passing multiple patterns to .with().
  • ❌ Not supported

Getting Started

예시로 HTTP 요청을 사용하여 일부 데이터를 가져오는 프론트엔드 애플리케이션을 위한 상태 리듀서를 만들어보겠습니다.

EX) ts-pattern이 있는 상태 리듀서

애플리케이션에 다음과 같은 상태가 존재한다고 가정해보겠습니다.

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(value)

match는 값을 취하고, 패턴 매칭 케이스를 추가할 수 있는 빌더를 반환합니다.

match<[State,Event]>, State>([state, event]);

여기에서 상태 및 이벤트 객체를 배열로 감싸고, 타입을 명시적으로 지정하여 TypeScript에서 Tuple[State, Event]로 해석되게끔 하므로 각 값에 대해 개별적으로 매칭시킬 수 있습니다.

하지만 대부분의 경우 match가 두 유형을 모두 추론할 수 있으므로, 입출력 유형을 위에서와 같이 match<Input, Output>(...) 형식으로 지정할 필요가 없습니다.

with(pattern, handler)

첫 번째 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,
    })
  )
  • 첫 번째 인수 : pattern 입니다. pattern은 이 분기에서 우리가 기대하는 값의 모양이라고 할 수 있습니다.
  • 두 번째 인수 : 핸들러 함수입니다. 입력 값이 패턴과 일치하면 호출될 코드 분기입니다. 핸들러 함수는 패턴이 매칭된 입력 값을 첫번째 파라미터로 받습니다.

P.select(name?)

두 번째 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)

특정 값을 제외한 모든 값에 매칭시켜야한다면, P.not(<pattern>)을 사용할 수 있습니다. 이 함수는 패턴을 인자로 받고, 그 반대를 리턴합니다.

P.when() 와 가드 함수

입력한 값이 패턴으로 표현할 수 없는 조건에 해당하는지 확인해야하는 경우 가드 함수를 사용할 수 있습니다. 예를 들어 다음과 같이 숫자가 양수인지 확인해야할 때 값을 받아 양수인지 여부를 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._ wildcard

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(), otherwise(), run()

  • .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

profile
하루가 너무 짧아~

0개의 댓글