👶 TypeScript

이름 기반 타입 흉내내기

개발자 린다씨 2023. 1. 22. 14:00
반응형

이름 기반 타입 흉내내기

예를 들어 몇 가지의 ID 타입이 있는데, 그 각각은 시스템에서 사용하는 서로 다른 종류의 객체를 고유한 방식으로 식별해 준다고 해봅시다.

type CompanyID = string
type OrderID = string
type MemberID = string
type ID = CompanyID | OrderID | MemberID

MemberID 타입의 값이 "i4want4go4home" 같은 단순 해시값이라고 해봅시다.

 

따라서 비록 MemberID라는 별칭으로 사용했지만 실질적으론 일반 string입니다.

 

MemberID를 인수로 받는 함수는 다음처럼 정의할 수 있습니다.

type CompanyID = string
type OrderID = string
type MemberID = string
type ID = CompanyID | OrderID | MemberID

function queryForMember(id: MemberID){
  // 블라 블라
}

문서화가 잘된 코드로, 다른 팀원들도 어떤 타입의 ID를 전달해야 하는지 명확하게 알 수 있습니다.

 

하지만 MemberID는 string의 별칭일 뿐이므로 이 정의로는 버그를 확실하게 방지할 수 없습니다.

 

어떤 개발자가 실수로 잘못된 ID 타입을 전달하면 타입 시스템도 어쩔 방법이 없기 때문입니다.

type CompanyID = string
type OrderID = string
type MemberID = string
type ID = CompanyID | OrderID | MemberID

function queryForMember(id: MemberID){
  // 블라 블라
}

let id: CompanyID = '제회사이름은비밀'
queryForMember(id) // OK

이 상황이 바로 이름 기반 타입이 유용한 사례입니다.

 

TypeScript는 이름 기반 타입을 제공하진 않지만 타입 브랜딩(type branding)이라는 기법으로 이를 흉내 낼 수 있습니다.

 

이름 기반 타입을 지원하는 다른 언어에 비해 TypeScript에서 타입 브랜딩으로 이를 흉내내기는 조금 까다로우며 몇 가지 설정도 필요합니다.

 

하지만 브랜디드 타입(branded type)을 이용하면 프로그램을 한층 안전하게 만들 수 있습니다.

응용 프로그램과 개발팀의 규모에 따라 이 기능이 필요 없을 수도 있습니다. 
규모가 큰 팀일수록 실수를 줄이는데 도움이 됩니다.

우선 필요한 이름 기반 타입 각각에 대응하는 임의의 타입 브랜드를 만듭니다.

type CompanyID = string & {readonly brand: unique symbol}
type OrderID = string & {readonly brand: unique symbol}
type MemberID = string & {readonly brand: unique symbol}
type ID = CompanyID | OrderID | MemberID

string과 {readonly brand: unique symbol}의 인터섹션은 물론 큰 의미는 없습니다.

 

예시에서 사용한 이유는 이런 타입을 만드는 자연스러운 방법이 존재하지 않으며 이 타입의 값을 만들려면 반드시 어서션을 이용해야 하기 때문입니다.

 

이는 브랜디드 타입의 핵심적인 특징으로, 실수로 잘못된 타입을 사용하기가 아주 어렵게 해줍니다.

 

예제에서 unique symbol을 '브랜드'를 사용했는데 이는 TypeScript에서 실질적으로 제공하는 두 가지 이름 기반 타입 중 하나이기 때문입니다.

 

그런 다음 이 브랜드를 string과 인터섹션하여 주어진 문자열이 정의한 브랜디드 타입과 같다고 어서션할 수 있도록 했습니다.

 

이제 CompanyID, OrderID, MemberID 타입의 값을 만드는 방법이 필요합니다. 

 

값을 만드는 데는 컴패니언 객체 패턴을 이용할 것입니다.

 

이제 각 브랜디드 타입의 생성자를 만들어보겠습니다.

 

이때 주어진 값(id)을 앞서 정의한 난해한 타입들로 지정하는 데 타입 어서션을(as)을 사용합니다.

type CompanyID = string & {readonly brand: unique symbol}
type OrderID = string & {readonly brand: unique symbol}
type MemberID = string & {readonly brand: unique symbol}
type ID = CompanyID | OrderID | MemberID

function CompanyID(id: string){
  return id as CompanyID
}

function OrderID(id: string){
  return id as OrderID
}

function MemberID(id: string){
  return id as MemberID
}

마지막으로 이들 타입을 아래처럼 사용할 수 있습니다.

type CompanyID = string & { readonly brand: unique symbol };
type OrderID = string & { readonly brand: unique symbol };
type MemberID = string & { readonly brand: unique symbol };
type ID = CompanyID | OrderID | MemberID;

function CompanyID(id: string) {
  return id as CompanyID;
}

function OrderID(id: string) {
  return id as OrderID;
}

function MemberID(id: string) {
  return id as MemberID;
}

function queryForMember(id: MemberID) {
  // 블라 블라
}

let companyId = CompanyID("회사이름은비밀");
let orderId = OrderID("큐릉");
let memberId = MemberID("CozyLinda");

queryForMember(memberId);

// 'CompanyID' 형식의 인수는 'MemberID' 형식의 매개 변수에 할당될 수 없습니다.
// 'CompanyID' 형식은 '{ readonly brand: unique symbol; }' 형식에 할당할 수 없습니다.
// 'brand' 속성의 형식이 호환되지 않습니다.
// 'typeof brand' 형식을 'typeof brand' 형식에 할당할 수 없습니다. 이름이 같은 2개의 서로 다른 형식이 있지만 서로 관련은 없습니다.ts(2345)
queryForMember(companyId);

런타임 오버헤드가 거의 없다는 것이 이 기법의 장점입니다.

 

ID 생성당 한 번의 함수 호출로 충분하며 아마 이 함수 호출조차 JavaScript VM이 인라인으로 삽입했을 것입니다.

 

런타임에 모든 ID는 단순한 string입니다.

 

즉, 브랜드는 순전히 컴파일 타임에만 쓰이는 구조물입니다.

 

대부분의 응용 프로그램에선 이렇게까지 하는 건 과할 수 있지만, 규모가 큰 응용 프로그램 또는 다양한 종류의 ID를 사용해 타입이 헷갈리는 작업 환경이라면 브랜디드 타입으로 안전성을 확보할 수 있습니다.

반응형