TypeScript 타입 시스템 마스터하기: 고급 패턴과 실전 활용

TypeScript는 JavaScript에 정적 타입을 더한 언어입니다. 하지만 단순히 변수에 타입을 붙이는 것을 넘어, TypeScript의 강력한 타입 시스템을 활용하면 버그를 컴파일 타임에 잡고, 더 안전하고 유지보수하기 쉬운 코드를 작성할 수 있습니다. 이 글에서는 실무에서 유용한 TypeScript 고급 타입 패턴들을 살펴봅니다.
1. 제네릭의 진정한 힘
1.1 기본 제네릭
제네릭(Generics)은 타입을 매개변수화하는 기능입니다. 가장 기본적인 예시는 배열입니다. Array<number>와 Array<string>은 같은 구조이지만 다른 타입의 요소를 담습니다.
함수에서도 제네릭을 활용합니다. 입력 타입에 따라 출력 타입이 결정되는 함수를 만들 수 있습니다. 예를 들어, 첫 번째 요소를 반환하는 함수는 배열의 요소 타입을 그대로 반환 타입으로 사용합니다.
1.2 제네릭 제약 조건
extends 키워드로 제네릭에 제약을 걸 수 있습니다. 특정 속성을 가진 객체만 받도록 하거나, 특정 타입의 하위 타입만 허용할 수 있습니다. 이를 통해 함수 내부에서 해당 속성이나 메서드에 안전하게 접근할 수 있습니다.
1.3 조건부 타입과 제네릭
조건부 타입(Conditional Types)과 결합하면 더욱 강력해집니다. T extends U ? X : Y 형태로, 타입 수준에서 if-else 로직을 표현합니다. 예를 들어, 배열이면 요소 타입을 추출하고, 아니면 원본 타입을 그대로 사용하는 Unwrap 타입을 만들 수 있습니다.
2. 유틸리티 타입 활용
TypeScript는 다양한 내장 유틸리티 타입을 제공합니다. Partial은 모든 속성을 선택적으로, Required는 모든 속성을 필수로, Readonly는 모든 속성을 읽기 전용으로 만듭니다.
Pick과 Omit은 객체 타입에서 특정 속성만 선택하거나 제외합니다. 큰 인터페이스에서 일부만 필요할 때 유용합니다. Record는 키와 값의 타입을 지정하여 객체 타입을 생성합니다.
ReturnType은 함수의 반환 타입을, Parameters는 함수의 매개변수 타입을 추출합니다. 서드파티 라이브러리의 함수를 사용할 때 타입을 직접 정의하지 않고 추론할 수 있어 편리합니다.
3. 템플릿 리터럴 타입
TypeScript 4.1부터 템플릿 리터럴 타입이 도입되었습니다. 문자열 리터럴 타입을 조합하여 새로운 타입을 만들 수 있습니다. 예를 들어, 이벤트 핸들러 이름을 생성할 때 'click', 'focus' 같은 이벤트 이름에 'on'을 붙인 'onClick', 'onFocus' 타입을 자동으로 생성할 수 있습니다.
Uppercase, Lowercase, Capitalize, Uncapitalize 같은 문자열 조작 유틸리티 타입과 함께 사용하면 더욱 유연해집니다. API 응답 키의 명명 규칙을 변환하거나, CSS 속성 이름을 JavaScript 카멜케이스로 변환하는 타입을 정의할 수 있습니다.
4. 타입 가드와 좁히기
4.1 사용자 정의 타입 가드
is 키워드를 사용한 타입 가드 함수를 정의하면, 조건문 내에서 타입을 좁힐 수 있습니다. isString, isNumber 같은 기본적인 타입 확인부터, 복잡한 객체의 형태를 검증하는 함수까지 만들 수 있습니다.
4.2 판별된 유니온
공통 속성(판별자)을 가진 유니온 타입은 switch 문이나 조건문에서 자동으로 타입이 좁혀집니다. 예를 들어, type 속성이 'success'면 data가 있고, 'error'면 error가 있는 응답 타입을 정의하면, type으로 분기할 때 각 분기에서 올바른 속성만 접근할 수 있습니다.
4.3 exhaustive 체크
never 타입을 활용하면 모든 경우를 처리했는지 컴파일 타임에 확인할 수 있습니다. switch 문의 default에서 never를 사용하면, 새로운 케이스가 추가되었을 때 처리를 빠뜨리면 컴파일 에러가 발생합니다.
5. 고급 패턴
5.1 브랜드 타입
기본 타입에 가상의 브랜드를 붙여 구분할 수 있습니다. UserId와 ProductId가 둘 다 string이지만, 브랜드 타입을 사용하면 서로 호환되지 않게 만들 수 있습니다. 런타임 오버헤드 없이 타입 안전성을 높이는 기법입니다.
5.2 빌더 패턴 타입
메서드 체이닝을 타입 안전하게 구현할 수 있습니다. 각 메서드가 호출되면 반환 타입에 해당 정보를 누적하고, 필수 메서드가 모두 호출되어야만 build가 가능하도록 제한할 수 있습니다.
5.3 const assertion과 as const
as const는 리터럴 타입을 추론하게 합니다. 객체에 적용하면 모든 속성이 readonly가 되고, 값 자체가 타입이 됩니다. 설정 객체나 상수 매핑에서 유용합니다. 배열에 적용하면 튜플로 추론됩니다.
6. 타입 추론 최적화
6.1 infer 키워드
조건부 타입에서 infer는 타입을 추출하여 변수처럼 사용합니다. Promise의 resolve 값 타입을 추출하거나, 함수 매개변수의 특정 위치 타입을 추출할 수 있습니다.
6.2 분배적 조건부 타입
제네릭 타입 매개변수가 유니온일 때, 조건부 타입은 각 멤버에 분배되어 적용됩니다. 이를 활용하면 유니온의 각 멤버를 변환하는 타입을 간결하게 작성할 수 있습니다. 분배를 원하지 않으면 대괄호로 감싸면 됩니다.
7. satisfies 연산자 (TS 4.9+)
TypeScript 4.9에 도입된 satisfies 연산자는 타입 안전성을 유지하면서도 구체적인 타입 추론을 가능하게 합니다.
type Colors = 'red' | 'green' | 'blue';
type RGB = [number, number, number];
const palette = {
red: [255, 0, 0],
green: '#00ff00',
blue: [0, 0, 255]
} satisfies Record<Colors, string | RGB>;
// palette.green은 'string'으로 정확히 추론되어 string 메서드 사용 가능
palette.green.toUpperCase();
만약 satisfies 대신 Record<Colors, string | RGB> 타입을 명시했다면, palette.green은 string | RGB 유니온 타입이 되어 toUpperCase()를 바로 호출할 수 없었을 것입니다. 이처럼 satisfies는 "타입 검사는 하되, 타입 넓히기(Widening)는 하지 마라"는 의미입니다.
결론
TypeScript의 타입 시스템은 매우 표현력이 풍부합니다. 제네릭, 조건부 타입, 템플릿 리터럴 타입 등을 조합하면 런타임 오류를 컴파일 타임에 잡아내고, IDE의 자동 완성과 리팩토링 지원을 최대한 활용할 수 있습니다. 처음에는 복잡해 보일 수 있지만, 하나씩 익혀 나가면 더 안전하고 생산적인 코드를 작성할 수 있습니다.
추가 팁: Strict Mode와 Type Narrowing
tsconfig.json에서 strict: true는 noImplicitAny, strictNullChecks 등 여러 옵션을 한꺼번에 활성화합니다. 새 프로젝트에서는 반드시 strict 모드를 켜세요. 타입 좁히기(Type Narrowing)는 typeof, instanceof, in, 사용자 정의 타입 가드(is)를 통해 유니온 타입을 좁히는 것이 핵심이며, 이를 마스터하면 불필요한 타입 단언(as) 사용을 크게 줄일 수 있습니다.
X. 깊게 파헤치는 타입스크립트의 공변성(Covariance)과 브랜디드 타입 (Deep Dive)
제네릭과 인터페이스의 구조적 타이핑(Structural Typing) 수준을 마스터했다고 자부한다면, 이제 타입 이론의 진정한 심연인 변성(Variance)과 명목적 결합의 영역으로 한 걸음 더 나아갈 차례입니다.
1. 공변성, 반공변성(Contravariance), 그리고 이변성(Bivariance)의 이해
고객 객체를 상속받은 VVIP 고객 객체가 있을 때, 이들을 파라미터로 처리하는 함수의 관계는 어떻게 정의되어야 안전할까요?
TypeScript는 기본적으로 구조를 바탕으로 타입 호환성을 판단하므로 '공변성(Covariance)'을 따르는 편입니다. 즉 하위 타입이 상위 타입의 자리로 들어갈 수 있음을 의미합니다.
하지만 함수의 매개변수(Parameter) 영역에서만큼은 예외적으로 '반공변적(Contravariant)' 기준 내지는 타입스크립트 특유의 타협점인 이변성(Bivariance)이 혼재하여 작동합니다. strictFunctionTypes 옵션을 활성화하면 함수의 배반성이 막히면서 더욱 견고한 안전 고리를 얻을 수 있습니다. 만약 이 타입 간의 힘의 균형(Variance Rules)의 흐름을 정확히 꿰뚫는다면, 거대한 콜백 지옥 속에서 어떤 제네릭 인수가 들어가더라도 에러를 파생시키지 않고 완벽히 타입을 캐스팅하는 프레임워크 수준의 라이브러리 제작 스킬을 손에 넣은 것입니다.
2. 브랜디드 타입(Branded Types)과 명목적 타이핑 시뮬레이션
은행 어플리케이션을 만들 때 const amount: number = 1000;이라는 선언이 과연 완벽히 안전할까요? 개발자의 실수로 이 자리에 '유저의 나이'가 들어가도 TypeScript는 그저 같은 number 타입이라며 에러 없이 통과시켜 버리는 구조적 타이핑의 치명적인 허점이 존재합니다.
이 논리 오류를 막기 위해 실무진들은 타입의 '명목성'을 흉내 내는 브랜디드 타입을 활용합니다.
type KRW = number & { __brand: 'KRW' };
type USD = number & { __brand: 'USD' };
이 교묘한 교차 타입(Intersection) 트릭 하나로, JS 런타임에는 아무런 오버헤드가 없으면서도 KRW를 요구하는 함수에 USD 변수가 실수로 인입되는 컴파일 참사를 원천 차단해냅니다. 이렇듯 타입스크립트의 끝자락은 '언어의 구조'를 탐구하는 것을 넘어 '도메인의 비즈니스적 가치와 안전망'을 코드의 텍스트만으로 어떻게 설계할 것인가에 대한 눈부신 학문적 사유 그 자체입니다.
X. 깊게 파헤치는 타입스크립트의 공변성(Covariance)과 브랜디드 타입 (Deep Dive)
제네릭과 인터페이스의 구조적 타이핑(Structural Typing) 수준을 마스터했다고 자부한다면, 이제 타입 이론의 진정한 심연인 변성(Variance)과 명목적 결합의 영역으로 한 걸음 더 나아갈 차례입니다.
1. 공변성, 반공변성(Contravariance), 그리고 이변성(Bivariance)의 이해
고객 객체를 상속받은 VVIP 고객 객체가 있을 때, 이들을 파라미터로 처리하는 함수의 관계는 어떻게 정의되어야 안전할까요?
TypeScript는 기본적으로 구조를 바탕으로 타입 호환성을 판단하므로 '공변성(Covariance)'을 따르는 편입니다. 즉 하위 타입이 상위 타입의 자리로 들어갈 수 있음을 의미합니다.
하지만 함수의 매개변수(Parameter) 영역에서만큼은 예외적으로 '반공변적(Contravariant)' 기준 내지는 타입스크립트 특유의 타협점인 이변성(Bivariance)이 혼재하여 작동합니다. strictFunctionTypes 옵션을 활성화하면 함수의 배반성이 막히면서 더욱 견고한 안전 고리를 얻을 수 있습니다. 만약 이 타입 간의 힘의 균형(Variance Rules)의 흐름을 정확히 꿰뚫는다면, 거대한 콜백 지옥 속에서 어떤 제네릭 인수가 들어가더라도 에러를 파생시키지 않고 완벽히 타입을 캐스팅하는 프레임워크 수준의 라이브러리 제작 스킬을 손에 넣은 것입니다.
2. 브랜디드 타입(Branded Types)과 명목적 타이핑 시뮬레이션
은행 어플리케이션을 만들 때 const amount: number = 1000;이라는 선언이 과연 완벽히 안전할까요? 개발자의 실수로 이 자리에 '유저의 나이'가 들어가도 TypeScript는 그저 같은 number 타입이라며 에러 없이 통과시켜 버리는 구조적 타이핑의 치명적인 허점이 존재합니다.
이 논리 오류를 막기 위해 실무진들은 타입의 '명목성'을 흉내 내는 브랜디드 타입을 활용합니다.
type KRW = number & { __brand: 'KRW' };
type USD = number & { __brand: 'USD' };
이 교묘한 교차 타입(Intersection) 트릭 하나로, JS 런타임에는 아무런 오버헤드가 없으면서도 KRW를 요구하는 함수에 USD 변수가 실수로 인입되는 컴파일 참사를 원천 차단해냅니다. 이렇듯 타입스크립트의 끝자락은 '언어의 구조'를 탐구하는 것을 넘어 '도메인의 비즈니스적 가치와 안전망'을 코드의 텍스트만으로 어떻게 설계할 것인가에 대한 눈부신 학문적 사유 그 자체입니다.
X. 깊게 파헤치는 타입스크립트의 공변성(Covariance)과 브랜디드 타입 (Deep Dive)
제네릭과 인터페이스의 구조적 타이핑(Structural Typing) 수준을 마스터했다고 자부한다면, 이제 타입 이론의 진정한 심연인 변성(Variance)과 명목적 결합의 영역으로 한 걸음 더 나아갈 차례입니다.
1. 공변성, 반공변성(Contravariance), 그리고 이변성(Bivariance)의 이해
고객 객체를 상속받은 VVIP 고객 객체가 있을 때, 이들을 파라미터로 처리하는 함수의 관계는 어떻게 정의되어야 안전할까요?
TypeScript는 기본적으로 구조를 바탕으로 타입 호환성을 판단하므로 '공변성(Covariance)'을 따르는 편입니다. 즉 하위 타입이 상위 타입의 자리로 들어갈 수 있음을 의미합니다.
하지만 함수의 매개변수(Parameter) 영역에서만큼은 예외적으로 '반공변적(Contravariant)' 기준 내지는 타입스크립트 특유의 타협점인 이변성(Bivariance)이 혼재하여 작동합니다. strictFunctionTypes 옵션을 활성화하면 함수의 배반성이 막히면서 더욱 견고한 안전 고리를 얻을 수 있습니다. 만약 이 타입 간의 힘의 균형(Variance Rules)의 흐름을 정확히 꿰뚫는다면, 거대한 콜백 지옥 속에서 어떤 제네릭 인수가 들어가더라도 에러를 파생시키지 않고 완벽히 타입을 캐스팅하는 프레임워크 수준의 라이브러리 제작 스킬을 손에 넣은 것입니다.
2. 브랜디드 타입(Branded Types)과 명목적 타이핑 시뮬레이션
은행 어플리케이션을 만들 때 const amount: number = 1000;이라는 선언이 과연 완벽히 안전할까요? 개발자의 실수로 이 자리에 '유저의 나이'가 들어가도 TypeScript는 그저 같은 number 타입이라며 에러 없이 통과시켜 버리는 구조적 타이핑의 치명적인 허점이 존재합니다.
이 논리 오류를 막기 위해 실무진들은 타입의 '명목성'을 흉내 내는 브랜디드 타입을 활용합니다.
type KRW = number & { __brand: 'KRW' };
type USD = number & { __brand: 'USD' };
이 교묘한 교차 타입(Intersection) 트릭 하나로, JS 런타임에는 아무런 오버헤드가 없으면서도 KRW를 요구하는 함수에 USD 변수가 실수로 인입되는 컴파일 참사를 원천 차단해냅니다. 이렇듯 타입스크립트의 끝자락은 '언어의 구조'를 탐구하는 것을 넘어 '도메인의 비즈니스적 가치와 안전망'을 코드의 텍스트만으로 어떻게 설계할 것인가에 대한 눈부신 학문적 사유 그 자체입니다.