TypeSafety 완벽 마스터
TypeSafety의 핵심 개념과 실전 활용법
학습 항목
이미지 로딩 중...
TypeScript 제네릭 마스터하기 완벽 가이드
TypeScript의 제네릭은 타입 안정성을 유지하면서도 재사용 가능한 컴포넌트를 만드는 핵심 기능입니다. 기초부터 고급 패턴까지, 실무에서 바로 활용할 수 있는 제네릭 활용법을 단계별로 배워보세요.
목차
- 제네릭 기초 - 타입을 매개변수처럼 사용하기
- 제네릭 제약조건 - 타입의 범위 제한하기
- 제네릭 인터페이스와 타입 - 재사용 가능한 타입 정의
- 제네릭 클래스 - 타입 안전한 컨테이너 만들기
- 제네릭 함수와 화살표 함수 - 실전 활용 패턴
- keyof와 제네릭 - 타입 안전한 속성 접근
- 조건부 타입과 제네릭 - 타입 레벨 프로그래밍
- 유틸리티 타입과 제네릭 - 내장 타입 활용하기
- 제네릭 배열과 튜플 - 다양한 컬렉션 타입 다루기
- 고급 제네릭 패턴 - 타입 추론과 infer
1. 제네릭 기초 - 타입을 매개변수처럼 사용하기
시작하며
여러분이 배열의 첫 번째 요소를 반환하는 함수를 만들 때, 숫자 배열용, 문자열 배열용, 객체 배열용으로 각각 따로 만들어본 적 있나요? 코드가 중복되고, 새로운 타입이 추가될 때마다 함수를 또 만들어야 하는 상황 말이죠.
이런 문제는 타입 안정성을 유지하면서 재사용 가능한 코드를 작성하려 할 때 자주 발생합니다. any 타입을 사용하면 재사용은 가능하지만 타입 안정성을 잃게 되고, 구체적인 타입을 지정하면 안전하지만 코드 중복이 발생합니다.
바로 이럴 때 필요한 것이 제네릭입니다. 제네릭을 사용하면 타입을 마치 함수의 매개변수처럼 전달하여, 하나의 코드로 여러 타입을 안전하게 처리할 수 있습니다.
개요
간단히 말해서, 제네릭은 타입을 변수처럼 사용할 수 있게 해주는 TypeScript의 강력한 기능입니다. 제네릭이 필요한 이유는 타입 안정성과 코드 재사용성을 동시에 확보하기 위함입니다.
예를 들어, API 응답을 처리하는 함수에서 다양한 데이터 타입을 받아야 하는 경우, 각 타입마다 함수를 만들지 않고도 타입 체크를 할 수 있습니다. 기존에는 any 타입으로 모든 것을 받거나 오버로딩으로 여러 함수를 만들었다면, 이제는 제네릭 하나로 타입 안정성을 유지하면서 재사용 가능한 코드를 작성할 수 있습니다.
제네릭의 핵심 특징은 타입을 <T>와 같은 형태로 선언하고, 함수나 클래스를 사용할 때 구체적인 타입을 지정한다는 것입니다. 컴파일 시점에 타입이 결정되므로 런타임 오류를 사전에 방지할 수 있으며, IDE의 자동완성과 타입 추론도 완벽하게 작동합니다.
코드 예제
// 제네릭을 사용한 배열 첫 요소 반환 함수
function getFirstElement<T>(arr: T[]): T | undefined {
// 배열이 비어있으면 undefined 반환
if (arr.length === 0) return undefined;
// 첫 번째 요소 반환 - 타입이 자동으로 추론됨
return arr[0];
}
// 사용 예시 - 타입이 자동으로 추론됩니다
const firstNumber = getFirstElement([1, 2, 3]); // number | undefined
const firstName = getFirstElement(['Alice', 'Bob']); // string | undefined
const firstUser = getFirstElement([{ id: 1, name: 'John' }]); // { id: number, name: string } | undefined
설명
이것이 하는 일: 제네릭 함수는 타입 매개변수 T를 받아서, 그 타입의 배열을 받고 같은 타입의 값을 반환합니다. 첫 번째로, function getFirstElement<T> 부분에서 <T>는 타입 매개변수를 선언합니다.
이는 "이 함수는 어떤 타입 T를 사용할 것"이라고 선언하는 것입니다. T는 관습적으로 사용하는 이름이지만, 원하는 이름을 사용할 수 있습니다.
그 다음으로, (arr: T[]): T | undefined 부분이 실행되면서 매개변수와 반환 타입을 정의합니다. arr은 T 타입의 배열이고, 반환값은 T 타입이거나 undefined입니다.
배열이 비어있을 수 있으므로 undefined도 반환 타입에 포함시켰습니다. 마지막으로, 함수를 호출할 때 TypeScript가 전달된 인자를 보고 T가 무엇인지 자동으로 추론합니다.
[1, 2, 3]을 전달하면 T는 number가 되고, ['Alice', 'Bob']를 전달하면 T는 string이 됩니다. 여러분이 이 코드를 사용하면 하나의 함수로 모든 타입의 배열을 안전하게 처리할 수 있습니다.
IDE는 반환 타입을 정확히 알고 있어서 자동완성을 제공하고, 타입 오류는 컴파일 시점에 잡아낼 수 있으며, 코드 중복 없이 깔끔한 구조를 유지할 수 있습니다.
실전 팁
💡 타입 매개변수 이름은 T, U, V 순서로 사용하는 것이 관습이지만, 의미 있는 이름을 사용하면 가독성이 높아집니다 (예: TData, TResponse)
💡 제네릭을 처음 사용할 때 타입을 명시적으로 지정할 수도 있습니다: getFirstElement<string>(['a', 'b'])
💡 반환 타입에 undefined를 포함시키는 것은 방어적 프로그래밍의 좋은 예시입니다 - 빈 배열 처리를 컴파일 타임에 강제할 수 있습니다
💡 제네릭은 런타임에 영향을 주지 않고 컴파일 타임에만 존재하므로, 성능 걱정 없이 사용할 수 있습니다
2. 제네릭 제약조건 - 타입의 범위 제한하기
시작하며
여러분이 객체의 특정 속성을 안전하게 접근하는 함수를 만들 때, 모든 타입을 받으면 안 되고 특정 속성을 가진 객체만 받아야 하는 상황을 겪어본 적 있나요? 예를 들어, id 속성이 있는 객체만 처리하고 싶은데 제네릭으로 어떻게 제한해야 할지 막막했던 경험 말이죠.
이런 문제는 실무에서 API 응답 처리, 데이터 검증, ORM 작업 등에서 자주 발생합니다. 제네릭으로 유연성을 확보하되, 완전히 자유로운 것은 아니고 특정 조건을 만족하는 타입만 허용해야 할 때가 많습니다.
바로 이럴 때 필요한 것이 제네릭 제약조건(Generic Constraints)입니다. extends 키워드를 사용하여 제네릭 타입이 특정 조건을 만족하도록 강제할 수 있습니다.
개요
간단히 말해서, 제네릭 제약조건은 타입 매개변수가 특정 타입을 확장하거나 특정 구조를 가지도록 제한하는 기능입니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 타입의 유연성과 안정성 사이의 균형을 맞추기 위함입니다.
너무 자유로운 제네릭은 런타임 오류를 일으킬 수 있고, 너무 엄격한 타입은 재사용성을 해칩니다. 예를 들어, 데이터베이스 엔티티를 처리하는 함수에서 모든 엔티티가 id 속성을 가진다는 것을 보장하고 싶을 때 매우 유용합니다.
기존에는 타입 가드나 런타임 체크로 속성 존재를 확인했다면, 이제는 컴파일 타임에 타입 시스템이 자동으로 검증하도록 할 수 있습니다. 제네릭 제약조건의 핵심 특징은 extends 키워드로 상한선을 정의하고, 인터페이스나 타입을 조건으로 사용할 수 있으며, 여러 제약조건을 조합할 수 있다는 것입니다.
이러한 특징들이 중요한 이유는 컴파일 타임에 타입 안정성을 보장하면서도 코드의 재사용성을 최대화할 수 있기 때문입니다.
코드 예제
// id 속성을 가진 객체만 받는 제네릭 함수
interface HasId {
id: number;
}
// T는 HasId를 확장해야 함 - id 속성이 보장됨
function updateEntity<T extends HasId>(entity: T, updates: Partial<T>): T {
// id는 변경 불가능하도록 처리
const { id, ...rest } = updates;
// 기존 엔티티와 업데이트를 병합
return { ...entity, ...rest };
}
// 사용 예시
const user = { id: 1, name: 'Alice', email: 'alice@example.com' };
const updatedUser = updateEntity(user, { name: 'Alice Smith' }); // 정상 작동
// const invalid = updateEntity({ name: 'Bob' }, {}); // 오류: id 속성이 없음
설명
이것이 하는 일: 제네릭 제약조건을 사용하여 모든 엔티티가 id 속성을 가지도록 보장하면서, 각 엔티티의 고유한 속성들도 유지합니다. 첫 번째로, interface HasId로 기본 구조를 정의합니다.
이는 "id 속성을 가진 객체"라는 최소한의 요구사항을 나타냅니다. 이렇게 하는 이유는 모든 엔티티가 공통으로 가져야 할 속성을 명시적으로 표현하기 위함입니다.
그 다음으로, <T extends HasId> 부분이 실행되면서 T가 반드시 HasId를 만족해야 한다고 선언합니다. 내부에서는 entity.id에 안전하게 접근할 수 있고, TypeScript는 이를 컴파일 타임에 검증합니다.
Partial<T>는 T의 모든 속성을 선택적으로 만들어 부분 업데이트를 가능하게 합니다. 마지막으로, 구조 분해 할당으로 id를 제외하고 나머지 속성만 병합하여, id가 실수로 변경되는 것을 방지합니다.
스프레드 연산자로 기존 엔티티와 업데이트를 깔끔하게 병합하여 새로운 객체를 반환합니다. 여러분이 이 코드를 사용하면 타입 안정성이 보장된 업데이트 로직을 구현할 수 있습니다.
컴파일러가 id 속성의 존재를 보장하므로 런타임 오류가 발생할 가능성이 낮아지고, IDE의 자동완성이 정확하게 작동하며, 코드 리뷰에서 타입 관련 버그를 미리 발견할 수 있습니다.
실전 팁
💡 여러 제약조건을 결합할 때는 & 연산자를 사용합니다: <T extends HasId & HasTimestamp>
💡 keyof 연산자와 함께 사용하면 객체의 키만 받는 안전한 함수를 만들 수 있습니다: <K extends keyof T>
💡 제약조건을 너무 엄격하게 만들면 재사용성이 떨어지므로, 꼭 필요한 최소한의 제약만 두는 것이 좋습니다
💡 복잡한 제약조건은 별도의 타입 별칭으로 추출하면 가독성이 향상됩니다: type Entity = HasId & HasTimestamp
3. 제네릭 인터페이스와 타입 - 재사용 가능한 타입 정의
시작하며
여러분이 API 응답 구조를 정의할 때, 성공/실패 상태와 데이터를 담는 공통 구조를 매번 반복해서 작성하고 있지는 않나요? User 응답, Product 응답, Order 응답마다 똑같은 구조를 복사-붙여넣기하면서 유지보수가 어려워지는 경험 말이죠.
이런 문제는 대규모 애플리케이션에서 일관된 데이터 구조를 유지하려 할 때 자주 발생합니다. 타입 정의가 중복되면 나중에 구조를 변경할 때 모든 곳을 찾아서 수정해야 하고, 실수로 누락하면 런타임 오류가 발생합니다.
바로 이럴 때 필요한 것이 제네릭 인터페이스와 타입입니다. 공통 구조는 재사용 가능한 제네릭 타입으로 정의하고, 구체적인 데이터 타입만 매개변수로 전달하면 됩니다.
개요
간단히 말해서, 제네릭 인터페이스는 타입 매개변수를 받는 인터페이스로, 구조는 같지만 내부 데이터 타입이 다른 여러 타입을 정의할 때 사용합니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 대규모 애플리케이션에서 타입 일관성을 유지하고 코드 중복을 제거하기 위함입니다.
예를 들어, 페이지네이션된 API 응답, 비동기 상태 관리, 폼 데이터 검증 등 공통 패턴을 가진 다양한 데이터를 다룰 때 매우 유용합니다. 기존에는 각 엔티티마다 별도의 응답 타입을 정의하거나 any를 사용해서 타입 안정성을 포기했다면, 이제는 제네릭 인터페이스 하나로 모든 응답 타입을 타입 안전하게 정의할 수 있습니다.
제네릭 인터페이스의 핵심 특징은 인터페이스 이름 뒤에 타입 매개변수를 선언하고, 내부에서 그 타입을 자유롭게 사용할 수 있으며, 기본 타입을 지정할 수도 있다는 것입니다. 이러한 특징들이 중요한 이유는 타입 재사용성을 극대화하면서도 각 사용처에서 구체적인 타입 정보를 잃지 않기 때문입니다.
코드 예제
// API 응답을 위한 제네릭 인터페이스
interface ApiResponse<TData, TError = string> {
success: boolean;
data?: TData;
error?: TError;
timestamp: number;
}
// 사용자 타입 정의
interface User {
id: number;
name: string;
email: string;
}
// 구체적인 응답 타입들 - 제네릭으로 재사용
type UserResponse = ApiResponse<User>;
type UsersListResponse = ApiResponse<User[]>;
type LoginResponse = ApiResponse<{ token: string; user: User }, { code: string; message: string }>;
// 실제 사용 예시
const response: UserResponse = {
success: true,
data: { id: 1, name: 'Alice', email: 'alice@example.com' },
timestamp: Date.now()
};
설명
이것이 하는 일: 제네릭 인터페이스로 API 응답의 공통 구조를 정의하고, 각 엔드포인트마다 구체적인 데이터 타입만 지정하여 재사용합니다. 첫 번째로, interface ApiResponse<TData, TError = string> 부분에서 두 개의 타입 매개변수를 선언합니다.
TData는 성공 시 반환되는 데이터의 타입이고, TError는 실패 시 오류 정보의 타입입니다. TError에 = string이 있는 것은 기본 타입을 지정한 것으로, 생략하면 string으로 간주됩니다.
그 다음으로, data?: TData와 error?: TError로 선택적 속성을 정의합니다. success가 true면 data가 있고, false면 error가 있는 구조입니다.
물음표(?)는 속성이 존재하지 않을 수 있음을 나타내며, 이는 성공과 실패 케이스를 하나의 타입으로 표현하기 위함입니다. 마지막으로, type 별칭으로 제네릭 인터페이스를 구체적인 타입으로 특화합니다.
UserResponse는 단일 사용자를, UsersListResponse는 사용자 배열을, LoginResponse는 토큰과 사용자 정보를 담는 응답으로 정의됩니다. LoginResponse에서는 TError에 구체적인 객체 타입을 전달하여 기본 타입을 오버라이드합니다.
여러분이 이 코드를 사용하면 모든 API 응답이 일관된 구조를 가지게 됩니다. 나중에 응답 구조를 변경할 때 ApiResponse만 수정하면 모든 타입에 자동으로 반영되고, IDE가 각 응답의 구체적인 데이터 타입을 정확히 추론하여 자동완성을 제공하며, 타입 오류를 컴파일 시점에 발견할 수 있습니다.
실전 팁
💡 기본 타입 매개변수는 가장 일반적인 케이스에 맞춰 설정하면 코드가 간결해집니다
💡 타입 매개변수가 3개 이상이면 가독성이 떨어지므로, 객체로 그룹화하는 것을 고려하세요
💡 제네릭 인터페이스는 유틸리티 타입과 조합하여 더욱 강력해집니다: Partial<ApiResponse<T>>, Required<ApiResponse<T>>
💡 API 응답뿐만 아니라 상태 관리(Redux, Zustand), 폼 라이브러리, 데이터 페칭 라이브러리 등에서도 동일한 패턴을 활용할 수 있습니다
4. 제네릭 클래스 - 타입 안전한 컨테이너 만들기
시작하며
여러분이 데이터를 저장하고 조작하는 컬렉션 클래스를 만들 때, 숫자 저장소, 문자열 저장소, 객체 저장소를 각각 따로 만들어야 했던 경험이 있나요? 같은 로직인데 타입만 다르다는 이유로 코드를 반복하는 것은 정말 비효율적입니다.
이런 문제는 캐시 시스템, 상태 관리 스토어, 큐나 스택 같은 자료구조를 구현할 때 자주 발생합니다. 제네릭 없이 구현하면 타입마다 클래스를 만들어야 하고, any를 사용하면 타입 안정성을 잃게 됩니다.
바로 이럴 때 필요한 것이 제네릭 클래스입니다. 클래스 레벨에서 타입 매개변수를 선언하면, 인스턴스 생성 시 구체적인 타입을 지정하여 타입 안전한 컨테이너를 만들 수 있습니다.
개요
간단히 말해서, 제네릭 클래스는 클래스 정의에 타입 매개변수를 사용하여, 다양한 타입의 데이터를 안전하게 다루는 재사용 가능한 클래스입니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 타입 안정성을 유지하면서 범용적인 데이터 구조나 유틸리티 클래스를 구현하기 위함입니다.
예를 들어, 메모리 캐시, 이벤트 버스, 데이터 저장소, 비동기 작업 큐 등을 구현할 때 각 타입마다 별도 클래스를 만들지 않고 하나의 제네릭 클래스로 모든 타입을 처리할 수 있습니다. 기존에는 Object나 any를 사용해서 모든 타입을 받되 타입 안정성을 포기하거나, 상속을 통해 타입별 클래스를 만들어 코드 중복을 감수했다면, 이제는 제네릭 클래스로 타입 안정성과 재사용성을 동시에 확보할 수 있습니다.
제네릭 클래스의 핵심 특징은 클래스 이름 뒤에 타입 매개변수를 선언하고, 모든 멤버 변수와 메서드에서 그 타입을 사용할 수 있으며, 인스턴스마다 다른 타입을 지정할 수 있다는 것입니다. 이러한 특징들이 중요한 이유는 하나의 구현으로 무한히 많은 타입을 지원하면서도 각 인스턴스의 타입 정보를 정확히 유지하기 때문입니다.
코드 예제
// 제네릭 스택 클래스 구현
class Stack<T> {
private items: T[] = [];
// 요소 추가
push(item: T): void {
this.items.push(item);
}
// 요소 제거 및 반환
pop(): T | undefined {
return this.items.pop();
}
// 맨 위 요소 조회
peek(): T | undefined {
return this.items[this.items.length - 1];
}
// 스택 크기 반환
get size(): number {
return this.items.length;
}
// 스택이 비었는지 확인
isEmpty(): boolean {
return this.items.length === 0;
}
}
// 사용 예시 - 각 인스턴스는 다른 타입을 가짐
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
const num = numberStack.pop(); // number | undefined
const stringStack = new Stack<string>();
stringStack.push('hello');
const str = stringStack.pop(); // string | undefined
설명
이것이 하는 일: 제네릭 클래스로 스택 자료구조를 구현하여, 어떤 타입의 데이터든 타입 안전하게 저장하고 조작할 수 있습니다. 첫 번째로, class Stack<T> 부분에서 클래스 레벨의 타입 매개변수를 선언합니다.
이는 클래스의 모든 멤버와 메서드에서 T를 사용할 수 있게 하며, 인스턴스 생성 시 T가 구체적인 타입으로 결정됩니다. private items: T[]는 T 타입의 배열로 내부 저장소를 선언하여 캡슐화합니다.
그 다음으로, push(item: T)와 pop(): T | undefined 메서드가 실행되면서 타입 안전한 연산을 수행합니다. push는 T 타입만 받을 수 있고, pop은 T 타입이거나 undefined를 반환합니다.
내부에서 배열의 push와 pop 메서드를 사용하지만, 외부에서는 스택의 인터페이스만 노출됩니다. 마지막으로, 인스턴스를 생성할 때 new Stack<number>()처럼 구체적인 타입을 지정하면, 그 인스턴스는 해당 타입으로만 동작합니다.
numberStack.push('hello')는 컴파일 오류가 발생하고, pop()의 반환 타입도 자동으로 number | undefined로 추론됩니다. 여러분이 이 코드를 사용하면 타입 안전한 자료구조를 쉽게 구현할 수 있습니다.
잘못된 타입의 데이터를 넣으려고 하면 컴파일 시점에 오류가 발생하고, IDE가 정확한 메서드 시그니처와 반환 타입을 보여주며, 리팩토링 시 타입 변경이 자동으로 추적됩니다. 또한 이 패턴은 큐, 링크드 리스트, 트리 등 다른 자료구조에도 동일하게 적용할 수 있습니다.
실전 팁
💡 제네릭 클래스에 제약조건을 추가할 수 있습니다: class Stack<T extends Comparable> - 특정 인터페이스를 구현한 타입만 허용
💡 여러 타입 매개변수를 사용할 수 있습니다: class KeyValueStore<K, V> - 키와 값의 타입을 각각 지정
💡 정적 멤버는 타입 매개변수를 사용할 수 없으므로, 필요하면 메서드 레벨 제네릭을 사용하세요
💡 제네릭 클래스를 상속할 때 부모의 타입 매개변수를 구체화하거나 전달할 수 있습니다: class NumberStack extends Stack<number>
5. 제네릭 함수와 화살표 함수 - 실전 활용 패턴
시작하며
여러분이 배열을 다루는 유틸리티 함수를 만들 때, map, filter, reduce를 조합하여 복잡한 변환을 수행하면서도 타입 안정성을 유지하고 싶었던 적 있나요? 특히 화살표 함수와 고차 함수를 사용할 때 타입 추론이 제대로 작동하지 않아 any로 도배되는 경험 말이죠.
이런 문제는 함수형 프로그래밍 스타일로 코드를 작성할 때 자주 발생합니다. 데이터 변환 파이프라인, 비동기 작업 체이닝, 고차 함수 구현 등에서 타입 정보가 손실되면 런타임 오류가 발생하기 쉽습니다.
바로 이럴 때 필요한 것이 제네릭 함수와 화살표 함수의 결합입니다. 함수 레벨에서 제네릭을 사용하면 입력과 출력의 타입 관계를 정확히 표현할 수 있습니다.
개요
간단히 말해서, 제네릭 함수는 호출 시점에 타입이 결정되는 함수로, 일반 함수와 화살표 함수 모두에서 사용할 수 있습니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 함수형 프로그래밍과 타입 안정성을 조화시키기 위함입니다.
예를 들어, 배열 변환 유틸리티, Promise 체이닝, 고차 함수 라이브러리를 만들 때 입력 타입과 출력 타입의 관계를 명확히 하면 코드의 안정성과 가독성이 크게 향상됩니다. 기존에는 any를 사용하거나 오버로딩으로 모든 경우를 나열해야 했다면, 이제는 제네릭으로 타입 변환 로직을 우아하게 표현할 수 있습니다.
제네릭 함수의 핵심 특징은 함수 이름 뒤 또는 화살표 앞에 타입 매개변수를 선언하고, 매개변수와 반환 타입에서 그 타입을 사용하며, 여러 타입 매개변수를 조합할 수 있다는 것입니다. 이러한 특징들이 중요한 이유는 복잡한 타입 변환 로직도 명확하고 안전하게 표현할 수 있기 때문입니다.
코드 예제
// 배열을 키로 그룹화하는 제네릭 함수
function groupBy<T, K extends string | number>(
array: T[],
keySelector: (item: T) => K
): Record<K, T[]> {
// 빈 객체로 시작 - Record<K, T[]> 타입
return array.reduce((result, item) => {
// 각 항목에서 키 추출
const key = keySelector(item);
// 해당 키의 배열이 없으면 생성
if (!result[key]) {
result[key] = [];
}
// 항목을 해당 키의 배열에 추가
result[key].push(item);
return result;
}, {} as Record<K, T[]>);
}
// 사용 예시
interface Product {
id: number;
name: string;
category: string;
}
const products: Product[] = [
{ id: 1, name: 'Laptop', category: 'Electronics' },
{ id: 2, name: 'Desk', category: 'Furniture' },
{ id: 3, name: 'Phone', category: 'Electronics' }
];
// 카테고리별로 그룹화 - 타입이 자동으로 추론됨
const grouped = groupBy(products, p => p.category);
// 타입: Record<string, Product[]>
설명
이것이 하는 일: 제네릭 함수로 배열의 항목들을 특정 키로 그룹화하여, 입력 타입과 키 타입에 따라 출력 타입이 자동으로 결정됩니다. 첫 번째로, function groupBy<T, K extends string | number> 부분에서 두 타입 매개변수를 선언합니다.
T는 배열 항목의 타입이고, K는 키의 타입입니다. K를 string | number로 제한한 이유는 객체의 키로 사용할 수 있는 타입만 허용하기 위함입니다.
keySelector: (item: T) => K는 각 항목에서 키를 추출하는 콜백 함수입니다. 그 다음으로, reduce 메서드가 실행되면서 배열을 순회하며 그룹화된 객체를 만듭니다.
{} as Record<K, T[]>는 초기값의 타입을 명시하여 TypeScript가 타입을 정확히 추론하도록 돕습니다. 내부에서 keySelector를 호출하여 각 항목의 키를 얻고, 해당 키의 배열에 항목을 추가합니다.
마지막으로, 함수를 호출할 때 groupBy(products, p => p.category)처럼 사용하면, TypeScript가 products의 타입(Product[])과 콜백의 반환 타입(string)을 보고 T=Product, K=string으로 자동 추론합니다. 따라서 반환 타입은 Record<string, Product[]>가 됩니다.
여러분이 이 코드를 사용하면 복잡한 데이터 변환도 타입 안전하게 수행할 수 있습니다. 잘못된 키 타입을 사용하면 컴파일 오류가 발생하고, 반환된 객체의 구조를 IDE가 정확히 알고 있어서 자동완성이 완벽하게 작동하며, 리팩토링 시 타입 변경이 함수 전체에 자동으로 반영됩니다.
이 패턴은 map, filter, reduce 같은 배열 메서드와 조합하여 강력한 유틸리티를 만드는 데 활용할 수 있습니다.
실전 팁
💡 화살표 함수에서 제네릭을 사용할 때는 <T,> 처럼 쉼표를 추가하여 JSX 태그와 구분합니다: const fn = <T,>(x: T) => x
💡 제네릭 함수는 타입 추론을 최대한 활용하되, 필요할 때만 명시적으로 타입을 지정하세요: groupBy<Product, string>(...)
💡 여러 타입 매개변수 간의 관계를 제약조건으로 표현할 수 있습니다: <T, U extends T> - U는 T의 하위 타입
💡 제네릭과 오버로딩을 조합하면 더욱 정교한 타입 시그니처를 만들 수 있습니다
6. keyof와 제네릭 - 타입 안전한 속성 접근
시작하며
여러분이 객체의 속성에 안전하게 접근하는 함수를 만들 때, 존재하지 않는 키를 전달해도 컴파일 타임에 잡아내지 못하는 경험을 해본 적 있나요? 런타임에 undefined가 반환되어 예상치 못한 오류가 발생하는 것은 정말 골치 아픈 일입니다.
이런 문제는 동적으로 객체 속성에 접근하는 코드, 폼 데이터 처리, ORM 쿼리 빌더, 설정 객체 관리 등에서 자주 발생합니다. 문자열 리터럴로 키를 받으면 타입 체크가 불가능하고, 모든 키를 유니온 타입으로 나열하면 유지보수가 어렵습니다.
바로 이럴 때 필요한 것이 keyof 연산자와 제네릭의 결합입니다. keyof는 객체 타입의 모든 키를 유니온 타입으로 추출하여, 컴파일 타임에 키의 유효성을 검증할 수 있습니다.
개요
간단히 말해서, keyof는 타입의 모든 속성 이름을 문자열 리터럴 유니온으로 추출하는 연산자로, 제네릭과 함께 사용하면 타입 안전한 속성 접근을 구현할 수 있습니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 객체의 구조가 변경될 때 관련된 모든 코드를 자동으로 타입 체크하기 위함입니다.
예를 들어, 객체에서 특정 속성을 선택하는 pick 함수, 속성을 생략하는 omit 함수, 중첩된 속성에 접근하는 get 함수 등을 만들 때 키의 존재를 보장할 수 있습니다. 기존에는 string 타입으로 모든 키를 받거나 타입 가드로 런타임에 체크했다면, 이제는 keyof로 컴파일 타임에 타입 시스템이 자동으로 검증하도록 할 수 있습니다.
keyof와 제네릭의 핵심 특징은 keyof T가 T의 모든 키를 추출하고, K extends keyof T로 키의 범위를 제한하며, 반환 타입을 T[K]로 지정하여 정확한 값 타입을 얻을 수 있다는 것입니다. 이러한 특징들이 중요한 이유는 객체 구조가 변경되어도 타입 안정성이 자동으로 유지되기 때문입니다.
코드 예제
// 객체에서 특정 속성 값을 안전하게 가져오는 함수
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
// 여러 속성을 선택하는 함수
function pick<T, K extends keyof T>(obj: T, ...keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const key of keys) {
result[key] = obj[key];
}
return result;
}
// 사용 예시
interface User {
id: number;
name: string;
email: string;
age: number;
}
const user: User = {
id: 1,
name: 'Alice',
email: 'alice@example.com',
age: 30
};
// 타입 안전한 속성 접근
const name = getProperty(user, 'name'); // string 타입
const age = getProperty(user, 'age'); // number 타입
// const invalid = getProperty(user, 'address'); // 컴파일 오류!
// 특정 속성만 선택
const partialUser = pick(user, 'id', 'name'); // { id: number; name: string }
설명
이것이 하는 일: keyof와 제네릭을 조합하여 객체의 속성에 접근할 때 키의 유효성을 컴파일 타임에 검증하고, 정확한 값 타입을 반환합니다. 첫 번째로, <T, K extends keyof T> 부분에서 T는 객체 타입이고, K는 T의 키 중 하나로 제한됩니다.
keyof T는 'id' | 'name' | 'email' | 'age' 같은 유니온 타입이 되고, K extends keyof T는 K가 이 중 하나여야 함을 의미합니다. 이렇게 하는 이유는 존재하지 않는 키를 전달하는 것을 컴파일 타임에 방지하기 위함입니다.
그 다음으로, 반환 타입 T[K]는 인덱스 접근 타입으로, T에서 K 키의 값 타입을 나타냅니다. 예를 들어 T가 User이고 K가 'name'이면 T[K]는 string이 됩니다.
pick 함수에서는 Pick<T, K> 유틸리티 타입을 사용하여 선택된 속성만 가진 새로운 타입을 생성합니다. 마지막으로, 함수를 호출할 때 TypeScript가 인자를 보고 타입을 자동 추론합니다.
getProperty(user, 'name')을 호출하면 T=User, K='name'으로 추론되고, 반환 타입은 User['name'] 즉 string이 됩니다. 존재하지 않는 키를 전달하면 K extends keyof T 제약조건을 위반하여 컴파일 오류가 발생합니다.
여러분이 이 코드를 사용하면 객체 조작이 완전히 타입 안전해집니다. User 인터페이스에 속성을 추가하거나 제거하면 관련된 모든 코드가 자동으로 타입 체크되고, IDE가 사용 가능한 키 목록을 자동완성으로 제공하며, 리팩토링 시 키 이름 변경이 모든 사용처에 자동으로 반영됩니다.
이 패턴은 라이브러리나 프레임워크를 만들 때 특히 유용하며, Lodash의 pick, omit, get 같은 함수들이 이 방식을 사용합니다.
실전 팁
💡 중첩된 객체의 경로를 타입 안전하게 접근하려면 재귀적 타입을 사용하세요: type Path<T> = ...
💡 keyof는 심볼 키와 숫자 키도 포함하므로, 필요하면 keyof T & string으로 문자열 키만 추출할 수 있습니다
💡 맵드 타입과 함께 사용하면 강력한 타입 변환을 만들 수 있습니다: { [K in keyof T]: T[K] | null }
💡 as const를 사용한 객체와 keyof를 조합하면 문자열 리터럴 유니온을 쉽게 만들 수 있습니다
7. 조건부 타입과 제네릭 - 타입 레벨 프로그래밍
시작하며
여러분이 함수의 반환 타입이 입력 타입에 따라 달라져야 하는 상황을 만난 적 있나요? 예를 들어, 단일 ID를 전달하면 단일 객체를, ID 배열을 전달하면 객체 배열을 반환하는 함수를 만들 때, 오버로딩으로 모든 경우를 나열하는 것은 너무 복잡합니다.
이런 문제는 API 클라이언트, 데이터베이스 쿼리 빌더, 상태 관리 라이브러리 등에서 자주 발생합니다. 입력 타입에 따라 출력 타입이 변하는 로직을 표현하려면 복잡한 오버로딩이나 any 타입을 사용해야 했습니다.
바로 이럴 때 필요한 것이 조건부 타입(Conditional Types)입니다. T extends U ?
X : Y 문법으로 타입 레벨에서 조건문을 작성하여, 제네릭 타입에 따라 다른 타입을 반환할 수 있습니다.
개요
간단히 말해서, 조건부 타입은 타입 레벨의 삼항 연산자로, 제네릭 타입이 특정 조건을 만족하는지에 따라 다른 타입을 반환합니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 복잡한 타입 관계를 표현하여 정교한 타입 추론을 구현하기 위함입니다.
예를 들어, Promise를 언래핑하는 타입, null을 제거하는 타입, 함수 타입에서 반환 타입을 추출하는 타입 등 고급 타입 유틸리티를 만들 때 필수적입니다. 기존에는 복잡한 오버로딩으로 모든 케이스를 나열하거나 타입 단언을 사용했다면, 이제는 조건부 타입으로 타입 시스템이 자동으로 추론하도록 할 수 있습니다.
조건부 타입의 핵심 특징은 T extends U ? X : Y 문법으로 조건을 표현하고, infer 키워드로 타입을 추출하며, 재귀적으로 사용할 수 있다는 것입니다.
이러한 특징들이 중요한 이유는 타입 시스템을 프로그래밍 언어처럼 사용하여 거의 모든 타입 관계를 표현할 수 있기 때문입니다.
코드 예제
// ID 타입에 따라 반환 타입이 달라지는 함수
type IdOrIds = number | number[];
// 조건부 타입: 배열이면 배열을, 단일값이면 단일값을 반환
type ReturnType<T> = T extends any[] ? User[] : User;
interface User {
id: number;
name: string;
}
// 사용자 데이터 (실제로는 DB에서 가져옴)
const users: User[] = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' }
];
// 제네릭과 조건부 타입을 사용한 함수
function getUser<T extends IdOrIds>(id: T): ReturnType<T> {
if (Array.isArray(id)) {
// ID 배열이면 여러 사용자 반환
return users.filter(u => id.includes(u.id)) as ReturnType<T>;
} else {
// 단일 ID면 한 사용자 반환
return users.find(u => u.id === id) as ReturnType<T>;
}
}
// 사용 예시 - 타입이 자동으로 추론됨
const singleUser = getUser(1); // User | undefined
const multipleUsers = getUser([1, 2]); // User[]
설명
이것이 하는 일: 조건부 타입을 사용하여 입력이 배열인지 단일값인지에 따라 반환 타입을 자동으로 결정합니다. 첫 번째로, type ReturnType<T> = T extends any[] ?
User[] : User 부분에서 조건부 타입을 정의합니다. T extends any[]는 "T가 배열 타입인가?"를 확인하는 조건입니다.
true면 User[]를, false면 User를 반환합니다. 이는 타입 레벨의 if문이라고 생각하면 됩니다.
그 다음으로, function getUser<T extends IdOrIds>(id: T): ReturnType<T> 부분이 실행되면서 제네릭 타입 T를 조건부 타입에 전달합니다. T가 number[]면 ReturnType<number[]>는 User[]가 되고, T가 number면 ReturnType<number>는 User가 됩니다.
이렇게 반환 타입이 입력 타입에 따라 자동으로 결정됩니다. 마지막으로, 함수 내부에서는 런타임 체크(Array.isArray)로 실제 동작을 분기하고, 타입 단언(as ReturnType<T>)으로 TypeScript에게 타입을 알려줍니다.
호출할 때 getUser(1)이면 T=number로 추론되어 반환 타입이 User가 되고, getUser([1, 2])면 T=number[]로 추론되어 반환 타입이 User[]가 됩니다. 여러분이 이 코드를 사용하면 하나의 함수로 여러 시나리오를 타입 안전하게 처리할 수 있습니다.
오버로딩 없이도 복잡한 타입 관계를 표현할 수 있고, API 설계가 직관적이며, 사용자가 타입 단언 없이도 정확한 타입을 얻을 수 있습니다. 이 패턴은 라이브러리 API 설계에서 매우 유용하며, React Query, Prisma 같은 인기 라이브러리들이 활용합니다.
실전 팁
💡 infer 키워드로 타입을 추출할 수 있습니다: type Unpacked<T> = T extends Promise<infer U> ? U : T
💡 조건부 타입은 유니온 타입에 분배됩니다(Distributive): T extends U는 T가 유니온이면 각 멤버에 대해 적용됩니다
💡 복잡한 조건부 타입은 타입 별칭으로 추출하여 재사용하면 가독성이 향상됩니다
💡 never 타입을 활용하면 특정 케이스를 필터링할 수 있습니다: T extends Function ? never : T
8. 유틸리티 타입과 제네릭 - 내장 타입 활용하기
시작하며
여러분이 기존 타입을 변형해야 할 때마다 맵드 타입을 직접 작성하고 있지는 않나요? 모든 속성을 선택적으로 만들거나, 읽기 전용으로 만들거나, 특정 속성만 선택하는 작업을 할 때마다 복잡한 타입 표현식을 작성하는 것은 비효율적입니다.
이런 문제는 상태 관리, 폼 데이터 처리, API 응답 변환 등 거의 모든 TypeScript 프로젝트에서 발생합니다. 타입을 변형하는 패턴은 정해져 있는데, 매번 새로 작성하면 실수할 가능성도 높아집니다.
바로 이럴 때 필요한 것이 TypeScript의 내장 유틸리티 타입입니다. Partial, Required, Readonly, Pick, Omit 등 자주 사용하는 타입 변환 패턴이 이미 제공되므로, 바로 활용하면 됩니다.
개요
간단히 말해서, 유틸리티 타입은 TypeScript가 기본 제공하는 제네릭 타입으로, 기존 타입을 변형하여 새로운 타입을 만드는 일반적인 패턴을 구현합니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 타입 변환 로직을 표준화하여 코드 일관성을 높이고 개발 속도를 향상시키기 위함입니다.
예를 들어, 업데이트 API에서 모든 필드가 선택적인 타입이 필요할 때 Partial을 사용하거나, 설정 객체를 읽기 전용으로 만들 때 Readonly를 사용하면 됩니다. 기존에는 각 프로젝트마다 커스텀 유틸리티 타입을 만들거나 맵드 타입을 직접 작성했다면, 이제는 표준 유틸리티 타입으로 대부분의 경우를 처리할 수 있습니다.
유틸리티 타입의 핵심 특징은 제네릭으로 입력 타입을 받고, 맵드 타입이나 조건부 타입으로 구현되어 있으며, 조합하여 더 복잡한 변환을 만들 수 있다는 것입니다. 이러한 특징들이 중요한 이유는 타입 변환 로직의 재사용성과 가독성을 크게 높이기 때문입니다.
코드 예제
interface User {
id: number;
name: string;
email: string;
age: number;
createdAt: Date;
}
// Partial: 모든 속성을 선택적으로 - 부분 업데이트에 유용
type UserUpdate = Partial<User>;
// { id?: number; name?: string; ... }
// Required: 모든 속성을 필수로 - 선택적 속성을 필수로 변환
type CompleteUser = Required<Partial<User>>;
// Readonly: 모든 속성을 읽기 전용으로 - 불변 객체 생성
type ImmutableUser = Readonly<User>;
// Pick: 특정 속성만 선택
type UserPreview = Pick<User, 'id' | 'name'>;
// { id: number; name: string; }
// Omit: 특정 속성 제외
type UserWithoutDates = Omit<User, 'createdAt'>;
// id, name, email, age만 포함
// Record: 키-값 쌍의 객체 타입 생성
type UserRoles = Record<string, 'admin' | 'user' | 'guest'>;
// { [key: string]: 'admin' | 'user' | 'guest' }
// 실제 사용 예시
function updateUser(id: number, updates: UserUpdate): User {
// 부분 업데이트 처리
const existingUser = getExistingUser(id);
return { ...existingUser, ...updates };
}
function getExistingUser(id: number): User {
// DB에서 사용자 조회 (예시)
return { id, name: 'Alice', email: 'alice@example.com', age: 30, createdAt: new Date() };
}
설명
이것이 하는 일: TypeScript의 내장 유틸리티 타입을 활용하여 기존 타입을 다양한 방식으로 변형하고, 실무에서 자주 필요한 타입 패턴을 쉽게 구현합니다. 첫 번째로, Partial<User>는 User의 모든 속성을 선택적(optional)으로 만듭니다.
이는 { [P in keyof User]?: User[P] } 형태의 맵드 타입으로 구현되어 있으며, 부분 업데이트나 초기화 전 상태를 표현할 때 유용합니다. updateUser 함수처럼 일부 필드만 업데이트하는 API에서 자주 사용됩니다.
그 다음으로, Pick<User, 'id' | 'name'>과 Omit<User, 'createdAt'>은 각각 포함할 속성과 제외할 속성을 지정합니다. Pick은 선택한 키만 가진 새 타입을 만들고, Omit은 제외한 키를 빼고 나머지로 새 타입을 만듭니다.
예를 들어 API 응답에서 민감한 정보를 제외하거나, 목록 화면에 필요한 필드만 선택할 때 사용합니다. 마지막으로, Readonly<User>는 모든 속성을 readonly로 만들어 불변성을 보장합니다.
Record<string, T>는 키가 string이고 값이 T인 객체 타입을 생성하며, 맵이나 딕셔너리 구조를 표현할 때 유용합니다. 이러한 유틸리티 타입들은 조합하여 사용할 수 있습니다: Partial<Pick<User, 'name' | 'email'>>은 name과 email만 선택적으로 포함하는 타입입니다.
여러분이 이 코드를 사용하면 타입 변환이 매우 간편해집니다. 복잡한 맵드 타입을 직접 작성할 필요가 없어 실수가 줄어들고, 코드 리뷰에서 의도가 명확하게 전달되며, TypeScript 커뮤니티의 표준 패턴을 따르므로 다른 개발자와 협업이 수월합니다.
또한 이 유틸리티 타입들은 TypeScript 컴파일러에 최적화되어 있어 성능도 우수합니다.
실전 팁
💡 유틸리티 타입을 중첩하면 더 복잡한 변환을 만들 수 있습니다: Readonly<Partial<User>>
💡 커스텀 유틸리티 타입을 만들 때 기존 유틸리티 타입을 조합하면 구현이 간단해집니다
💡 ReturnType<T>, Parameters<T> 같은 함수 관련 유틸리티 타입도 매우 유용합니다
💡 Exclude<T, U>, Extract<T, U>, NonNullable<T> 등 유니온 타입을 다루는 유틸리티도 알아두면 좋습니다
💡 각 유틸리티 타입의 구현을 살펴보면 고급 타입 기법을 배울 수 있습니다 (Ctrl+클릭으로 정의로 이동)
9. 제네릭 배열과 튜플 - 다양한 컬렉션 타입 다루기
시작하며
여러분이 여러 타입의 값을 순서대로 반환하는 함수를 만들 때, 배열로 반환하면 타입 정보가 손실되는 경험을 해본 적 있나요? 예를 들어, [User, number, boolean]을 반환하는데 TypeScript가 (User | number | boolean)[]로만 추론하여 각 위치의 정확한 타입을 알 수 없는 상황 말이죠.
이런 문제는 React의 useState 같은 훅, 여러 값을 동시에 반환하는 유틸리티 함수, 파서나 검증 함수의 결과 등에서 자주 발생합니다. 객체로 반환하면 타입은 안전하지만 구조 분해 할당이 불편하고, 배열로 반환하면 편리하지만 타입 정보가 부정확합니다.
바로 이럴 때 필요한 것이 제네릭 튜플입니다. 튜플은 고정된 길이와 각 위치별 타입을 가진 배열로, 제네릭과 결합하면 타입 안전한 다중 값 반환을 구현할 수 있습니다.
개요
간단히 말해서, 튜플은 각 인덱스마다 다른 타입을 가질 수 있는 고정 길이 배열이고, 제네릭 튜플은 타입 매개변수로 튜플의 각 요소 타입을 지정합니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 여러 관련 값을 함께 반환하면서도 각 값의 타입을 정확히 유지하기 위함입니다.
예를 들어, 데이터와 로딩 상태와 에러를 함께 반환하는 훅, 파싱 결과와 남은 문자열을 반환하는 파서, 성공 여부와 결과를 함께 반환하는 함수 등에서 매우 유용합니다. 기존에는 객체로 반환하여 { data, loading, error } 형태로 사용하거나, 배열로 반환하되 타입 단언을 사용했다면, 이제는 제네릭 튜플로 타입 안전성과 편의성을 모두 확보할 수 있습니다.
제네릭 튜플의 핵심 특징은 [T1, T2, T3] 형태로 각 위치의 타입을 명시하고, 구조 분해 할당 시 타입이 정확히 유지되며, rest 요소와 옵셔널 요소도 지원한다는 것입니다. 이러한 특징들이 중요한 이유는 코드의 간결성과 타입 안정성을 동시에 달성할 수 있기 때문입니다.
코드 예제
// useState 스타일의 제네릭 튜플 반환 함수
function useToggle(initialValue: boolean): [boolean, () => void, () => void] {
let value = initialValue;
const toggle = () => { value = !value; };
const setTrue = () => { value = true; };
const setFalse = () => { value = false; };
return [value, toggle, setTrue];
}
// 비동기 작업의 상태를 관리하는 제네릭 함수
type AsyncState<T> = [
data: T | null,
loading: boolean,
error: Error | null
];
function useAsync<T>(asyncFn: () => Promise<T>): AsyncState<T> {
let data: T | null = null;
let loading = true;
let error: Error | null = null;
asyncFn()
.then(result => {
data = result;
loading = false;
})
.catch(err => {
error = err;
loading = false;
});
return [data, loading, error];
}
// 사용 예시
const [isOpen, toggle, setTrue] = useToggle(false);
// isOpen: boolean, toggle: () => void, setTrue: () => void
const [userData, isLoading, fetchError] = useAsync<User>(() =>
fetch('/api/user/1').then(res => res.json())
);
// userData: User | null, isLoading: boolean, fetchError: Error | null
설명
이것이 하는 일: 제네릭 튜플을 사용하여 여러 관련 값을 함께 반환하면서도, 구조 분해 할당 시 각 값의 타입을 정확히 유지합니다. 첫 번째로, function useToggle(): [boolean, () => void, () => void] 부분에서 튜플 반환 타입을 선언합니다.
첫 번째 요소는 boolean, 두 번째와 세 번째는 함수입니다. 튜플은 배열과 달리 각 위치의 타입이 고정되어 있어, 구조 분해 할당 시 const [value, fn1, fn2] = useToggle(false)에서 value는 boolean, fn1과 fn2는 () => void로 정확히 추론됩니다.
그 다음으로, type AsyncState<T> = [data: T | null, loading: boolean, error: Error | null] 부분이 실행되면서 레이블이 있는 튜플 타입을 정의합니다. data:, loading:, error: 같은 레이블은 가독성을 높이고 IDE에서 힌트를 제공하지만, 구조 분해 시에는 순서로만 매칭됩니다.
제네릭 T를 사용하여 데이터 타입을 유연하게 지정할 수 있습니다. 마지막으로, 함수를 사용할 때 const [userData, isLoading, fetchError] = useAsync<User>(...)처럼 구조 분해 할당하면, 각 변수의 타입이 정확히 추론됩니다.
순서를 바꾸거나 일부만 받을 수도 있습니다: const [data] = useAsync<User>(...) - 이 경우 data만 사용하고 나머지는 무시합니다. 여러분이 이 코드를 사용하면 React Hooks 스타일의 깔끔한 API를 만들 수 있습니다.
구조 분해 할당으로 원하는 이름을 자유롭게 지정할 수 있고, 각 값의 타입이 정확히 유지되어 IDE 자동완성이 완벽하게 작동하며, 객체보다 간결하면서도 타입 안전성을 잃지 않습니다. React의 useState, useReducer, useContext 등이 모두 이 패턴을 사용합니다.
실전 팁
💡 튜플에 레이블을 추가하면 가독성이 향상됩니다: [value: T, setValue: (v: T) => void]
💡 옵셔널 요소는 ? 를 사용합니다: [string, number, boolean?] - 세 번째 요소는 선택적
💡 rest 요소로 가변 길이를 표현할 수 있습니다: [string, ...number[]] - 첫 번째는 string, 나머지는 모두 number
💡 튜플의 길이는 length 속성으로 접근할 수 있고, 타입도 정확히 추론됩니다
💡 readonly 튜플을 사용하면 불변성을 보장할 수 있습니다: readonly [T, U]
10. 고급 제네릭 패턴 - 타입 추론과 infer
시작하며
여러분이 Promise나 함수의 내부 타입을 추출하려고 할 때, 복잡한 타입 표현식을 작성하거나 타입 단언을 사용해야 했던 경험이 있나요? 예를 들어, Promise<User>에서 User를 추출하거나, (a: string, b: number) => boolean에서 반환 타입 boolean을 얻으려고 할 때 말이죠.
이런 문제는 라이브러리 타입 정의, 고차 함수 작성, 타입 유틸리티 구현 등 고급 타입 작업에서 자주 발생합니다. 타입의 일부를 추출하는 것은 TypeScript의 강력한 기능이지만, 문법이 복잡하여 어렵게 느껴집니다.
바로 이럴 때 필요한 것이 infer 키워드입니다. 조건부 타입 내에서 infer를 사용하면 타입의 일부를 변수처럼 추출하여 사용할 수 있습니다.
개요
간단히 말해서, infer는 조건부 타입 내에서 타입의 일부를 추출하는 키워드로, 타입 레벨의 패턴 매칭이라고 생각할 수 있습니다. 왜 이 개념이 필요한지 실무 관점에서 보면, 복잡한 제네릭 타입에서 원하는 부분만 추출하여 재사용하기 위함입니다.
예를 들어, API 응답을 래핑한 타입에서 실제 데이터 타입만 추출하거나, 함수 타입에서 매개변수나 반환 타입을 얻거나, 배열에서 요소 타입을 추출할 때 매우 유용합니다. 기존에는 수동으로 타입을 지정하거나 복잡한 맵드 타입을 작성했다면, 이제는 infer로 TypeScript가 자동으로 타입을 추론하도록 할 수 있습니다.
infer의 핵심 특징은 T extends Pattern<infer U> 형태로 사용하고, U는 추론된 타입을 담는 변수이며, 조건부 타입의 true 분기에서만 사용할 수 있다는 것입니다. 이러한 특징들이 중요한 이유는 타입 시스템의 추론 능력을 최대한 활용하여 유지보수가 쉬운 타입을 만들 수 있기 때문입니다.
코드 예제
// Promise에서 내부 타입 추출
type Unpromise<T> = T extends Promise<infer U> ? U : T;
// 사용 예시
type User = { id: number; name: string };
type UserPromise = Promise<User>;
type ExtractedUser = Unpromise<UserPromise>; // User
type NotPromise = Unpromise<string>; // string (변화 없음)
// 함수의 반환 타입 추출 (TypeScript 내장 ReturnType의 구현)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// 함수의 첫 번째 매개변수 타입 추출
type FirstParameter<T> = T extends (first: infer F, ...args: any[]) => any ? F : never;
// 배열 요소 타입 추출
type ArrayElement<T> = T extends (infer E)[] ? E : never;
// 실제 사용 예시
function fetchUser(): Promise<User> {
return fetch('/api/user').then(res => res.json());
}
type FetchReturn = MyReturnType<typeof fetchUser>; // Promise<User>
type ActualUser = Unpromise<FetchReturn>; // User
function processData(id: number, name: string, active: boolean): void {
console.log(id, name, active);
}
type FirstParam = FirstParameter<typeof processData>; // number
type ReturnValue = MyReturnType<typeof processData>; // void
const numbers: number[] = [1, 2, 3];
type NumberElement = ArrayElement<typeof numbers>; // number
설명
이것이 하는 일: infer를 사용하여 복잡한 제네릭 타입에서 원하는 부분만 추출하고, 재사용 가능한 타입 유틸리티를 만듭니다. 첫 번째로, type Unpromise<T> = T extends Promise<infer U> ?
U : T 부분에서 infer U가 핵심입니다. T extends Promise<infer U>는 "T가 Promise<무언가>의 형태인가?"를 확인하면서, 동시에 그 '무언가'를 U에 할당합니다.
마치 정규표현식의 캡처 그룹처럼 작동합니다. 조건이 true면 추출된 U를 반환하고, false면 T를 그대로 반환합니다.
그 다음으로, type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never 부분이 실행되면서 함수의 반환 타입을 추출합니다.
(...args: any[]) => infer R은 "어떤 매개변수든 받아서 R을 반환하는 함수"를 의미하고, infer R이 바로 그 반환 타입을 추출합니다. 함수가 아니면 never를 반환하여 타입 오류를 발생시킵니다.
마지막으로, 여러 infer를 조합하면 더 복잡한 추출도 가능합니다. type FirstParameter<T> = T extends (first: infer F, ...args: any[]) => any ?
F : never는 첫 번째 매개변수만 추출합니다. type ArrayElement<T> = T extends (infer E)[] ?
E : never는 배열의 요소 타입을 추출합니다. 이런 패턴들을 조합하면 거의 모든 타입 구조에서 원하는 부분을 추출할 수 있습니다.
여러분이 이 코드를 사용하면 타입 정의가 매우 유연해집니다. 타입을 수동으로 복사-붙여넣기할 필요가 없어 단일 진실 공급원(Single Source of Truth)을 유지할 수 있고, 타입이 변경되어도 자동으로 추적되며, 라이브러리나 프레임워크의 복잡한 타입을 다룰 때 필수적인 기법입니다.
TypeScript의 내장 유틸리티 타입인 ReturnType, Parameters, ConstructorParameters 등이 모두 infer를 사용하여 구현되어 있습니다.
실전 팁
💡 infer는 조건부 타입의 extends 절에서만 사용할 수 있으며, true 분기에서만 접근 가능합니다
💡 여러 infer를 중첩하면 깊은 타입 구조도 추출할 수 있습니다: T extends Promise<Promise<infer U>>
💡 infer와 재귀를 조합하면 매우 강력한 타입 변환을 만들 수 있습니다 (예: 모든 중첩 Promise 언래핑)
💡 오버로드된 함수에서는 마지막 시그니처가 추론됩니다
💡 복잡한 infer 패턴은 타입 별칭으로 추출하여 재사용하면 코드가 깔끔해집니다