[TypeScript] branded type

TypeScript를 깊이 있게 하다 보면 다양한 타입을 마주하게 됩니다. Branded Type(브랜드 타입)도 그중에 하나입니다. branded type은 보통 원시 값을 구분하기 위해 사용하는데요. 예시와 함께 원시 값과 branded type을 구분하는 이유에 대해서 알아보겠습니다.

명목적 타입 시스템

branded type(브랜딩 타입)을 사용하면 명목적 타입 시스템처럼 타입을 사용할 수 있습니다. 여기서 말하는 명목적 타입 시스템은 무엇일까요?

명목적 타입 시스템(Nominal Type System)은 타입의 이름을 통해 구분하는 타입 시스템 입니다. 이러한 자료형 체계에서 자료형의 호환성과 등가성은 선언이나 자료형의 이름에 의해 명시적으로 결정됩니다.

이러한 명목적 타입 시스템은 왜 필요할까요?

대표적으로 환전과 관련된 코드로 명목적 타입 시스템을 사용하는 브랜딩 타입에 대한 필요성을 이야기하곤 합니다.

function usdToKo(usd: USD): KO {
	return (usd * 1300 as KO);
}

위 코드는 USD를 KO로 환전해주는 함수입니다. 평소 사용할 때는 큰 이슈가 없습니다. 하지만 USD가 아닌 EUR(유로)를 넣으면 어떻게 될까요?

네, 아무 문제 없이 환전되서 반환하게 됩니다. 왜냐하면 USD는 그냥 Number 이기 때문이죠. 메서드 명에 어떤 기능을 하는지 제대로 명시했지만 사람은 언제나 그렇듯 실수를 하게 됩니다.

이러한 문제를 해결하기 위해서 명목적 타입 시스템을 사용하게 됩니다. 그리고 명목적 타입 시스템을 지원하는 branded type(브랜딩 타입)을 사용하게 합니다.

type Brand<Key extends string, Value> = Value & { __brand: Key }

브랜딩 타입 만들기

브랜딩 타입은 실제로 존재하지 않는 타입이며, 커스텀 타입니다.

그렇기 때문에 우리가 직접 커스텀해서 코드를 작성해야 합니다. 앞서 예시로 들었던 환전과 관련한 코드에서 브랜딩 타입이 어떻게 사용되는지 살펴보겠습니다.

type Brand<K, T> = K & { __brand: T }
type USD = Brand<number, "USD">
type KRW = Brand<number, "KRW">
type EUR = Brand<number, "EUR">

let usd = 50 as USD;
let krw = 10000 as KRW;
let eur = 30 as EUR;

const usdToKrw = (usd: USD): KRW => {
	return (usd * 1300) as KRW;
}
스크린샷 2024 04 13 오전 11.06.25
[TypeScript] branded type 5
스크린샷 2024 04 13 오후 12.06.38
[TypeScript] branded type 6

브랜딩 타입은 위와 같이 Brand 타입을 제네릭으로 선언을 합니다. 그다음 타입을 선언할 때 Brand 타입으로 만들고자 하는 type을 선언합니다. 그리고 각 변수에서 사용하게 됩니다.

브랜딩 타입을 사용하지 않았을 때는 다른 값이 들어와도 에러가 발생하지 않습니다. 아래처럼 말이죠.

type USD_N = number;
type KRW_N = number;
type EUR_N = number;

let usd_n = 50 as USD_N;
let krw_n = 10000 as KRW_N;
let eur_n = 30 as EUR_N;

const usdToKrw_n = (usd: USD_N): KRW_N => {
	return (usd * 1300) as KRW_N;
}

console.log(usdToKrw_n(usd_n));
console.log(usdToKrw_n(eur_n));
스크린샷 2024 04 13 오후 11.36.58

어떤 에러도 없이 그냥 사용할 수 있습니다. TypeScript PlayGround에서는 실행은 되지만 앞서 보여드린 것처럼 에러를 확인할 수 있습니다. 개발자 입장에서 에러가 있는 상태에서 컴파일을 하지 않기 때문에 좀 더 확실한 타입을 정의할 때 Branded Type을 사용하면 좋습니다.

브랜드 유형을 사용해야 하는 이유

Branded Type은 기본 유형을 사용하는 것 보다 아래와 같은 이점을 가져올 수 있습니다.

  • 명확성 – 변수의 의도된 사용에 대해 좀 더 명확하게 표현할 수 있습니다. 예를 들어 유저 이름이라는 브랜드 유형을 사용하면 변수에 유효한 유저 이름만 포함되어 잘못된 문자나 길이 등 다양한 문제를 제한할 수 있습니다.
  • 안정성과 정확성 – 코드에 대해 더 쉽게 추론하고 유형 비호환성 또는 불일치와 관련된 오류를 포착함으로써 문제를 예방하는 데 도움이 됩니다.
  • 유지 보수성 – 타입스크립트는 타입에 대한 명확한 정의 및 추론이 가능해야 합니다. branded type은 애매한 타입에 대한 혼란을 줄이고 다른 사람들이 보다 쉽게 코드 베이스를 이해할 수 있도록 합니다. 또한 유지 보수를 위한 코드 분석이 쉽도록 도와줍니다. branded type의 가장 큰 이점은 개발자 본인의 의도를 다른 사람에게 명확하게 전달하고 변수의 오해나 오용을 방지할 수 있다는 점입니다.

활용 사례

branded type은 앞서 확인했던 사례를 기반으로 다양한 시나리오에서 사용될 수 있습니다.

맞춤형 검증

사용자의 데이터가 표준이나 원하는 형식으로 변환되는지 확인하기 위해 유효성 검사 기능을 생성하는 데 도움이 됩니다. 아래 코드는 branded type를 사용하여 이메일 주소가 올바른 형식인지 확인하는 코드입니다.

type EmailAddress = Brand<string, "EmailAddress">
function validEmail(email: string): EmailAddress {
	return email as EmailAddress
}

위 코드는 이메일이 정의된 형식대로 작성되지 않으면 사용자는 실패 메시지를 받게 됩니다.

도메인 모델링

branded type은 다양한 도메인을 정의할 때 사용됩니다. 특히, 기본적인 string 정의나 number 정의는 다른 도메인과 차별된 타입을 선언하기 어렵습니다. 브랜딩 타입을 사용하면 같은 string 타입도 명확한 정의가 가능하기 때문에 활용하기 좋습니다.

type CarBrand = Brand<string, "CarBrand">
type EngineType = Brand<string, "EngineType">
type CarModel = Brand<string, "CarModel">
type CarColor = Brand<string, "CarColor">

이렇게 타입을 선언해서 사용하면 유형 검사기가 더 명확한 유형 안전성을 적용할 수 있습니다.

function create(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 End-Point는 branded type 를 사용하여 API 호출의 응답 및 요청을 사용자 정의로 선언할 수 있습니다. 브랜드 또는 레이블이 지정된 유형은 레이블 지정이 API 제공자에서 제공되기 때문에 제대로 동작합니다.

예시 코드에서는 특정 API가 있는 branded type를 사용하여 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 fetchEndPoint();
if (isApiSuccess(response)) {
	// success response
}

if (isApiFailure(response)) {
	// failure response
}

Branded Type을 더 활용하기 위한 방법

TypeScript에 있는 기능들은 다양하게 존재합니다. Branded Type 또한 그런 기능 중 하나입니다. 기능에 대한 코드를 배우는 가장 좋은 방법은 해당 주제와 관련된 것을 실제로 구축해 보거나 해결해 보는 것이죠.

아래 코드는 특정 나이를 구하는 코드로 문제 정의는 다음과 같습니다.

나이 계산을 위한 number 값을 받고, 이 number가 0살 이상 125살 이하인지 판별된 값을 토대로 연도를 반환하는 기능을 만들어야 합니다.

type Branded<K, T> = K & { __brand: T }
type Age = Branded<number, "Age">;

function createAge(input: number): Age {
  if (input < 0 || input > 125) {
    throw new Error("Input value for Age is not within the valid range")
  }

  return input as Age;
}

function getBirthYear(age: Age) {
    return (new Date()).getFullYear() - age
}

const myAge: Age = createAge(36); // true
const birthYear = getBirthYear(myAge) 
const birthYear2 = getBirthYear(20); // error
console.log(birthYear); // 1988

마무리

Branded Type은 TypeScript를 작성할 때, 좀 더 명확하고 추론하기 쉬운 코드를 작성하는 데 도움을 줍니다. 또한 개발자가 실수 할 수 있는 부분을 미리 막아주고, 쉽게 코드를 작성하는 데 도움을 줍니다.

그렇기 때문에 위와 같이 명확한 코드를 작성하거나, 유효성 검사가 필요할 때 사용하면 많은 도움이 되는 기능입니다.

함께보면 좋은 글

참고

https://velog.io/@sjyoung428/Typescript-브랜딩

https://cheri.tistory.com/268

Leave a Comment