광고
~ / posts / developer
💻 개발자

타입스크립트 구조적 타이핑 실무 판단표, 브랜드 타입과 초과 속성 구멍

타입스크립트는 이름이 아니라 모양으로 타입을 맞춰 봐요. 그래서 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 선언 위치가 달라야 해요.

섞이면 버그인 값인지, JSON·DB 를 오가는지, 동작이 많은지 세 질문으로 type 별칭·브랜드 타입·#private 클래스 중 하나를 고르는 순서도
ID 처럼 저장·전송되는 값은 대부분 브랜드 타입으로 끝나요

클래스가 명목적으로 동작하는 이유는 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 는 여기서만
}

브랜드 타입과 래퍼 객체 비용 — ID 100만 개로 잰 숫자

"타입 하나 추가하려고 객체를 만들면 비싸지 않나" 를 숫자로 확인했어요. "u-0" ~ "u-999999" 문자열 100만 개를 배열에 담되, ① 문자열 그대로(브랜드 타입은 컴파일 뒤 이것과 같아요) ② { value } 객체 ③ #private 필드 클래스로 감싸서 GC 뒤 힙 증가량과 생성 시간을 쟀어요. 세 번 돌려 중앙값을 썼어요.

ID 하나가 차지하는 힙 (배열 칸 포함)
브랜드(문자열 그대로)브랜드(문자열 그대로) · 32.1바이트32.1바이트{ value } 래퍼{ value } 래퍼 · 64.1바이트64.1바이트#private 클래스#private 클래스 · 64.1바이트64.1바이트

래퍼는 객체 헤더와 필드 칸만큼 ID 하나에 32바이트씩 더 들어요

단위: 바이트 · 자료: Node.js 25.3·Apple M1, ID 100만 개를 만들고 --expose-gc 로 GC 한 뒤 heapUsed 차이를 개수로 나눔 (3회 같은 값)
표로 보기
구분ID 하나당 힙
브랜드(문자열 그대로)32.1
{ value } 래퍼64.1
#private 클래스64.1
ID 100만 개 생성 시간
브랜드(문자열 그대로)브랜드(문자열 그대로) · 304ms304ms{ value } 래퍼{ value } 래퍼 · 291ms291ms#private 클래스#private 클래스 · 404ms404ms

문자열 만드는 비용이 대부분이라 브랜드와 래퍼는 오차 범위, 클래스는 약 1.3배

단위: ms · 자료: Node.js 25.3·Apple M1, 3회 중앙값 (문자열 결합 포함)
표로 보기
구분생성 시간
브랜드(문자열 그대로)304
{ value } 래퍼291
#private 클래스404

컴파일 시간도 확인했어요. 판별 속성이 있는 객체 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> 에통과
초과 속성 검사가 잡는 10가지 자리와 조용히 통과하는 9가지 자리를 두 카드로 나눠 비교한 그림
타입이 자리에 바로 붙어 있으면 잡히고, 중간에 추론이 끼면 꺼져요

실무에서 사고가 잦은 건 콜백 반환이에요. 리액트 상태 갱신 setUser(u => ({ ...u, nmae: "새 이름" })) 같은 코드는 오타가 그대로 들어가고, 원래 name 은 바뀌지 않아요. 대처는 셋 중 하나예요.

  1. 콜백에 반환 타입을 적어요: setUser((u): User => ({ ...u, name })).
  2. 리터럴을 먼저 satisfies 로 검사받고 넘겨요.
  3. 설정 객체처럼 한 번 만들어 여러 곳에 넘기는 값은 선언하는 줄에 타입 주석이나 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 propertynpm 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 같은 멤버에 실제로 들어가 있는지 확인하세요.

도입 전에 따질 것

브랜드 타입이나 클래스 기반 값 객체를 기존 코드에 들일 때 확인할 것들이에요.

광고
광고

자주 묻는 질문

브랜드 타입을 쓰면 런타임 성능이 떨어지나요?

아니요. 브랜드는 타입에만 있고 컴파일 뒤에는 그냥 문자열이에요. 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")이 있으면 검사기가 리터럴이 어느 멤버를 노린 것인지 하나로 고를 수 있어서 그 멤버 기준으로 초과 속성을 봐요. 판별 속성이 없으면 고를 기준이 없어 유니언 전체의 속성 이름을 다 허용해요. 하나만 허용해야 하는 옵션 객체라면 판별 속성을 두세요.

정리

타입 좁히기 쪽 판단은 타입스크립트 타입 좁히기 고르는 법에서 이어서 볼 수 있어요.

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 타입스크립트 구조적 타이핑·타입 추론 원리 — 대입 판정과 넓히기에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#타입스크립트 구조적 타이핑#브랜드 타입#초과 속성 검사#타입 호환성#NoInfer#const 타입 매개변수#제네릭 추론 오류#Types have separate declarations of a private property
← 이전 글트랜잭션 격리 수준 고르는 법, MySQL·PostgreSQL 판단표와 재시도 비용다음 글 →투기적 디코딩 초안 모델 고르기, 크기 비율·수락률로 γ 정하는 법
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →