Waylog Blog

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

TypeScript

TypeScript Mastery

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.greenstring | RGB 유니온 타입이 되어 toUpperCase()를 바로 호출할 수 없었을 것입니다. 이처럼 satisfies는 "타입 검사는 하되, 타입 넓히기(Widening)는 하지 마라"는 의미입니다.

결론

TypeScript의 타입 시스템은 매우 표현력이 풍부합니다. 제네릭, 조건부 타입, 템플릿 리터럴 타입 등을 조합하면 런타임 오류를 컴파일 타임에 잡아내고, IDE의 자동 완성과 리팩토링 지원을 최대한 활용할 수 있습니다. 처음에는 복잡해 보일 수 있지만, 하나씩 익혀 나가면 더 안전하고 생산적인 코드를 작성할 수 있습니다.