Skip to content

donghyun1998/string-key-factory

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

string-key-store

문자열 키에 컴파일 타임 브랜드를 부여하는 TypeScript 유틸리티

문제

대부분의 코드베이스에서 식별자는 plain string이다.

function sendMessage(roomId: string, senderId: string, text: string): void { ... }

sendMessage(userId, roomId, "hello!");
// 순서가 바뀌었지만 컴파일러는 모른다.
// → 다른 방에 메시지가 날아가는 사일런트 버그

UserId, RoomId, MessageId가 전부 string이면 파라미터를 바꿔 넣어도, 삭제 API에 잘못된 ID를 넘겨도 컴파일러가 잡지 못한다.

같은 문자열 포맷을 여러 파일에서 각자 하드코딩하면 더 심해진다. 실제로 같은 ID를 만드는 코드가 여러곳에 흩어져 있다가, 신규 타입 추가 시 한 곳만 포맷이 달라서 네비게이션이 조용히 깨진 적이 있다. 유틸 함수로 포맷을 한 곳에 모아도, 그 유틸의 존재를 모르는 개발자는 또 하드코딩한다. 유틸을 만드는 것만으로는 부족하고, 유틸 사용을 타입 레벨에서 강제해야 한다.

해결

1. Branded Type

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 ≠ RoomId

2. Key Store

TanStack 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" (런타임 디버깅용)

3. 중첩 구조

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}`,
});

4. Self-Reference

팩토리 내부에서 같은 팩토리의 다른 키를 참조해 합성 키를 만들 수 있다.

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)}`,
});

설계 원칙

  • 이 모듈은 브랜딩 메커니즘만 담당한다. 실제 키 포맷(도메인 지식)은 각 도메인 모듈이 소유한다.
  • BrandedKeystring의 서브타입이므로 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 --noEmit

example-usage.ts@ts-expect-error 주석이 전부 유효한지 확인된다.

구현 기록 : https://donghyk2.tistory.com/189

About

branded type기반 string key 관리 팩토리

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors