타입스크립트는 이름이 아니라 모양으로 타입을 맞춰 봐요. 그래서 UserId 와 OrderId 가 둘 다 문자열이면 서로 섞여도 아무 말이 없고, 설정 객체를 변수로 한 번 빼면 colour 같은 오타도 조용히 지나가요. 결론부터 말하면 섞이면 안 되는 값은 브랜드 타입으로, 오타를 잡아야 하는 객체는 선언 자리에 타입을 붙여서, 추론이 엉뚱한 제네릭은 NoInfer·const 타입 매개변수로 막는 게 실무에서 가장 싸요.
이 글은 원리 설명 대신 언제 무엇을 고르는지, 운영에서 무엇부터 의심할지를 다뤄요. 모든 예제는 typescript@7.0.2(npm latest)의 tsc --strict 로 돌려서 에러가 나는지 직접 확인했고, 런타임 비용은 Apple M1·Node.js 25.3 에서 쟀어요. 규칙의 근거는 TypeScript 핸드북 Type Compatibility와 각 버전 릴리스 노트예요.
같은 모양, 다른 의미 — 타입 구분 방법 판단표
구조적 타이핑에서 가장 자주 사고가 나는 곳은 모양은 같고 뜻이 다른 값이에요. 사용자 ID 와 주문 ID, 원과 달러, 정규화 전 문자열과 정규화 후 문자열처럼요. 고를 수 있는 수단은 다섯 가지예요.
| 수단 | 섞임 차단 | 런타임 흔적 | JSON 으로 나갈 때 | 맞는 경우 |
|---|---|---|---|---|
type UserId = string | 안 됨 | 없음 | 문자열 그대로 | 읽기 쉽게 이름만 붙일 때 |
브랜드 string & { readonly __brand: "UserId" } | 됨 | 없음 | 문자열 그대로 | ID·단위·검증 끝난 값 |
unique symbol 브랜드 | 됨 | 없음 | 문자열 그대로 | 라이브러리 공개 API (속성 이름 충돌 방지) |
private·#private 필드 클래스 | 됨 | 객체 하나 | #private 는 {} 로 사라짐 | 값에 메서드·불변식이 많을 때 |
{ value: string } 래퍼 객체 | 안 됨 (모양이 같으면 섞임) | 객체 하나 | {"value":"..."} 로 모양이 바뀜 | 거의 없음 |
판단표에서 놓치기 쉬운 줄이 마지막이에요. 래퍼 객체를 만들어도 다른 래퍼와 모양이 같으면 다시 섞여요. 구분이 목적이라면 속성 이름이 아니라 브랜드 리터럴이나 private 선언 위치가 달라야 해요.
클래스가 명목적으로 동작하는 이유는 private 멤버의 선언 위치를 비교하기 때문이에요. 모양이 똑같은 두 클래스에 private id 를 각각 두면 tsc 7.0.2 가 이렇게 막아요.
error TS2322: Type 'A2' is not assignable to type 'A1'.
Types have separate declarations of a private property 'id'.
ES 표준 #id 필드도 똑같이 막히고, 메시지만 "refers to a different member" 로 달라요. 클래스 쪽 규칙은 핸드북 Classes 문서에 정리돼 있어요.
브랜드 타입 만드는 규칙
브랜드는 "검증을 통과한 값" 이라는 표시라서, as 로 브랜드를 붙이는 곳을 생성 함수 한 곳으로 묶어야 의미가 있어요.
type UserId = string & { readonly __brand: "UserId" };
export function toUserId(raw: string): UserId {
if (!/^u-\d+$/.test(raw)) throw new Error(`bad user id: ${raw}`);
return raw as UserId; // as 는 여기서만
}
- API 응답·DB 행·URL 파라미터에서 들어오는 값은 모두 이 함수를 거치게 해요. 파싱 계층(스키마 검증 라이브러리)에서 한 번에 붙이면 빠뜨릴 일이 줄어요.
- 코드 곳곳에
"u-1" as UserId가 보이면 브랜드가 아무것도 보장하지 않는 상태예요. 린트로as UserId를 생성 함수 파일 밖에서 막는 팀도 많아요. - 브랜드 타입은
string의 하위 타입이라UserId를string자리에 넣는 건 자유로워요. 반대 방향만 막혀요.
브랜드 타입과 래퍼 객체 비용 — ID 100만 개로 잰 숫자
"타입 하나 추가하려고 객체를 만들면 비싸지 않나" 를 숫자로 확인했어요. "u-0" ~ "u-999999" 문자열 100만 개를 배열에 담되, ① 문자열 그대로(브랜드 타입은 컴파일 뒤 이것과 같아요) ② { value } 객체 ③ #private 필드 클래스로 감싸서 GC 뒤 힙 증가량과 생성 시간을 쟀어요. 세 번 돌려 중앙값을 썼어요.
- 메모리: ID 1,000만 개를 메모리에 들고 있는 서비스라면 래퍼 때문에 약 320MB 가 더 들어요(32바이트 × 1,000만). 요청 하나에서 수백 개 쓰는 수준이면 무시해도 돼요.
- 조회: 같은 ID 로
Map.has를 100만 번 부르는 시간은 세 방식 모두 464~498ms 로 차이가 없었어요. 문자열 해시가 시간을 대부분 써요. - 직렬화: 이쪽 차이가 더 커요.
JSON.stringify결과가 브랜드는"u-1"(5바이트), 래퍼는{"value":"u-1"}(15바이트)이고,#private클래스는{}로 값이 통째로 사라져요. 비공개 필드는 열거되지 않기 때문이에요. 클래스를 고르면toJSON()을 꼭 달아야 해요.
컴파일 시간도 확인했어요. 판별 속성이 있는 객체 800종의 유니언을 모양이 같은 다른 800종 유니언에 대입해 봐도 tsc --checkers 1 의 Check time 이 빈 파일(0.84~1.4초)과 구분되지 않았어요. 구조 비교 자체를 성능 이유로 피할 일은 거의 없어요. 검사가 느릴 때 볼 곳은 타입스크립트 strict 옵션과 tsc 검사 한계 글에 따로 정리했어요.
초과 속성 검사가 꺼지는 자리 — 19가지 직접 확인
초과 속성 검사는 구조 규칙이 아니라 "바로 넘긴 객체 리터럴에 없는 속성이 있으면 오타일 것" 이라는 보조 검사예요(핸드북 Object Types). 그래서 리터럴과 타입 사이에 무언가 한 단계가 끼면 꺼져요. interface Pet { name: string } 에 owner 를 더 넣는 식으로 19가지를 돌린 결과예요.
| 쓰는 방식 | 7.0.2 결과 | |
|---|---|---|
cfg({ colour: "red" }) 처럼 인수에 바로 | 잡힘 (TS2561, "Did you mean 'color'?" 까지 알려 줌) | |
const p: Pet = {...}, 배열 원소, 삼항의 양쪽 | 잡힘 | |
반환 타입을 적은 함수의 return, async 함수의 Promise<Pet> 반환 | 잡힘 | |
{...} satisfies Pet | 잡힘 | |
판별 속성(kind)이 있는 유니언 | 잡힘 | |
{ ...base, age: 3 } 에서 직접 적은 age | 잡힘 (base 안의 속성은 안 봄) | |
| 모든 속성이 선택적인 타입에 공통 속성 0개인 변수 | 잡힘 (TS2559, 약한 타입 검사) | |
map((n): Pet => ({...})) 처럼 콜백 반환 타입을 적음 | 잡힘 | |
| 변수에 담았다가 넘김 (공통 속성이 하나라도 있음) | 통과 | |
{...} as Pet | 통과 | |
{ ...base } 펼침만 | 통과 | |
const a: Pet[] = list.map(n => ({...})) | 통과 | |
setP(p => ({ ...p, owner: "x" })) 처럼 문맥 타입이 있는 콜백 반환 | 통과 | |
| 판별 속성 없는 유니언 `{a} \ | {b} 에 {a, b}` | 통과 |
Object.assign({ name }, { owner }) | 통과 | |
id<T>(x: T): T 같은 제네릭 함수를 거침 | 통과 | |
Promise.resolve({...}) 를 Promise<Pet> 에 | 통과 |
실무에서 사고가 잦은 건 콜백 반환이에요. 리액트 상태 갱신 setUser(u => ({ ...u, nmae: "새 이름" })) 같은 코드는 오타가 그대로 들어가고, 원래 name 은 바뀌지 않아요. 대처는 셋 중 하나예요.
- 콜백에 반환 타입을 적어요:
setUser((u): User => ({ ...u, name })). - 리터럴을 먼저
satisfies로 검사받고 넘겨요. - 설정 객체처럼 한 번 만들어 여러 곳에 넘기는 값은 선언하는 줄에 타입 주석이나
satisfies를 붙여요. 주석·satisfies·as const중 무엇을 고를지는 앞의 strict 옵션 글 판단표를 보면 돼요.
판별 속성 없는 유니언이 통과하는 것도 기억해 두세요. { a } | { b } 는 "둘 중 하나" 가 아니라 "적어도 하나" 처럼 동작해요. 정말 하나만 허용하려면 { a: number; b?: never } | { b: number; a?: never } 처럼 반대쪽을 never 로 막거나 kind 판별 속성을 두세요.
제네릭 추론이 엉뚱할 때 고르는 수단
제네릭 함수는 인수마다 T 후보를 모아 하나로 합쳐요(핸드북 Generics). 그래서 "추론에 쓰이면 안 되는 인수" 가 후보로 끼거나, 원하는 것보다 넓게 합쳐지는 일이 생겨요. 증상별로 고를 수단이에요.
| 증상 | 예 | 고를 수단 | 7.0.2 결과 | |
|---|---|---|---|---|
기본값·대조값 인수가 T 를 넓힘 | withDefault(["sm","md"], "lg") 가 통과 | 그 매개변수를 NoInfer<T> 로 (5.4~) | "lg" 에 TS2345 | |
배열·튜플 인수가 string[] 로 넓혀짐 | routes(["/a","/b"]) → string[] | <const T extends readonly string[]> (5.0~) | readonly ["/a", "/b"] | |
| 서로 다른 타입 인수를 같이 받아야 함 | pick("a", 1) 이 "a" 로 굳어 에러 | 호출에 타입 인자 명시 `pick<string \ | number>` | 통과 |
결과를 let 에 담았더니 리터럴이 사라짐 | let b = pick("a","b") → string | 의도한 것. 리터럴이 필요하면 const 나 주석 | — | |
콜백 매개변수가 any/unknown | 콜백이 다른 인수보다 앞에 있음 | 추론 재료가 되는 인수를 앞으로, 콜백은 뒤로 | — |
NoInfer 는 TypeScript 5.4 릴리스 노트, const 타입 매개변수는 TypeScript 5.0 릴리스 노트에 나와 있어요. 둘 다 함수 선언 쪽에서 고치는 수단이라, 라이브러리 API 를 만드는 사람이 쓰기 좋아요. 호출하는 쪽에서는 타입 인자 명시가 가장 확실해요.
에러 메시지 읽는 요령도 하나 있어요. Argument of type '1' is not assignable to parameter of type '"a"' 처럼 매개변수 타입이 리터럴 하나로 찍혀 있으면 대개 앞 인수가 T 를 먼저 정해 버린 경우예요. 뒤 인수가 틀렸다고 보기보다 T 가 무엇으로 굳었는지부터 보세요.
콜백 타입은 속성 문법으로 — 메서드 문법의 구멍
strictFunctionTypes 를 켜도 메서드 문법으로 쓴 타입은 매개변수를 양방향으로 허용해요(TSConfig strictFunctionTypes). 7.0.2 에서 확인한 결과예요.
type Handler = { on: (e: string | number) => void }; // 속성 문법
type Handler2 = { on(e: string | number): void }; // 메서드 문법
const onlyText = (e: string) => e.trim();
const h1: Handler = { on: onlyText }; // TS2322 로 막힘
const h2: Handler2 = { on: onlyText }; // 통과 → 숫자가 오면 런타임 TypeError
이벤트 핸들러·플러그인 훅처럼 밖에서 함수를 받아 오는 자리는 속성 문법으로 쓰는 게 안전해요. 팀 전체에 강제하려면 typescript-eslint 의 method-signature-style 규칙을 property 로 켜면 돼요. 이 규칙은 자동 수정도 돼요. 다만 클래스가 구현하는 인터페이스를 바꾸면 기존 구현이 새로 에러가 날 수 있으니, 에러 수를 먼저 세 보고 켜세요.
운영에서 자주 나는 문제와 확인 순서
| 증상 | 먼저 볼 것 | 고치는 법 |
|---|---|---|
Types have separate declarations of a private property | npm ls <패키지> 로 같은 라이브러리가 다른 버전으로 두 벌인지 | 버전 맞추기, overrides/resolutions 로 하나로 |
| 설정 오타가 배포 뒤에야 발견됨 | 설정 객체 선언 줄에 타입이 붙었는지 | 선언 줄에 satisfies Config |
| 상태 갱신이 반영 안 됨 | setX(p => ({...p, ...})) 안의 속성 이름 | 콜백 반환 타입 명시 |
| 서로 다른 ID 가 함수 인수에서 섞임 | 그 ID 가 type X = string 별칭인지 | 브랜드 타입 + 생성 함수 |
Box<string> 자리에 Box<number> 가 들어감 | Box 정의 안에서 T 를 실제로 쓰는지 | 쓰지 않는 타입 매개변수 제거, 또는 멤버에 사용 |
클래스 객체를 API 로 보냈더니 {} | #private 필드만 있는지 | toJSON() 추가, 또는 브랜드 타입으로 |
첫 줄은 직접 재현해 봤어요. node_modules/lib 와 node_modules/plugin/node_modules/lib 에 private 필드가 있는 같은 Client 클래스 선언을 두고, plugin 이 돌려준 Client 를 최상위 Client 에 대입했어요. 두 package.json 의 이름·버전이 같으면 tsc 가 하나로 합쳐 에러가 없었고, 안쪽 버전만 1.1.0 으로 바꾸자 위 에러가 났어요. 그러니 이 에러를 보면 코드보다 잠금 파일부터 보는 게 빨라요. private 멤버가 없는 클래스라면 버전이 달라도 모양만 맞으면 통과해서, 더 늦게 런타임에서 드러날 수도 있어요.
다섯째 줄은 핸드북의 Empty<T> 예와 같은 원리예요. 타입 매개변수는 구조에 나타날 때만 비교에 쓰여요. "단위를 타입 인자로 구분하자" 며 Money<"KRW"> 를 만들었다면 그 인자가 currency: C 같은 멤버에 실제로 들어가 있는지 확인하세요.
도입 전에 따질 것
브랜드 타입이나 클래스 기반 값 객체를 기존 코드에 들일 때 확인할 것들이에요.
- 경계가 어디인가 — 값이 들어오는 곳(HTTP 핸들러, DB 매퍼, 큐 소비자)을 목록으로 뽑아 생성 함수를 붙일 자리를 먼저 정해요. 경계가 많을수록
as가 퍼지기 쉬워요. - 직렬화 모양을 바꿔도 되나 — 클라이언트·다른 서비스가
"u-1"을 기대하면 래퍼·클래스는 쓰면 안 돼요. 브랜드는 바깥 모양이 그대로예요. JSON 샘플에서 타입을 뽑을 때는 JSON → TypeScript 변환기로 먼저string인터페이스를 만든 뒤 ID 필드만 브랜드로 바꾸면 손이 덜 가요. - 라이브러리 공개 API 인가 — 공개 타입에
__brand같은 평범한 속성 이름을 쓰면 다른 라이브러리 브랜드와 이름이 겹칠 수 있어요.declare const brand: unique symbol을 키로 쓰면 겹치지 않아요. - 테스트 픽스처 — 테스트 코드에서
"u-1" as UserId가 대량으로 생기기 쉬워요. 픽스처용 생성 함수(userId(1))를 따로 두면 나중에 형식이 바뀌어도 한 곳만 고쳐요. - 점진 도입 — 한 번에 바꾸지 말고 ID 종류 하나(예: 결제 ID)부터 바꿔 에러 수를 세 보세요. 에러가 많이 나는 곳이 실제로 ID 가 섞여 흐르던 곳이에요.
자주 묻는 질문
브랜드 타입을 쓰면 런타임 성능이 떨어지나요?
아니요. 브랜드는 타입에만 있고 컴파일 뒤에는 그냥 문자열이에요. ID 100만 개로 재 보니 힙이 ID 하나에 32.1바이트로 문자열과 같았어요. 비용이 생기는 건 { value } 래퍼나 클래스로 감쌀 때이고, 그때는 하나에 32바이트씩 더 들었어요.
private 키워드와 #private 중 무엇으로 명목적 타입을 만들어야 하나요?
타입 구분만 보면 둘 다 막혀요. 차이는 런타임이에요. #private 는 진짜로 숨겨져 JSON.stringify 에서 사라지고, private 키워드는 타입에서만 숨겨져 JSON 에 그대로 나가요. 외부로 내보내는 값이면 어느 쪽이든 toJSON() 을 직접 정하는 게 안전해요.
초과 속성 검사를 변수에도 항상 적용하는 옵션이 있나요?
tsconfig 에 그런 옵션은 없어요. 변수 선언 줄에 타입 주석이나 satisfies 를 붙이는 게 사실상 유일한 방법이에요. 콜백 반환처럼 자주 놓치는 자리는 반환 타입을 적는 습관으로 막아요.
NoInfer 를 쓸 수 없는 낮은 버전에서는 어떻게 하나요?
5.4 전에는 <C extends string, D extends C> 처럼 기존 매개변수에 묶인 타입 매개변수를 하나 더 두는 우회를 썼어요. 5.4 릴리스 노트도 이 방식을 소개하면서, 서명에 한 번만 쓰이는 타입 매개변수라 어색하다고 적어요. 가능하면 5.4 이상으로 올리고 NoInfer 를 쓰는 게 나아요. 현재 npm latest 는 7.0.2 예요.
판별 유니언에서 초과 속성이 잡히는데 일반 유니언에서는 안 잡히는 이유가 뭔가요?
판별 속성(kind: "c")이 있으면 검사기가 리터럴이 어느 멤버를 노린 것인지 하나로 고를 수 있어서 그 멤버 기준으로 초과 속성을 봐요. 판별 속성이 없으면 고를 기준이 없어 유니언 전체의 속성 이름을 다 허용해요. 하나만 허용해야 하는 옵션 객체라면 판별 속성을 두세요.
정리
- 섞이면 버그인 값은 브랜드 타입 + 생성 함수 한 곳이 기본이에요. 메모리·직렬화 비용이 0이고, 클래스는 동작이 많을 때만 골라요.
- 초과 속성 검사는 19가지 중 10가지에서만 걸려요. 변수·
as·펼침·콜백 반환·제네릭 경유는 통과하니, 선언 줄satisfies와 콜백 반환 타입으로 막아요. - 제네릭이 엉뚱하게 넓어지면
NoInfer·const타입 매개변수·타입 인자 명시 순서로 봐요. 매개변수 타입이 리터럴 하나로 찍힌 에러는 앞 인수가T를 정해 버린 신호예요. - "separate declarations of a private property" 는 코드보다 잠금 파일의 중복 버전부터 확인해요.
타입 좁히기 쪽 판단은 타입스크립트 타입 좁히기 고르는 법에서 이어서 볼 수 있어요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 타입스크립트 구조적 타이핑·타입 추론 원리 — 대입 판정과 넓히기에서 도식과 코드로 단계별로 풀었어요.