Search
🏷️

[Typescript] Discriminated Unions 톺아보기

A common technique for working with unions is to have a single field which uses literal types which you can use to let TypeScript narrow down the possible current type. - Typescript Docs

1. Intro

Discsriminated Union (Tagged Union)를 일전에 유심히 눈여겨 보고, 기회가 된다면 실무에 적용을 해보고 싶었다. 특히, 인터페이스 내 특정 공통 필드를 통해, 서로 다른 타입을 선언하여 코드를 인터페이스나 코드 구조를 굉장히 유연하게 작성할 수 있어서 매력적으로 느껴졌다.
억지로 해당 패턴을 도입하기보다는, 적절한 상황에서 쓰고 싶었는데, 이번에 적절한 쓰임을 찾아서 도입을 잘 할 수 있었다. 그리고 이참에 해당 패턴에 대한 내용을 정리해보고자 한다.

2. 정의

여러개의 유니언 타입 중 하나의 공통 필드 (tag type)을 사용하여 사용하여 구분하는 방법이다. 해당 필드를 활용하여 각 유니언 타입의 고유한 필드에 접근할 수 있으면 Type Safe하게 작성할 수 있게 도와주는 기법이다.
Tagged Unions라고도 불리며, 이를 활용하면 코드를 더욱 안전하고 직관적이며 유지보수하기 쉽게 작성할 수 있다.

3. 간단 예시

공통 필드 typeliteral type으로 사용하여 switch 구문에서 분기 조건을 적용하여 다른 로직을 동작시킬 수 있다.
interface Cat { type: "cat"; name: string; purrs: boolean; } interface Dog { type: "dog"; name: string; barks: boolean; } type Animal = Cat | Dog;
TypeScript
복사
function makeSound(animal: Animal) { switch (animal.type) { case "cat": console.log(animal.name + " purrs"); break; case "dog": console.log(animal.name + " barks"); break; default: throw new Error("Unexpected object: " + x); } }
TypeScript
복사

4. 실전 예시

실무에서는 특정 유저간의 관계 및 행동을 검사하기 위한 Global Validator 모듈을 구현하는 것이 목표였다. 이때, 유저간의 관계는 다양한 형태로 나누어져 있으며, 관계 종류에 따라 validate를 할때 필요한 함수 시그니처가 다른 상황이었다.
동일한 코드를 블로그에 공유할 순 없지만, 유사한 형태로 예시를 보여주고자 한다.
관계(행동) 종류 (tag type) - Discriminated unions에서 공통으로 사용하는 필드
sendFriendRequest - 친구 신청 보내기
acceptFriendRequest - 친구 신청 수락하기
joinChatRoom - 채팅룸에 참여하기
leaveChatRoom - 채팅룸에 나가기
위 관계 종류에 따라, validate하는 로직은 상이하다. 따라서 이를 위한 함수 시그니처 (input)도 상이할 것이다. 이를 Discriminated Unions로 구현하면 다음과 같다.
각 인터페이스는 공통 필드인 type을 갖고 있음
각 인터페이스 마다 data 필드의 구조는 상이하며, 해당 필드는 실제 validate 함수를 동작시키기 위한 input이 담긴 데이터 필드로 활용
이를 바탕으로 validateRelation 라는 single entry point 함수를 만들고, 내부에서 switch 조건에 따라 각 관계 종류에 맞는 고유한 validation 로직을 호출하여 동작시키는 코드를 구현할 수 있다.
interface ValidateSendFriendRequestDto { type: 'sendFriendRequest'; data: { userId: number; counterPartUserId: number; }; } interface ValidateAcceptFriendRequestDto { type: 'acceptFriendRequest'; data: { userId: number; counterPartUserId: number; friendRequestId: number; }; } interface ValidateJoinChatRoomDto { type: 'joinChatRoom'; data: { userId: number; counterPartUserId: number; chatRoomId: number; chatRoomUrl: string; }; } interface ValidateLeaveChatRoomDto { type: 'leaveChatRoom'; data: { userId: number; counterPartUserId: number; chatRoomId: number; chatRoomState: string; }; } type ValidateRelationDto = | ValidateSendFriendRequestDto | ValidateAcceptFriendRequestDto | ValidateJoinChatRoomDto | ValidateLeaveChatRoomDto;
TypeScript
복사
export async function validateRelation(dto: ValidateRelationDto) { switch (dto.type) { case 'sendFriendRequest': { await validateSendFriendRequest(dto); break; } case 'acceptFriendRequest': { await validateReceiveFriendRequest(dto); break; } case 'joinChatRoom': { await validateJoinChatRoom(dto); break; } case 'leaveChatRoom': { await validateLeaveChatRoom(dto); break; } default: { throw new Error('relation type is not valid'); } } } async function validateSendFriendRequest(dto: ValidateSendFriendRequestDto) { // TO BE IMPLEMENTED } async function validateReceiveFriendRequest(dto: ValidateAcceptFriendRequestDto) { // TO BE IMPLEMENTED } async function validateJoinChatRoom(dto: ValidateJoinChatRoomDto) { // TO BE IMPLEMENTED } async function validateLeaveChatRoom(dto: ValidateLeaveChatRoomDto) { // TO BE IMPLEMENTED }
TypeScript
복사