ts-pattern은?
프로그래밍에서는 패턴 매칭이라는 개념이 존재합니다.
데이터가 특정한 패턴(값, 자료구조, 타입, 함수)에 일치하는지 따지는 것을 통하여
대상을 특정하는 기술을 패턴 매칭이라고 지칭하는데요
ts-pattern은 타입스크립트 생태계에서 이러한 패턴 매칭을 구현한 라이브러리입니다.
프로그래밍 세계에서 분기는 필연적으로 필요한 존재입니다.
우리는 분기가 존재하지 않는 프로그램을 상상할 수 있을까요?
그런데 우리는 복잡한 요구사항에 의해 매우 복잡한 분기를 작성하게 되곤 합니다.
예를 들어 이런 코드를 상상해봅시다.
const complexFunction = (
first: string | number | null | undefined | { text: string },
second: { text: string } | null | undefined | string,
) => {
if (typeof first === 'number') {
if (typeof second === 'string') {
return first + second;
} else if (typeof second === 'object') {
if (typeof second?.text === 'string') {
return second.text;
}
} else if (!first && !second) {
throw new Error('invalidated argument');
} else {
return '안녕하세요';
}
}
};
정말 끔찍하기 짝이 없습니다.
그런데 더 끔찍한 일은 비즈니스로직에 집중하여 생각을 하다보면
쉽게 위와같은 중첩 if, else if문이 나오게된다는 것입니다.
우리가 코드를 작성할 때에는 그 코드를 구석구석 이해하고 있기 때문에
비교적 쉽게 코드를 작성할 수 있지만 나중에 그 코드를 읽게될 다른 개발자 / 혹은 미래의 나는
이 코드가 대체 무슨 일을 수행하는 것인지 알아 내기 위하여 오랜 시간이 필요할 것입니다.
제가 다니는 회사에서도 이런 비즈니스 친화(?) 적인 코드들을 많이 찾아볼 수 있었는데요
여러가지 조건과 값들을 기반으로 복잡한 분기가 많이 들어가다보니
코드를 건드리기가 고통스러워지기 쉬웠습니다.
이런 복잡한 비즈니스 로직들을 처리하는 코드를 만들다보면
아무리 코드를 깔끔하게 유지하고자해도 가독성이 망가지기 쉬웠습니다.
ts-pattern을 도입하면 이렇게 까다로운 분기들이 적용되는 코드들을
선언적으로 관리할 수 있어지는데요
예제 코드를 작성해보면서 간단하게 이해해봅시다.
예제 코드를 치면서 이해해보기
import { match, P } from 'ts-pattern';
type Data =
| { type: 'text'; content: string }
| { type: 'img'; src: string };
type Result =
| { type: 'ok'; data: Data }
| { type: 'error'; error: Error };
const result: Result = ...;
const html = match(result)
.with({ type: 'error' }, () => <p>Oups! An error occured</p>)
.with({ type: 'ok', data: { type: 'text' } }, (res) => <p>{res.data.content}</p>)
.with({ type: 'ok', data: { type: 'img', src: P.select() } }, (src) => <img src={src} />)
.exhaustive();
다음 코드는 ts-pattern의 리드미에서 제일 먼저 찾아볼 수 있는 예제입니다.
match와 P를 사용하는 것을 볼 수 있는데요
위 코드를 참고하며 가장 기본이 되는 match 함수만을 다루는 간단한 예제를 만들어보겠습니다.
npm i ts-pattern
타입스크립트가 설치되어있는 프로젝트에서 위와 같이 ts-pattern을 설치하겠습니다.
match, with의 첫번째 인수는 match 함수의 첫번째 제네릭 타입이 들어가게되며
match 함수의 두번째 제네릭 타입을 with 함수의 리턴타입을 의미하게됩니다.
with 함수는 첫번째 인수에서는 조건을 설정하게되며
두번째 인수에는 해당 조건에서 실행할 함수를 전달합니다.
즉 이런식으로 생각할 수 있습니다.
const ifFunction = (item: { type: 'ok' | 'error' }) => {
if (item.type === 'ok') {
return `${item.type}오케이네요`;
}
if (item.type === 'error') {
return `${item.type}에러네요`;
}
return '이거나머지네요';
};
이러한 if문은 ts-pattern에서는
const patternFunction = (item: { type: 'ok' | 'error' }) =>
match(item)
.with({ type: 'ok' }, (state) => {
return `${state.type}오케이네요`;
})
.with({ type: 'error' }, (state) => {
return `${state.type}에러네요`;
})
.otherwise(() => {
return '이거나머지네요';
});
이렇게 대체할 수 있습니다.
지금과 같이 간단한 조건을 작성할때에는 강력함을 느끼기 힘들지만
복잡한 분기를 처리해야하는 경우에는 아주 유용합니다.
이제 본격적인 예제를 작성해보겠습니다.
먼저 패턴 매칭을 위한 데이터 타입과 패턴매칭을 수행할 빈 함수를 정의하겠습니다.
export type Data =
| { type: 'text'; content: string }
| { type: 'img'; src: string };
export type Result =
| { type: 'ok'; data: Data }
| { type: 'error'; error: Error };
export const resultReducer = () => {}
Result 타입은 항상 'ok' | 'error' 둘중에 하나의 타입을 지니며
타입이 에러일때에는 data 프로퍼티 대신 error 프로퍼티를 가집니다.
유니온 타입을 이용하면 이와 같이 특정 프로퍼티의 값을 통해 타입을 좁힐 수 있습니다.
type프로퍼티의 값이 ok인 경우 data 프로퍼티를 가진다는 것을 추론할 수 있기 때문입니다.
이제 test 파일을 작성해주겠습니다.
굳이 테스트를 돌려보고 싶지 않으신 분들은 생략하셔도 좋습니다
import { Result, resultReducer } from './pattern';
describe('pattern 라이브러리를 학습합니다.', () => {
it('데이터 타입이 텍스트인 경우를 테스트해봅시다.', () => {
const result: Result = {
type: 'ok',
data: {
type: 'text',
content: '안녕하세요',
},
};
const data = resultReducer(result);
expect(data).toBe('이거텍스트네요');
});
it('데이터 타입이 이미지인 경우를 테스트해봅시다.', () => {
const result: Result = {
type: 'ok',
data: {
type: 'img',
src: '',
},
};
const data = resultReducer(result);
expect(data).toBe('이거이미지네요');
});
it('타입이 에러인 경우를 테스트해봅시다.', () => {
const result: Result = {
type: 'error',
error: new Error('error'),
};
const data = resultReducer(result);
expect(data).toBe('이거에러네요');
});
});
이제 이 테스트를 통과하는 매치함수를 작성해봅시다
import { match } from 'ts-pattern';
export type Data =
| { type: 'text'; content: string }
| { type: 'img'; src: string };
export type Result =
| { type: 'ok'; data: Data }
| { type: 'error'; error: Error };
export const resultReducer = (item: Result) =>
match<Result, string>(item)
.with({ type: 'ok', data: { type: 'img' } }, (state) => {
return '이거이미지네요';
})
.with({ type: 'ok', data: { type: 'text' } }, (state) => {
return '이거텍스트네요';
})
.with({ type: 'error' }, (state) => {
return '이거에러네요';
})
.otherwise((state) => {
return '이거나머지네요';
});
그외에도 여기서 소개하지 않은 수많은 기능들과 타입스크립트 친화적인 타이핑 등
간단히 사용해본 입장에서도 매우 매력적인 라이브러리입니다.
단점은?
근데 이게 진짜 개좋은거면 왜 안씀?
당연히 다 써야하는 거 아님? 이라는 생각이 들 수 있습니다.
물론 패턴매칭이라는 개념이 주는 아름다운 코드와..
라이브러리의 엄청나게 높은 테스트 커버리지를 보면
버그에 대한 안정성도 어느정도 확보되어있지만
라이브러리라는 특성이 주는 문제가 걸리게됩니다.
제가 생각하기에 ts-pattern을 도입하기 망설여지는 이유는 다음과 같습니다.
1. 이게 진짜 if문에 비해 훨씬 좋은가?
ts-pattern을 적절히 도입하기 위해서는 다음과 같은 전제조건이 필요합니다.
-> 충분히 복잡한 분기를 처리하고 있을 것
-> 팀원들이 이 복잡한 분기를 처리하는 것이 어렵다는 공감대를 가질것
즉 if문과 switch문과 같이 전통적인 방식으로도 이미 잘 처리하고 있다라고 생각한다면
라이브러리를 도입할 필요성 자체가 없습니다.
2. 패턴매칭과 라이브러리 사용법에 대한 이해가 필요함
패턴 매칭이 제공하는 강력함을 잘 활용하기 위해서는 학습비용이 필요합니다.
이 학습비용을 감내할 만큼 패턴 매칭이 필요한 곳이라면 물론 사용해야겠지만
그렇지 않다면 필요없는 선택일것입니다.
3. 외부 종속성이 내 코드와 결합될 가능성이 높아짐
if문과 switch문은 자바스크립트 기본 문법이기에 어느 환경에서나 돌아갈 수 있습니다
하지만 ts-pattern은 ts-pattern이 없는 환경에서는 돌아갈 수 없습니다.
또한 if문과 같은 분기처리 코드는 정말 많이 모든 곳에서 사용되기 때문에
이런 분기처리 코드들에 대한 처리를 외부 종속성에 위임한다는 것이 부담되는 것 같습니다.
마치며
ts-pattern 라이브러리의 완성도와는 별개로 이러한 부분때문에
내 코드에 적용하기에는 망설임이 생기는 것 같습니다.
개인적인 생각으로는 어서 자바스크립트 정규 문법으로 편입되어
걱정없이 패턴매칭을 사용하는 세상이 오면 참 좋을 것 같네요
'typescript' 카테고리의 다른 글
storybook argTypes 추론이 안되는 문제 (0) | 2024.04.09 |
---|---|
혹시 이런 타입 없었나 생각해보신 적 없으신가요? (1) | 2024.01.10 |
typescript any 와 unknown의 차이 (0) | 2023.08.13 |
왜 타입스크립트는 DOM 요소를 확신하지 못할까? (0) | 2023.06.02 |
useState가 반환하는 setState의 타입은 어떻게 설정할까? (0) | 2023.05.14 |