문자열 키에 컴파일 타임 브랜드를 부여하는 TypeScript 유틸리티
대부분의 코드베이스에서 식별자는 plain string이다.
function sendMessage(roomId: string, senderId: string, text: string): void { ... }
sendMessage(userId, roomId, "hello!");
// 순서가 바뀌었지만 컴파일러는 모른다.
// → 다른 방에 메시지가 날아가는 사일런트 버그UserId, RoomId, MessageId가 전부 string이면 파라미터를 바꿔 넣어도, 삭제 API에 잘못된 ID를 넘겨도 컴파일러가 잡지 못한다.
같은 문자열 포맷을 여러 파일에서 각자 하드코딩하면 더 심해진다. 실제로 같은 ID를 만드는 코드가 여러곳에 흩어져 있다가, 신규 타입 추가 시 한 곳만 포맷이 달라서 네비게이션이 조용히 깨진 적이 있다. 유틸 함수로 포맷을 한 곳에 모아도, 그 유틸의 존재를 모르는 개발자는 또 하드코딩한다. 유틸을 만드는 것만으로는 부족하고, 유틸 사용을 타입 레벨에서 강제해야 한다.
string & { readonly [__brand]: Tag } 패턴으로 UserId ≠ RoomId ≠ MessageId를 컴파일 타임에 구분한다. 런타임에는 그냥 string이므로 오버헤드가 없다
type UserId = BrandedKey<"UserId">;
type RoomId = BrandedKey<"RoomId">;
function sendMessage(roomId: RoomId, senderId: UserId, text: string): void { ... }
sendMessage(dmRoom, alice, "hello!"); // OK
sendMessage(alice, dmRoom, "hello!"); // 컴파일 에러 — UserId ≠ RoomIdTanStack Query의 키 팩토리 패턴을 차용했다. 모든 키를 하나의 거대한 스토어에 flat하게 넣는 게 아니라, 도메인별로 defineKeyStore를 따로 만든다. userKeys., roomKeys., messageKeys. 처럼 도메인 단위로 자동완성이 되고, 키 포맷도 각 도메인 모듈 안에서 관리된다.
const userKeys = defineKeyStore("UserId", {
fromUuid: (uuid: string): string => `user:${uuid}`,
anonymous: (sessionId: string): string => `anon:${sessionId}`,
});
type UserId = InferKey<typeof userKeys>;
userKeys.fromUuid("abc") // UserId (= BrandedKey<"UserId">)
userKeys._tag // "UserId" (런타임 디버깅용)flat / nested 모두 지원한다. 키가 많아지면 nested로 계층 그룹핑이 가능하다.
const roomKeys = defineKeyStore("RoomId", {
direct: (userA: string, userB: string): string =>
`dm:${[userA, userB].sort().join("+")}`,
group: (groupId: string): string => `group:${groupId}`,
channel: (workspace: string, slug: string): string =>
`ch:${workspace}:${slug}`,
});팩토리 내부에서 같은 팩토리의 다른 키를 참조해 합성 키를 만들 수 있다.
const messageKeys = defineKeyStore("MessageId", {
fromSnowflake: (snowflake: string): string => `msg:${snowflake}`,
optimistic: (nonce: string): string => `msg:~${nonce}`,
reply: (parentSnowflake: string, snowflake: string): string =>
`${messageKeys.fromSnowflake(parentSnowflake)}→${messageKeys.fromSnowflake(snowflake)}`,
});- 이 모듈은 브랜딩 메커니즘만 담당한다. 실제 키 포맷(도메인 지식)은 각 도메인 모듈이 소유한다.
BrandedKey는string의 서브타입이므로getElementById,localStorage, 템플릿 리터럴 등 string을 기대하는 곳에 그대로 쓸 수 있다. 반대 방향(plain string → BrandedKey)만 차단된다.- 런타임에는
__brand프로퍼티가 존재하지 않는다. 타입 레벨에서만 동작하는 phantom type이라 런타임 오버헤드가 없다.
src/
├── shared/lib/
│ └── string-key-store.ts # defineKeyStore, BrandedKey, InferKey
└── chat/
├── keys/
│ └── chat-keys.ts # UserId, RoomId, MessageId 팩토리
└── example-usage.ts # 유즈케이스 & 컴파일 타임 안전성 데모
npm install
npm run typecheck # tsc --noEmitexample-usage.ts의 @ts-expect-error 주석이 전부 유효한지 확인된다.
구현 기록 : https://donghyk2.tistory.com/189