원문 : https://egghead.io/blog/using-branded-types-in-typescript
여러분은 아마 코드 베이스의 신뢰성과 안정성을 높이기 위해 타입스크립트를 사용한다는 것을 이미 알고 있을 것 입니다. 하지만, 추가로 더 개선을 해볼 수 있습니다!
이러한 신뢰도를 높일 수 있는 개념 중 하나가 Branded Types 입니다.
이 개념은 기본 원시 타입을 넘어 데이터를 모델링하기 위한 더 깊은 특수성과 고유성을 생성하는 방법을 제공합니다.
이 글에서는 branded types가 무엇인지, 어떻게 사용하는지, 그리고 몇 가지 사용 사례를 알아보고 복습을 위한 도전과제를 살펴봅니다.
문제
타입스크립트는 애플리케이션 흐름에 맞는 올바른 데이터가 전달될 수 있도록 도와주지만, 이 데이터가 충분히 구체적이지 않은 경우가 있습니다. 사용자가 여러 개의 다른 데이터 구조를 소유하는 아래와 같은 시나리오를 상상해 봅시다.
type User = {
id: string
name: string
}
type Post = {
id: string
ownerId: string;
comments: Comments[]
}
type Comments = {
id: string
timestamp: string
body: string
authorId: string
}
이 예시는 User 타입과 Post 타입 간의 관계를 정의하고 있으며 하나의 Post에는 User가 작성한 많은 Comments들이 있을 수 있습니다.
여기서 문제는 각 객체를 식별하는 프로퍼티에 있습니다. User[“id”], Post[“id”], Comments[“id”] 는 모두 string 타입입니다. 그래서 타입스크립트에서는 모두 동일한 타입이고 서로 상호교환도 가능하기 때문에 잘못된 데이터를 전달해도 타입스크립트가 잡아낼 수 없습니다.
async function getCommentsForPost(postId: string, authorId: string) {
const response = await api.get(`/author/${authorId}/posts/${postId}/comments`)
return response.data
}
const comments = await getCommentsForPost(user.id, post.id) // 타입에러가 나지 않습니다.
위 코드는 postId와 authorId 두 개의 인자를 받는 함수의 예시입니다. 두 인자 다 string 타입을 인자로 받습니다. 하지만 여기에 오류가 있는데 혹시 여러분은 발견하셨나요? getCommentsForPost 함수를 호출할 때 넘겨주는 인자의 순서를 반대로 전달했지만, 타입스크립트에서는 두 인자 모두 같은 타입이므로 오류가 발생하지 않습니다.
위 함수는 런타임에서 잘못된 응답을 가져오는데, 어떻게 해야 타입스크립트 내에서 이 오류를 포착할 수 있을까요? 우리는 다양한 타입의 ID를 명확하게 지정할 수 있는 방법이 필요합니다.
Branded Types?
이러한 경우에 Branded Types가 사용될 수 있습니다. 이 아이디어는 기존 타입에 프로퍼티나 라벨들을 추가하여 보다 명확하고 구체적인 새로운 데이터 타입을 만드는 방법입니다.
Branded Types는 기본 타입과 brand 프로퍼티에 있는 객체 리터럴을 결합하여 타입에 추가할 수 있습니다. 아래는 예시입니다.
type Brand<K, T> = K & { __brand: T }
type UserID = Brand<string, "UserId">
type PostID = Brand<string, "PostId">
이렇게 하면 UserId 라고 불리는 새로운 타입이 생성됩니다. 이 brand 타입의 변수는 사용하는 곳에서 특정 타입과 일치해야 합니다. Brand
헬퍼 타입을 사용하여 이전 예제를 다시 작성해 보겠습니다.
type UserID = Brand<string, "UserId">
type PostID = Brand<string, "PostId">
type CommentID = Brand<string, "CommentId">
type User = {
id: UserID;
name: string
}
type Post = {
id: PostID;
ownerId: string;
comments: Comments[]
}
type Comments = {
id: CommentID
timestamp: string
body: string
authorId: UserID
}
async function getCommentsForPost(postId: PostID, authorId: UserID) {
const response = await api.get(`/author/${authorId}/posts/${postId}/comments`)
return response.data
}
const comments = await getCommentsForPost(user.id,post.id) // ❌ PostID 타입이 와야하는데 user.id는 UserID 타입이라서 오류가 발생합니다.
// ^Argument of type 'UserID' is not assignable to parameter of type 'PostID'.
// Type 'UserID' is not assignable to type '{ __brand: "PostId"; }'.
// Types of property '__brand' are incompatible.
// Type '"UserId"' is not assignable to type '"PostId"'.
이 예제는 타입스크립트 플레이그라운드 내에서도 볼 수 있습니다.
이는 현재 문제에 좋은 해결책이지만 몇 가지 단점이 있습니다.
- 타입에 ‘태그’를 지정하는 데 사용되는 __brand 프로퍼티는 ‘빌드 시점’에만 존재하는 프로퍼티입니다.
- __brand 프로퍼티는 런타임에서는 표시되지 않기 때문에 개발자가 이를 사용하려고 하면 문제가 될 수 있지만 여전히 자동완성에는 보입니다.
- __brand 프로퍼티에 대한 제약이 없기 때문에 branded types를 복제할 수 있습니다.
더 나아진 Branded Type
이전 구현은 branded type을 일차원적으로 해석한 것입니다. 위에 언급한 단점들을 피하려면 보다 강력한 Brand 유틸리티 타입을 사용해야 합니다.
- __brand 라는 하드코딩된 프로퍼티 대신 계산된 프로퍼티키를 사용할 수 있습니다.
- unique symbol을 사용해 이미 언급된 key의 중복 사용을 방지할 수 있습니다.
- 또한 Brand 유틸리티를 자체 파일에 작성하여 해당 프로퍼티의 읽기 접근을 차단할 수 있습니다.
declare const __brand: unique symbol
type Brand<B> = { [__brand]: B }
export type Branded<T, B> = T & Brand<B>
이 코드의 동작은 플레이그라운드 링크에서 확인할 수 있습니다.
왜 Branded Types 가 유용한가요?
Branded types는 기존 원시 타입을 사용하는 것보다 몇 가지의 장점이 있습니다.
- 명확성: 변수의 용도를 명확하게 표현할 수 있습니다. 예를 들자면 “Username” Branded Type을 사용하면 해당 변수에 유효한 유저이름만 포함되도록 하여 잘못된 문자나 길이에 관련된 문제를 방지할 수 있습니다.
- 안정성과 정확성: 코드를 더 쉽게 추론하고 타입 불일치나 호환되지 않는 타입에 관한 에러를 잡아 이슈들을 막는데 도움이 됩니다.
- 유지보수성: 모호함과 혼동을 줄여 다른 사람들이 코드를 더 쉽게 이해할 수 있도록 함으로써 코드베이스를 좀 더 유지보수하기 쉽게 만들어줍니다. branded types는 사용하면 개발자는 의도를 보다 명확하게 전달하고 오해나 변수들을 잘못 사용하는 것을 피할 수 있습니다. 게다가 branded types는 다양한 타입의 데이터와 각각의 용도를 명확하게 구분하여 리팩토링을 더 쉽게 만들 수 있습니다.
사용 예시
Branded types는 다양한 시나리오에서 사용할 수 있습니다. 아래에 몇 가지 예시가 있습니다.
커스텀 유효성 검사
사용자의 데이터가 표준 또는 원하는 형식과 맞도록 검증하는데 도움을 줄 수 있습니다. 예를 들어, brands를 사용하여 이메일 주소가 올바른 형식인지 확인하는 데 사용할 수 있습니다.
type EmailAddress = Brand<string, "EmailAddress">
function validEmail(email: string): EmailAddress {
// 이메일 검증로직이 여기 들어가야합니다.
return email as EmailAddres;
}
만약 적절하지 못한 이메일을 넘겨주게 된다면은 사용자는 실패 메시지를 받게 됩니다.
도메인 모델링
Branded types는 도메인 모델링에 뛰어나며 전체적으로 더 표현력 있는 코딩 경험을 줄 수 있습니다. 예를 들어 자동차 공정 라인에서 자동차의 다양한 종류나 기능에 branded types를 사용할 수 있습니다.
type CarBrand = Brand<string, "CarBrand">
type EngineType = Brand<string, "EngineType">
type CarModel = Brand<string, "CarModel">
type CarColor = Brand<string, "CarColor">
이 접근 방식을 통해 타입 체커는 타입 안정성을 더 강화할 수 있습니다.
function createCar(carBrand: CarBrand, carModel: CarModel, engineType: EngineType, color: CarColor): Car {
// ...
}
const car = createCar("Toyota", "Corolla", "Diesel", "Red") // Error:
// "Diesel" is not of type "EngineType"
API 응답과 요청
API 엔드포인트에서 brands를 사용하면 API 호출의 응답과 요청을 커스텀 할 수 있습니다. Branded 타입(혹은 라벨이 지정된 타입)은 API 제공하는 쪽에서부터 라벨링을 제공하기 때문에 이러한 맥락에서 잘 작동합니다. 아래 예시에서 brand를 사용하여 특정 API의 성공 및 실패 케이스를 구분해 보겠습니다.
type ApiSuccess<T> = T & { __apiSuccessBrand: true }
type ApiFailure = {
code: number;
message: string;
error: Error;
} & { __apiFailureBrand: true };
type ApiResponse<T> = ApiSuccess<T> | ApiFailure;
이제 이러한 응답 타입을 사용해서 오해를 피할 수 있습니다.
const response: ApiResponse<string> = await fetchSomeEndpoint();
if (isApiSuccess(response)) {
// 성공했을 때 응답 처리
}
if (isApiFailure(response)) {
// 에러 메시지 로그 남기기
}
도전과제
코드와 관련된 내용을 배우는 가장 좋은 방법은 특정 주제와 관련해서 실제로 만들어보거나 문제를 해결하는 것입니다.
여러분을 위한 간단한 도전 과제를 준비했습니다. 사람의 나이에 대한 새로운 brand 타입을 만들고, 숫자 입력을 받아 Age로 반환하는 함수 createAge를 작성해주세요. 그리고, Age를 입력받아 숫자를 반환하는 함수 getBirthYear도 함께 작성해주세요.
Age는 임의의 숫자가 아니라 0 이상 125 이하의 범위에 있어야 합니다. 입력이 이 간격에 속하지 않으면 createAge 함수는 입력이 유효한 범위 내에 있지 않다는 에러를 발생시켜야 합니다.
/** typescript playground 안에서 직접 이 도전과제를 풀어볼 수 있습니다: https://tsplay.dev/WzxBRN*/
/** 이 도전과제는 Branded utility type을 만들어야합니다. */
type Age = Branded<never, "Age">; // never 타입을 원시 타입으로 바꿔보세요.
/**
* 🏋️♀️ createAge 함수를 구현해보세요. 숫자를 입력으로 받고 brand 타입의 Age를 반환해야합니다.
**/
function createAge() {
// 여기에 함수를 구현하세요.
}
/**
* 🏋️♀️ getBirthYear 함수를 구현하세요. brand 타입의 Age를 받고 숫자를 반환해야합니다.
*/
function getBirthYear() {
// 여기에 함수를 구현하세요.
}
/** Usage **/
const myAge = createAge(36); // 정상동작
const birthYear = getBirthYear(myAge); // 정상동작
const birthYear2 = getBirthYear(36); // 에러발생
/** Type Tests */
import type { Equal, Expect } from '@type-challenges/utils'
type cases = [
Expect<Equal<typeof myAge, Age>>,
Expect<Equal<typeof birthYear, number>>,
Expect<Equal<Parameters<typeof createAge>, [number] >>,
Expect<Equal<Parameters<typeof getBirthYear>, [Age] >>
]
정답은 플레이그라운드 링크에서 볼 수 있습니다.
결론
Branded Types는 타입스크립트의 강력한 기능으로, 코드의 타입 안정성, 유지보수성, 명확성을 향상시키는 데 도움을 줍니다. 이러한 타입을 사용하면 데이터의 형태에 대한 더 나은 제어를 제공하고, 보다 표현력 있는 타입을 정의할 수 있으며, 컴파일 시 안전한 타입 검사를 통해 디버깅 시간을 줄이고 런타임 오류를 방지할 수 있습니다. Branded Types를 적절하게 사용하면 더 효율적이고 확장 가능하며 안전한 타입스크립트 프로젝트를 만들 수 있습니다.