TypeScript는 타입을 모양으로 비교하므로 string의 별칭 두 개는 서로 바꿔 쓸 수 있습니다. 브랜드 타입은 타입 시스템에만 존재하는 태그 string & { readonly __brand: "UserId" }를 붙여, UserId를 일반 문자열이나 다른 모든 브랜드와 호환되지 않게 만듭니다.
런타임에서 userId는 그냥 문자열 "u_42"입니다. 브랜드는 컴파일 타임 라벨이고, 하는 일은 ID, 단위, 검증된 문자열이 뒤섞이지 않게 막는 것 하나뿐입니다.
문제: 별칭은 이름일 뿐이다
타입 별칭은 새 타입을 만들지 않습니다. 기존 타입에 두 번째 이름을 붙일 뿐이고, 컴파일러는 두 이름을 같은 것으로 취급합니다.
이것이 구조적 타이핑입니다. TypeScript는 모양이 맞는지 확인하는데, string은 string에 맞습니다. 객체는 보통 모양이 다르지만 ID, 이메일, 통화, 단위는 결코 다르지 않습니다. 브랜드는 바로 이 경우를 해결합니다.
브랜드의 동작 원리
string & { readonly __brand: "UserId" }는 교차 타입입니다. 값은 문자열이면서 "UserId" 타입의 __brand 속성도 가져야 합니다. 실제 문자열에는 그런 속성이 없으므로 일반 문자열은 대입할 수 없습니다.
index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
Types of property '__brand' are incompatible.
Type '"OrderId"' is not assignable to type '"UserId"'.
중요한 방향은 그대로 동작합니다. UserId는 문자열이므로 문자열을 받는 곳 어디에나 넘길 수 있고, .startsWith()를 호출하거나 템플릿에 넣을 수도 있습니다. 브랜드는 들어오는 길만 막습니다.
검증하는 생성 함수
as UserId 단언이 유일한 입구인데, 단언은 아무것도 검사하지 않습니다. 입력을 검증하는 함수 하나에 단언을 넣으면, 프로그램의 모든 브랜드 값이 그 검사를 통과했다는 것을 알 수 있습니다.
sendWelcome은 입력을 다시 검사하지 않습니다. 매개변수 타입이 이미 검사했다는 사실을 말해 주기 때문입니다. 이것이 "parse, don't validate" 아이디어입니다. 경계에서 검사하고, 그 증거를 타입에 담아 다닙니다. 예외 대신 불리언을 선호한다면 타입 가드도 생성 함수로 쓸 수 있습니다: function isEmail(s: string): s is Email.
제네릭 Brand 헬퍼
타입마다 교차 타입을 손으로 쓰면 반복이 됩니다. 작은 제네릭 하나로 한 번에 처리합니다.
숫자에 브랜드를 붙이는 방법은 문자열과 같습니다. amount / 100은 일반 number라는 점에 주의하세요. 브랜드 숫자로 산술 연산을 하면 브랜드가 없는 결과가 나오며, 이는 아래에서 다룹니다.
unique symbol 브랜드
__brand 같은 문자열 속성 이름은 실제 속성처럼 보입니다. userId.__brand는 타입 검사에서 "UserId"가 되지만 런타임에서는 undefined이고, 두 라이브러리가 같은 이름을 고를 수도 있습니다. unique symbol 키를 쓰면 둘 다 피할 수 있습니다.
declare const brand: unique symbol은 타입 검사기만을 위해 존재하는 심볼을 선언합니다. declare 키워드가 있으면 이에 대한 JavaScript는 출력되지 않습니다. 심볼을 모듈에서 export하지 않으므로 다른 파일의 코드는 브랜드 속성의 이름조차 쓸 수 없고, 그 모듈 밖에서 Meters를 얻는 방법은 export한 함수나 as Meters 단언뿐입니다.
브랜드의 런타임 비용은 0
컴파일된 출력에는 브랜드의 흔적이 전혀 없습니다. 다음은 Meters 예제에서 컴파일러가 붙이는 모듈 헤더 아래에 출력되는 줄입니다. declare, 두 타입 별칭, 모든 as가 사라졌고, 마지막 줄의 억제된 호출은 여전히 실행됩니다.
function toMeters(feet) {
return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);
브랜드 값은 평범한 원시 값입니다. typeof는 "string"이나 "number"를 돌려주고, JSON.stringify는 평소처럼 쓰며, 비교도 전과 같이 동작합니다. 반대로 말하면 생성 함수가 검사하지 않는 한 런타임에는 아무것도 검사되지 않습니다. JSON, 데이터베이스, URL에서 파싱한 데이터는 string으로 들어오며, 그 함수를 거쳐야만 UserId가 됩니다.
산술 연산과 메서드는 브랜드를 잃는다
브랜드 값에 대한 연산은 기본 타입을 반환합니다. +나 .slice()가 만드는 결과에는 브랜드가 포함되지 않기 때문입니다.
type Cents = number & { readonly __brand: "Cents" };
const a = 500 as Cents;
const b = 250 as Cents;
const sum = a + b; // number, not Cents
const total: Cents = a + b; // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid
대개는 이것이 원하는 동작입니다. 센트 두 개를 더하면 센트지만, 센트에 센트를 곱하면 센트가 아니며, 어떤 연산이 의미를 유지하는지는 여러분만 압니다. 코드에 필요한 연산에 대해 addCents(a: Cents, b: Cents): Cents 같은 작은 헬퍼를 만드세요.
브랜드 타입을 쓸 때
같은 원시 타입의 두 값이 뒤섞이는 것이 실제 위험이고 컴파일러가 달리 도울 방법이 없는 곳에 브랜드를 쓰세요.
| 상황 | 브랜드 예시 |
|---|---|
| 서로 다른 테이블의 ID | UserId, OrderId, ProductId |
| 검증된 문자열 | Email, Url, NonEmptyString, Slug |
| 단위와 통화 | Meters, Feet, Cents, Usd, Eur |
| 정제되거나 이스케이프된 텍스트 | SafeHtml, SqlIdentifier |
| 범위가 있는 숫자 | Percentage, PositiveInt |
절대 헷갈릴 일이 없는 값이나 이미 모양이 다른 객체 타입에는 쓰지 마세요. 검증 라이브러리는 스키마로부터 브랜드 타입을 만들어 줄 수 있습니다. Zod에서 z.string().brand<"UserId">()는 parse가 브랜드 UserId를 반환하는 스키마를 주므로, 생성 함수를 직접 쓰지 않아도 됩니다.
자주 묻는 질문
TypeScript에서 브랜드 타입이란 무엇인가요?
런타임 표현이 같은 두 타입을 서로 호환되지 않게 만드는 패턴입니다. 기본 타입에 일반 값에는 없는 태그를 교차시킵니다: type UserId = string & { readonly __brand: "UserId" }. 그러면 UserId가 필요한 곳에서 일반 문자열이나 태그가 다른 OrderId는 거부됩니다.
TypeScript에 명목적 타입이 있나요?
없습니다. TypeScript의 타입 시스템은 구조적입니다. 이름이 달라도 모양이 같은 두 타입은 서로 바꿔 쓸 수 있습니다. private이나 #private 멤버가 있는 클래스 선언은 명목적으로 동작하며, 문자열이나 숫자 같은 원시 타입에서 같은 효과를 얻는 흔한 방법이 브랜드 타입입니다.
브랜드 타입에 런타임 비용이 있나요?
없습니다. 브랜드는 타입에만 존재합니다. 런타임의 값은 추가 속성 없는 평범한 문자열이나 숫자이고, 컴파일된 JavaScript는 브랜드가 없을 때와 같습니다. 런타임 코드는 브랜드 값을 만드는 함수에 직접 넣은 검증뿐입니다.
브랜드 타입의 값은 어떻게 만드나요?
타입 단언으로 만들며, 입력을 먼저 검사하는 작은 함수 하나에 두는 것이 가장 좋습니다: function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }. as를 그 한 곳에만 두면 프로그램의 모든 Email이 검사를 통과했다는 뜻이 됩니다.
타입 별칭과 브랜드 타입의 차이는 무엇인가요?
type UserId = string은 새 이름일 뿐이라 UserId가 필요한 곳에 어떤 문자열이든 들어갑니다. type UserId = string & { readonly __brand: "UserId" }는 호환되지 않는 새 타입이라, 일반 문자열은 먼저 생성 함수나 단언을 거쳐야 합니다.