PickOne 커스텀 유틸리티 타입 구현하기

박희수·2024년 5월 9일
0

🙋‍♀️ 이 포스트는 '우아한 타입스크립트 with React' 도서를 읽고 적은 글입니다.


타입스크립트에는 서로 다른 2개 이상의 객체를 유니온 타입으로 받을 때 타입 검사가 제대로 진행되지 않는 이슈가 있습니다.

바로 예시를 보겠습니다.

 type Card = {
	card : string
 }
 
 type Account = {
  	account : stirng;
 }
 
 function withdraw(type : Card | Account) {
  	...
 }
 
 withdraw({ card : 'hyundai', account : 'hana'});

🚩 위 코드와 같이 Card, Account 중 하나의 객체만 받고 싶은 상황에서 Card | Account로 타입을 작성하면 의도한 대로 타입 검사가 이뤄지지 않습니다.
withdraw 함수의 인자로 card와 account 속성을 모두 포함해 받아도 타입 에러가 발생하지 않습니다.

이는 집합 관점에서 바라볼 때, 유니온은 합집합이 되기 때문입니다.
즉, Card 객체와 Account를 합친 형태로 보고 있기 때문에 속성이 하나씩만 할당된 상태도 허용하지만 card와 account 속성을 모두 포함해도 에러가 발생하지 않는 것입니다.

이런 문제를 해결하기 위해 타입스크립트에서는 식별할 수 있는 유니온 기법을 자주 활용합니다.


해결1. 식별할 수 있는 유니온

식별할 수 있는 유니온은 각 타입에 type이라는 공통된 속성을 추가하여 구분짓는 방법입니다.

type Card = {
 type : 'Card';
 card : string;
}

type Account = {
 type : 'account';
 account : string;
}

function withdraw(type : Card | Account) {
 ...
}

withdraw({ type : 'card', card : 'hyundai' });
withdraw({ type : 'account', account : 'hana' });

🚩 위 코드를 보면, Card, Account를 구분할 수 있도록 type이라는 속성을 추가했습니다.
그리고 withdraw 함수를 사용하는 곳에 type을 기준으로 객체를 구분해 정확한 타입을 추론할 수 있습니다. 이 방법은 기존의 문제를 해결해주지만 type을 다 넣어줘야 한다는 불편함이 생깁니다.


해결2. 옵셔널 + undefined로 타입 지정

현재 발생한 문제를 해결하기 위해 구현해야 하는 타입의 형태는 account 또는 card 속성 하나만 존재하는 객체를 받는 타입입니다.

account일 때는 card를 받지 못하고, card일 때는 account를 받지 못하게 하려면 하나의 속성이 들어왔을 때 다른 타입을 옵셔널한 undefined 값으로 지정하는 방법을 생각해볼 수 있습니다.

{ account : string; card? : undefined } | { account? : undefined; card : string } 

위와 같이 옵셔널 + undefined로 타입을 지정하면 사용자가 의도적으로 undefined 값을 넣지 않는 이상, 원치 않는 속성에 값을 넣었을 때 타입 에러가 발생할 것입니다.


해결3. 커스텀 유틸리티 타입으로 구현하기

2번 방법을 요약하자면, 선택하고자 하는 하나의 속성을 제외한 나머지 값을 옵셔널 타입 + undefined로 설정하면 원하고자 하는 속성만 받도록 구현할 수 있다는 것입니다.

이를 커스텀 유틸리티 타입으로 구현해보면 아래와 같습니다.

type PickOne<T> = {
 	[P in keyof T] : Record<P, T[P]> & Partail<Record<Exclude<keyof T,P>, undefined>>; 
}[keyof T];

위 코드를 차근차근 뜯어보도록 하겠습니다.


🔺 One<T>
type One<T> = { [P in keyof T] : Record<P, T[P]> }[keyof T];
  • [P in keyof T]: T의 각 키를 P로 순회하면서, 각 키에 대해 새로운 타입을 정의합니다.
  • Reacord<P, T[P]>: 각 키 P와 그에 해당하는 타입 T[P]를 사용하여 P키 하나만을 가지고, 그 값의 타입이 T[P]인 객체 타입을 만듭니다.
  • {[P in keyof T]: Record<P, T[P]> } : T의 각 키 P를 순회하며, 각 키에 대해 해당 키와 그 키의 타입을 가지는 Record 타입의 객체를 생성합니다.
  • [keyof T] : T의 키에 해당하는 유니온 타입에 따라 인덱싱된 타입을 선택합니다. 즉, T의 각 키에 대해 만들어진 Record 객체 타입 중 하나를 선택하는 유니온 타입을 생성합니다.

위의 내용을 종합한 예시를 들자면, T가 { a: number; b : string }이라면, { [P i keyof T] : Record<P, T[P]> }는 두 개의 타입 { a: {a: number} } , { b: {b: string} }을 생성하고 [keyof T]를 통해 이 두 타입의 유니온 타입 { a: {a: number} } | { b: {b: string} }을 최종결과로 반환합니다.


🔺 ExcludeOne<T>
type ExcludeOne<T> = { 
	[P in keyof T] : Partial<Record<Exclude<keyof T, P>, undefined>>
}[keyof T];
  • Exclude<keyof T, P>: T 객체가 가진 키 값에서 P 타입과 일치하는 키값을 제외합니다.
    이 타입을 A라고 가정합니다.
  • Record<A, undefined>: 키로 A 타입을, 값으로 undefined를 갖는 레코드 타입입니다. 즉, 전달 받은 객체 타입을 모두 {[key] : undefined} 형태로 만듭니다. 이 타입을 B라고 가정합니다.
  • Partial: B타입을 옵셔널로 만듭니다. 따라서 {[key]? : undefined}와 같습니다.

우리가 결론적으로 얻고자 하는 타입은 속성하나와 나머지는 옵셔널 + undefined인 타입이기 때문에 앞의 속성을 활용해 PickOne 타입을 표현할 수 있습니다.

이제 다시 처음으로 돌아가 Card와 Account를 수정해 보겠습니다.

type PickOne<T> = One<T> & ExcludeOne<T>
[P in keyof T] : Record<P, T[P]> & Partial<Record<Exclude<keyof T, P>, undefined>>

type Card = { card : string};
type Accoung = { account : string };
type CardOrAccount = PickOne<Card & Account>;

function withdraw (type : CardOrAccount) {
 ...
}

withdraw({ card : 'hyundai', account : 'hana' }); // 에러 발생 
profile
프론트엔드 개발자입니다 :)

0개의 댓글