typescript · high priority
TypeScript 타입 시스템
구조적 타이핑 · 좁히기(narrowing) · top/bottom — 컴파일러는 무엇을 알고 무엇을 모르는가
학습 개요
탄생 배경
쉬운 설명
복잡한 개념을 실생활 비유로 설명합니다.
“아파트 임대 계약서 vs 실제 입주민”
TypeScript 타입은 *임대 계약서* 입니다. 계약서엔 "방 3개, 화장실 2개" 라고 적혀 있고 (타입), 실제 *입주민* 은 런타임 객체죠. 구조적 타이핑은 "*이름이 우리 가족이 아니어도, 방 3개 화장실 2개에 잘 맞으면 입주 OK*". `unknown` 은 "*신원 확인 전엔 집 열쇠 안 줘*", `any` 는 "*아무나 들여보내고 알아서 쓰세요*". `never` 는 "*그 방에는 누구도 들어갈 수 없음*". 그리고 결정적으로 — 입주 첫날 (런타임) 에는 *계약서가 사라집니다*. 그래서 외부에서 들어오는 사람은 (Zod 같은) *대문 검문* 이 필요합니다.
핵심 개념
명목적(nominal) vs 구조적(structural)
- 타입의 *이름* 이 다르면 비호환
- 명시적
implements선언 필요 - 캡슐화·무결성에 강함
- 리팩터링 안전
- 필드 모양만 같으면 호환
implements없어도 매칭- 런타임 JS 의 덕 타이핑과 정합
- 의도치 않은 호환을 막으려면 *brand* 필요
1interface Point2D { x: number; y: number; }2interface Vector2D { x: number; y: number; }34function logPoint(p: Point2D) {5 console.log(p.x, p.y);6}78const v: Vector2D = { x: 1, y: 2 };9logPoint(v); // ✅ 이름은 다르지만 구조가 같다
구조적 타이핑의 함정 — "id 가 string 인 다른 타입" 들이 다 섞임
UserId 와 OrderId 가 모두 string 이면 컴파일러는 *구별하지 못합니다*. 의도된 명목성이 필요할 때는 *brand* 패턴 (type UserId = string & { __brand: "UserId" }) 으로 *구조 위에 표지* 를 얹는 것이 표준 해법.
실무 적용
어떤 상황에서 사용하는가
레거시 JS 프로젝트에 TS 를 점진적으로 도입하는 중. 외부 API 응답을 다루는 부분에서 `any` 가 도배되어 있고, 도메인 객체끼리 *string id 가 섞이는* 버그가 빈번.
어떻게 적용하는가
(1) `tsconfig.json` 에 `strict: true` + `noUncheckedIndexedAccess` 켜기. (2) `any` → `unknown` 으로 1차 치환 — 사용 전 좁히기를 강제. (3) 외부 API 경계에 *Zod 스키마* 를 두고 `User.parse(json)` 로 런타임 검증을 통과한 결과만 도메인 타입으로 통과시킴 — `z.infer<typeof User>` 로 타입과 런타임 검증이 *한 소스*. (4) 도메인 ID 는 *brand 패턴* 으로 명목성 부여 (`type UserId = string & { __brand: "UserId" }`) — `string` 이 섞이는 버그를 컴파일에서 차단. (5) 상태/응답 타입은 *식별 유니온* 으로 모델링하고 switch + `never` exhaustiveness 체크로 *케이스 누락을 컴파일 시점* 에 잡는다. (6) 컴포넌트 prop 의 변환·합성 파일에는 `as` 대신 `satisfies` 를 우선 — 추론된 좁은 타입 보존.
흔한 실수와 안티패턴
- `as` 로 일단 입을 막고 시작 → *런타임 폭발* 의 시한 폭탄.
- 외부 입력에 Zod 같은 검증 없이 타입만 붙임 → 타입과 런타임 어긋남.
- 구조적 타이핑을 잊고 *string id 가 다 같은 타입* 이라 가정 → 잘못된 ID 끼리 섞임.
- 식별 유니온의 default 분기에서 `never` 체크를 안 둬 케이스 추가 누락.
- `any` 가 한 번 들어오면 *전염* 됨 — 함수 경계마다 차단.
흔한 오해
"`unknown` 과 `any` 는 거의 같다."
교정`any` 는 *체크를 끔*, `unknown` 은 *모르지만 안전하게 다룬다*. `unknown` 은 사용 전 *좁히기를 강제* 한다.
왜 중요API 경계에서 `any` 는 사실상 JS 로 회귀, `unknown` 은 *경계에서 검증* 을 강제하는 도구로 쓰이도록 설계되었다.
"`interface` 는 객체에만, `type` 은 그 외 전부 — 둘 중 하나만 쓰자."
교정*객체 모양은 interface (선언 병합 가능)*, *유니온·매핑·조건은 type* 으로 *역할별* 사용이 실용적. 일관성보다 표현력.
왜 중요TS Handbook 도 둘의 차이를 *선언 병합 + 합성 자유도* 로 정리하며, 둘을 모두 쓰는 것을 명시적으로 권한다.
"타입이 있으니 런타임도 안전하다."
교정타입은 컴파일 시점에만 존재 — *지워진다(erasure)*. 외부 입력은 Zod/Valibot 같은 *런타임 검증* 이 필요.
왜 중요JSON, URL, localStorage 같은 경계는 컴파일러가 보장하지 않는다. 타입은 *내부* 흐름의 검사, 런타임 검증은 *경계* 의 검사.
면접 질문
답변 방향 힌트
"top type", "bottom type", "좁히기 강제" 가 키워드.
반드시 언급할 키워드
- `any`: 체크를 끔 — 어떤 연산도 통과, 전염성
- `unknown`: 모든 값을 받지만 *사용 전 좁히기 강제*
- `never`: 어떤 값도 가질 수 없는 bottom — exhaustiveness 체크
- 외부 입력 1차 타입은 `unknown` + Zod 등 검증
- switch default 의 `never` 변수로 케이스 누락 컴파일 에러
예상 꼬리 질문
- `any` 가 들어왔을 때 그 *전염성* 을 막는 패턴은?
- `assertNever` 같은 헬퍼 함수의 시그니처를 적어보세요.
자기 점검
구조적 타이핑이 만들어낸 *bug 한 가지* 와 그 해결 패턴 한 줄로?
기대 키워드
자주 하는 오해
"같은 타입이면 같은 의미" — `string` 두 개도 도메인이 다르면 *brand* 로 분리해야 한다.
식별 유니온의 *exhaustiveness 체크* 패턴을 한 줄 코드로 적으면?
기대 키워드
자주 하는 오해
`default: throw new Error()` 만으로는 컴파일 시점 안전망이 없다 — `never` 변수가 필요.
`unknown` 으로 받은 값을 안전하게 사용하는 *최소 단계* 는?
기대 키워드
자주 하는 오해
`unknown` 위에서 바로 프로퍼티 접근이 가능하다고 오해 — 좁히기 없이 접근 불가.
학습 자료
- TypeScript Handbook — Narrowing제어 흐름 분석과 좁히기 메커니즘 공식 가이드.Doctypescriptlang.org
- TypeScript 5.5 Release Notes — Inferred Type Predicatesfilter 등에서 자동 type predicate 추론. 5.5 의 가장 큰 narrowing 개선.Doctypescriptlang.org
- TypeScript Handbook — Type Compatibility (Structural)구조적 타이핑의 정의와 호환성 규칙.Doctypescriptlang.org
- Announcing TypeScript 5.5inferred predicates · const type parameters 보강 · regex syntax checking.BlogMicrosoft DevBlogs · 2024
- Effective TypeScript — `unknown` 타입 활용`unknown` 을 *경계* 의 1차 타입으로 두는 패턴.Blogeffectivetypescript.com