
Intro
타입스크립트의 타입 시스템은 구조가 동일하면 두 타입을 서로 할당할 수 있는 구조적 타이핑을 따릅니다.
하지만 구조가 같더라도 특정 값만 허용하도록 타입을 제한하고 싶다면 어떻게 해야 할까요? 예를 들어 특정 정규식을 만족하는 문자열을 구분하거나 양수와 음수를 구분하는 경우가 있을 수 있습니다.
이런 상황을 브랜드 타입(Brand Type)이라는 패턴으로 해결할 수 있습니다.
브랜딩이 왜 필요할까요?
타입스크립트의 타입 시스템은 구조적으로 동일한 타입을 구분할 방법을 제공하지 않습니다.
예를 들어 양수만 입력받아야 하는 함수가 있다고 가정해보겠습니다.
function waitForSeconds(value:number) {
return new Promise((resolve) => setTimeout(resolve,value))
}
async function waitThenLog(seconds: number) {
// 🟢 1은 양수니까 정상입니다.
await waitForSeconds(1);
// 🔴 -1은 오류가 오류가 발생해야합니다.
await waitForSeconds(-1);
console.log("완료!");
}
seconds라는 매개변수를 number 타입이기 때문에 양수임을 보장할 수 없습니다. 타입을 좁혀서 양수임을 보장할 수 있는 방법이 있으면 보다 안정적인 코드를 작성할 수 있을 것 같습니다.
브랜드 타입으로 이런 문제를 해결할 수 있습니다.
브랜드 타입은 일반적인 타입을 구분하기위한 속성이 추가된 타입입니다. 이 속성을 통해 타입시스템에서 구분하지 못하는 타입을 브랜드화할 수 있습니다.
예를 들어 양수를 Positive타입으로 브랜드화할 수 있습니다. 이제 양수만 입력받아야하는 로직에서 number 타입 대신 Positive 타입을 사용하면 됩니다.
type Positive = number & { __brand: "positive" }
function waitForSeconds(value: Positive) {
return new Promise((resolve) => setTimeout(resolve, value))
}
async function waitThenLog(seconds: Positive) {
// 🟢 이제 안전하게 양수를 전달할 수 있습니다.
await waitForSeconds(seconds);
// 🔴 검증되지 않은 타입을 입력하면 컴파일 에러가 발생합니다.
// Argument of type 'number' is not assignable to parameter of type 'Positive'.
// Type 'number' is not assignable to type '{ __brand: "positive"; }'.
await waitForSeconds(-1);
console.log("완료!");
}
브랜드 타입 검증하기
암묵적 가정이 깔린 코드를 브랜드 타입을 이용해서 검증할 수 있음을 배웠습니다. 여기서 빠진 부분은 일반적인 타입(eg. number)이 브랜드 타입임을 알려줄 수 있는 방법입니다.
여기엔 크게 두가지 방법이 있는데 as assertion을 이용한 방법과 유틸 함수를 이용하는 방법입니다.
let myPositive: Positive;
// 🟢 이제 myPositive이 양수임을 다른 로직에서 보장할 수 있게 됐습니다.
myPositive = 123 as Positive;
하지만 이 방식은 어설션을 할 때 마다 올바른 판단인지 확인해야 합니다. 브랜드 타입의 조건이 파편화될 수 있는 방법입니다.
예를 들어 -1 as Positive 라는 코드가 존재할 수 있습니다. 모든 어설션에서 이런 검증이 필요합니다.
다음은 유틸 함수를 이용한 타입 좁히기 방법입니다.
앞서 소개한 어셜선보다 나은 방식이라 생각합니다. 브랜드 타입을 검증하는 논리(eg. value > 0 )가 하나의 로직으로 중앙화됐기 때문입니다.
type Positive = number & { __brand: "positive" };
function isPositive(value: number): value is Positive {
return value > 0;
}
let myPositive: Positive;
let value = 123;
let value: number
if (isPositive(value)) {
// 🟢 유틸 함수에 의해 Positive 타입임을 보장할 수 있습니다.
myPositive = value;
let value: Positive
}
// 🟢 검증 로직을 거치지 않은 변수 할당에서 타입 에러가 발생합니다. 이로써 동일한 조건을 통한 검증을 보장할 수 있게 됐습니다.
myPositive = 123;
assertion function을 이용하는 방법도 있습니다. util 함수와 유사한 방식이지만 에러를 던진다는 점에서 차이가 있습니다.
특정 문맥에 대해서 확실히 보장해야 하는 경우 문맥의 상단에 assertion 함수를 호출하는 것을 권장드립니다.
function assertPositive(value: number): asserts value is Positive {
if (value < 0) {
throw new Error(`${value} is not positive.`);
}
}
let n = 123;
// 🟢 양수가 아닌 경우 오류를 throw합니다.
assertPositive(n);
// 🟢 assertPositive 호출이후 문맥에서는 Positive 타입임이 보장됩니다.
waitForSeconds(n);
브랜드 타입을 언제 쓰면 되나요?
타입 시스템상 동일한 타입을 구분하고 싶은 상황에 활용할 수 있습니다. 몇 가지 구체적 사용 사례를 소개해드리겠습니다.
Brand number
타입스크립트에서 더 구체적인 숫자 타입을 지원하지 않습니다. 음수, 소수등이 예일 수 있습니다.
통화 처리에서도 유용합니다. 달러와 원화를 타입 시스템 내에서 구분할 수 있고 환전 로직을 작성하는데도 활용할 수 있습니다.
type Currency<T> = number & { __currency: T };
type WON = Currency<"won">;
type USD = Currency<"usd">;
interface PurchasableUSD{
cost: USD;
name: string;
}
const myPrice = 10 as WON;
// 🔴 달러로 입력되어야 하는 코드에 원화 통화가 전달됐습니다.
const product: PurchasableUSD = {
cost: myPrice,
name: "노트북",
};
Brand String
브랜드 문자열의 대표적 사용 사례를 몇 가지 소개해드리겠습니다.
첫번째로 유효성 검증이 완료된 문자열을 브랜드 타입으로 명시할 수 있습니다. 예를 들어 XSS를 예방하기 위해 sanitize 검증를 거쳤는지 보증할 필요가 있습니다.
type SafeString = string & { __sanitized: true };
/**
* <script> 태그같은 위험한 코드를 제거하여 안전성을 보증합니다.
*/
declare function sanitize(xml: string): SafeString;
function writeToDocument(xml: SafeString) {
document.body.innerHTML += xml;
}
const userInput = `<script src="evil.js"></script>`;
// 🟢 유효성 검증을 통과한 브랜드 타입만 매개변수로 전달받아 안정성이 보증됩니다.
writeToDocument(sanitize(userInput));
// 🔴 검증되지 않은 문자열을 전달할 경우 타입에러가 발생합니다.
writeToDocument(userInput);
또 다른 사용 사례로 엔티티별 유니크 아이디를 구분하는 용도로 활용할 수 있습니다.
type DataType = "comment" | "post";
type Guid<DataType> = string & { __guid: DataType };
type CommentId = Guid<"comment">;
type PostId = Guid<"post">;
interface Comment {
id: CommentId;
// ...
}
interface Post {
id: PostId;
// ...
}
declare function getCommentById(id: CommentId): Promise<Comment>;
declare function getPostById(id: PostId): Promise<Post>;
async function getById(id: CommentId) {
// 🟢 commandId가 입력되는 것을 보증합니다.
await getCommentById(id);
// 🔴 잘못된 아이디 전달을 타입 검증에서 확인합니다.
await getPostById(id);
}
라이브러리
브랜드 타입을 제공하는 effect 라이브러리를 소개드립니다. 이 라이브러리를 통해 앞서 소개한 브랜드 타입의 검증 로직을 보다 쉽게 작성할 수 있습니다.
import { Brand } from "effect";
// 브랜드 타입 정의
type Positive = number & Brand.Brand<"Positive">;
// 브랜드 타입 검증 함수 선언
const asPositive = Brand.nominal<Positive>();
let myPositive: Positive;
// 🟢 유틸 함수를 통한 브랜드 타입 검증
myPositive = asPositive(123);
// 🔴 검증되지 않은 직접 할당 에러
myPositive = 123;
유틸 함수 외에 어설션 함수를 선언하는 방법도 있습니다.
import { Brand } from "effect";
type Positive = number & Brand.Brand<"Positive">;
const asPositive = Brand.refined<Positive>(
(value) => value > 0,
(value) => Brand.error(`Non-positive value: ${value}`)
);
// 🟢 검증 로직을 통해 브랜드 타입을 검증합니다.
asPositive(42);
// 🔴 검증에 실패한 경우 에러를 throw합니다.
asPositive(-1);
Brand 타입의 대안
만능 해결책이 없듯, brand 타입이 아닌 다른 접근 방법을 고민해봐야 하는 경우들이 있습니다.
다음 대안을 참고하여 상황에 맞는 적절한 방법을 적용하시길 바랍니다.
유니온 타입 (Union Type)
만약 타입의 경우의 수가 한정되어 있다면 유니온 타입이 가장 좋은 접근방법일 수 있습니다.
// status는 "pending", "rejected", "resolved" 셋 중 한가지입니다.
type TaskStatus = string & { __brand: "status" };
class Task {
#status: TaskStatus = "pending" as TaskStatus;
constructor(worker: () => Promise<void>) {
worker()
.then(() => (this.#status = "resolved" as TaskStatus))
.catch(() => (this.#status = "rejecting" as TaskStatus));
// ~~~~~~~~~~~
// 🔴 오타를 검증할 수 없습니다. 유니온 타입으로 정의하는 것이 더 나은 접근입니다.
}
getStatus() {
return this.#status;
}
}
타입이 한정되어 있는 경우 유니온타입을 사용하는 것이 더 명확합니다.
type TaskStatus = "pending" | "rejected" | "resolved";
class Task {
#status: TaskStatus = "pending";
constructor(worker: () => Promise<void>) {
worker()
.then(() => (this.#status = "resolved"))
.catch(() => (this.#status = "rejected"));
}
getStatus() {
return this.#status;
}
}
혹은 enum타입으로 접근할 수 있습니다.
enum TaskStatus {
Pending = "pending",
Rejected = "rejected",
Resolved = "resolved",
}
class Task {
#status = TaskStatus.Pending;
constructor(worker: () => Promise<void>) {
worker()
.then(() => (this.#status = TaskStatus.Resolved))
.catch(() => (this.#status = TaskStatus.Rejected));
}
getStatus() {
return this.#status;
}
}
템플릿 리터럴
어떤 경우는 값 유형이 구체적으로 정해지지 않았거나 조합의 경우를 패턴으로 표현하는 것이 더 나을 수 있습니다.
이럴 경우 템플리 리터럴이 더 나은 선택입니다.
// 다음 두가지 요소가 "-"으로 조합되어야 합니다.
// 1. Base: "dark", "light", "system"
// 2. Contrast: "high", "low", "standard"
type Theme = string & { __brand: "theme" };
class ThemeStore {
#theme: Theme;
constructor(theme: Theme) {
this.#theme = theme;
}
getTheme() {
return this.#theme;
}
setTheme(theme: Theme) {
this.#theme = theme;
}
}
const store = new ThemeStore("dark-standard" as Theme);
store.setTheme("dark-high" as Theme);
store.setTheme("dark-regular" as Theme);
// ~~~~~~~~~~~~~
// 🔴 Contrast에 regular는 없습니다.
위 케이스를 템플릿 리터럴로 정의하면 훨씬 심플해집니다.
type ThemeBase = "dark" | "light" | "system";
type ThemeContrast = "high" | "low" | "standard";
type Theme = `${ThemeBase}-${ThemeContrast}`;
class ThemeStore {
#theme: Theme;
constructor(theme: Theme) {
this.#theme = theme;
}
getTheme() {
return this.#theme;
}
setTheme(theme: Theme) {
this.#theme = theme;
}
}
const store = new ThemeStore("dark-standard");
store.setTheme("dark-high");
store.setTheme("dark-regular");
래퍼 클래스
래퍼 클래스를 이용한 접근도 있습니다. 다음 코드 스니핏을 참고해보세요
class Positive {
readonly value: number;
private constructor(value: number) {
this.value = value;
}
static of(value: number): Positive | Error {
return value > 0
? new this(value)
: new Error(`Non-positive number: ${value}`);
}
}
Positive.of(123); // Positive { value: 123 }
Positive.of(-1); // Error { message: "Non-positive number: -1" }
마무리
구조적으로 동일한 타입을 구분하기 위한 브랜드 타입에 대해 알아봤습니다.
마지막 섹션에서 브랜드 타입의 대안에 대해 다룬 것 처럼 상황에 따라 더 간단한 접근이 더 효과적일 수 있습니다. ISO 표준처럼 특정 규격에 맞는 값을 보장해야 하는 로직에서는 브랜드 타입이 유용했습니다. 서론에서 이야기했던 것 처럼 암묵적 전제 (이 API 응답은 ISO표준일꺼야)를 코드상에서 보증함으로서 잠재적 문제를 표현상으로 끌어올릴 수 있었습니다.
참고
https://www.learningtypescript.com/articles/branded-types
Branded Types | Learning TypeScript
How the concept of branded types allows describing primitives more precisely than TypeScript normally allows.
www.learningtypescript.com
https://basarat.gitbook.io/typescript/main-1/nominaltyping
Nominal Typing | TypeScript Deep Dive
CopyTIPsNominal Typing Nominal Typing The TypeScript type system is structural and this is one of the main motivating benefits. However, there are real-world use cases for a system where you want two variables to be differentiated because they have a diffe
basarat.gitbook.io
'typescript' 카테고리의 다른 글
| 변수처럼 사용하는 제네릭 타입 (0) | 2025.12.29 |
|---|---|
| satistfies 문법 그래서 언제 쓰는 건가요? (1) | 2025.12.29 |
| 객체지향 설계: 자율적인 책임의 힘 (0) | 2025.08.27 |
| 타입스크립트의 공변성과 반변성 (2) | 2024.07.13 |
| any 타입이 유용한 경우 (1) | 2024.07.13 |