🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

TypeScript 제네릭 완벽 가이드 - 슬라이드 1/13
A

AI Generated

2025. 10. 29. · 18 Views

TypeScript 제네릭 완벽 가이드

TypeScript의 제네릭을 처음 접하는 개발자들을 위한 완벽 가이드입니다. 기본 개념부터 고급 활용법까지, 실무에서 바로 적용할 수 있는 예제와 함께 단계별로 학습합니다. 타입 안정성과 코드 재사용성을 동시에 잡는 제네릭의 모든 것을 알아보세요.


목차

  1. 제네릭 기본 개념
  2. 함수 제네릭
  3. 제네릭 인터페이스
  4. 제네릭 클래스
  5. 제네릭 제약 조건
  6. 다중 제네릭 타입
  7. 제네릭 유틸리티 타입
  8. keyof와 제네릭
  9. 조건부 제네릭 타입
  10. 제네릭 기본값

1. 제네릭 기본 개념

시작하며

여러분이 배열을 다루는 함수를 작성할 때 이런 고민을 해본 적 있나요? "이 함수는 숫자 배열에도 쓰고 싶고, 문자열 배열에도 쓰고 싶은데...

어떻게 하지?" 결국 getFirstNumber, getFirstString 같은 비슷한 함수를 여러 개 만들게 됩니다. 이런 문제는 실제 개발 현장에서 매우 자주 발생합니다.

같은 로직인데 타입만 다르다는 이유로 코드를 중복으로 작성하면, 유지보수가 어렵고 버그가 발생할 위험도 커집니다. any 타입을 쓰면 재사용은 되지만, 타입 안정성을 완전히 잃어버리죠.

바로 이럴 때 필요한 것이 제네릭(Generics)입니다. 제네릭을 사용하면 타입을 마치 함수의 매개변수처럼 전달할 수 있어서, 코드 재사용성과 타입 안정성을 동시에 확보할 수 있습니다.

개요

간단히 말해서, 제네릭은 타입을 변수처럼 사용할 수 있게 해주는 TypeScript의 강력한 기능입니다. 여러분이 함수를 작성할 때 값을 매개변수로 받듯이, 제네릭을 사용하면 타입 자체를 매개변수로 받을 수 있습니다.

이를 통해 하나의 함수나 클래스가 여러 타입에 대해 동작하면서도, 각각의 타입 안정성을 유지할 수 있습니다. 예를 들어, API 응답을 처리하는 함수를 만들 때 User, Product, Order 등 다양한 타입의 응답을 하나의 함수로 처리할 수 있습니다.

기존에는 각 타입마다 별도의 함수를 만들거나 any 타입을 사용했다면, 이제는 제네릭을 통해 타입 안정성을 유지하면서도 코드를 재사용할 수 있습니다. 제네릭의 핵심 특징은 첫째, 타입 안정성을 보장한다는 점입니다.

컴파일 시점에 타입 오류를 잡아낼 수 있죠. 둘째, 코드 재사용성이 극대화됩니다.

하나의 코드로 여러 타입을 처리할 수 있습니다. 셋째, IDE의 자동완성과 타입 추론을 완벽하게 지원받을 수 있습니다.

이러한 특징들이 대규모 프로젝트에서 유지보수성과 개발 생산성을 크게 향상시킵니다.

코드 예제

// 제네릭을 사용하지 않은 경우
function getFirstNumber(arr: number[]): number {
  return arr[0];
}

// 제네릭을 사용한 경우 - T는 타입 매개변수
function getFirst<T>(arr: T[]): T {
  return arr[0];
}

// 사용 예시 - TypeScript가 자동으로 타입 추론
const firstNum = getFirst([1, 2, 3]); // number 타입
const firstStr = getFirst(["a", "b", "c"]); // string 타입
const firstUser = getFirst([{ name: "Kim" }, { name: "Lee" }]); // { name: string } 타입

설명

이것이 하는 일: 제네릭 함수 getFirst<T>는 어떤 타입의 배열이든 받아서, 첫 번째 요소를 해당 타입 그대로 반환합니다. 첫 번째로, <T>라는 타입 매개변수를 선언합니다.

여기서 T는 Type의 약자로 관례적으로 사용되며, 원하는 다른 이름을 사용해도 됩니다. 이 T는 함수가 호출될 때 실제 타입으로 대체됩니다.

마치 함수의 매개변수가 호출 시점에 실제 값으로 채워지는 것과 같은 원리입니다. 그 다음으로, arr: T[]에서 T를 사용하여 매개변수의 타입을 정의합니다.

이렇게 하면 어떤 타입의 배열이든 받을 수 있으면서도, 그 타입 정보를 함수 내부에서 계속 유지할 수 있습니다. 반환 타입도 : T로 지정하여, 입력된 배열의 요소 타입과 정확히 일치하는 타입을 반환한다는 것을 명시합니다.

마지막으로, 함수를 실제로 호출할 때 TypeScript 컴파일러가 전달된 인자를 보고 T가 무엇인지 자동으로 추론합니다. getFirst([1, 2, 3])를 호출하면 T는 number가 되고, getFirst(["a", "b"])를 호출하면 T는 string이 됩니다.

이를 통해 각 호출마다 완벽한 타입 안정성을 제공합니다. 여러분이 이 코드를 사용하면 하나의 함수로 모든 타입의 배열을 안전하게 처리할 수 있습니다.

any 타입을 사용했을 때처럼 타입 정보를 잃지 않으면서도, 코드 중복 없이 재사용 가능한 함수를 만들 수 있습니다. IDE는 반환 값의 정확한 타입을 알고 있어서 자동완성과 타입 체크를 완벽하게 지원합니다.

실전 팁

💡 제네릭 타입 매개변수 이름은 T, U, V 같은 단일 문자보다 TData, TResult처럼 의미 있는 이름을 사용하면 코드 가독성이 높아집니다.

💡 제네릭을 남용하면 코드가 복잡해집니다. 정말 여러 타입에서 재사용되는 경우에만 사용하고, 단일 타입만 다룬다면 구체적인 타입을 명시하세요.

💡 함수 호출 시 타입을 명시적으로 지정할 수도 있습니다: getFirst<string>(["a", "b"]). 타입 추론이 불명확할 때 유용합니다.

💡 제네릭은 런타임에는 존재하지 않습니다. 컴파일 타임에만 타입 체크용으로 사용되므로, 런타임 로직에서 타입을 체크하려면 별도의 방법이 필요합니다.


2. 함수 제네릭

시작하며

여러분이 API를 호출하고 응답을 처리하는 함수를 작성할 때, 매번 타입 캐스팅을 하거나 any를 사용하고 계신가요? 사용자 정보를 가져올 때는 User 타입으로, 상품 목록을 가져올 때는 Product[] 타입으로 받고 싶은데, 각각 다른 함수를 만들기에는 코드가 너무 중복됩니다.

이런 상황은 실제 프론트엔드 개발에서 매우 흔합니다. API 클라이언트, 데이터 변환 함수, 유틸리티 함수 등에서 같은 로직을 여러 타입에 적용해야 할 때가 많죠.

any를 쓰면 편하지만, 나중에 버그를 찾기 어렵고 리팩토링도 힘들어집니다. 바로 이럴 때 필요한 것이 함수 제네릭입니다.

함수 제네릭을 사용하면 타입 안정성을 유지하면서도 범용적인 함수를 만들 수 있어서, 실무에서 가장 많이 활용되는 제네릭 패턴입니다.

개요

간단히 말해서, 함수 제네릭은 함수 선언 시 타입 매개변수를 정의하여 호출할 때마다 다른 타입으로 동작하게 만드는 기법입니다. 일반 함수가 값을 매개변수로 받아서 처리하듯이, 제네릭 함수는 타입도 매개변수로 받아서 처리합니다.

이를 통해 API 호출, 데이터 변환, 배열 조작 등 다양한 상황에서 타입 안전한 범용 함수를 만들 수 있습니다. 예를 들어, fetch 함수를 래핑한 제네릭 함수를 만들면 모든 API 엔드포인트에서 정확한 타입으로 응답을 받을 수 있습니다.

기존에는 각 API 엔드포인트마다 별도의 함수를 만들거나, 응답을 any로 받아서 타입 단언(type assertion)을 해야 했다면, 이제는 하나의 제네릭 함수로 모든 엔드포인트를 처리하면서도 완벽한 타입 안정성을 보장받을 수 있습니다. 함수 제네릭의 핵심 특징은 첫째, 호출 시점에 타입이 결정된다는 점입니다.

유연성과 타입 안정성을 동시에 제공하죠. 둘째, 여러 매개변수와 반환값에 동일한 타입 제약을 적용할 수 있습니다.

입력과 출력의 타입 관계를 명확히 표현할 수 있습니다. 셋째, 화살표 함수와 일반 함수 모두에서 사용 가능합니다.

이러한 특징들이 실무에서 타입 안전한 유틸리티 라이브러리를 만들 때 필수적입니다.

코드 예제

// API 응답을 처리하는 제네릭 함수
async function fetchData<TResponse>(url: string): Promise<TResponse> {
  const response = await fetch(url);
  const data = await response.json();
  return data as TResponse;
}

// 사용 예시 - 각 호출마다 다른 타입 반환
interface User {
  id: number;
  name: string;
}

interface Product {
  id: number;
  title: string;
  price: number;
}

// TypeScript가 정확한 타입을 추론하고 검증
const user = await fetchData<User>("/api/user/1"); // User 타입
const products = await fetchData<Product[]>("/api/products"); // Product[] 타입

설명

이것이 하는 일: fetchData 함수는 제네릭 타입 TResponse를 받아서, 어떤 타입의 데이터든 타입 안전하게 fetch하고 반환합니다. 첫 번째로, <TResponse> 타입 매개변수를 함수명 바로 뒤에 선언합니다.

여기서 TResponse는 "이 함수가 반환할 데이터의 타입"을 나타내며, 함수를 호출하는 쪽에서 구체적인 타입을 지정하게 됩니다. Promise<TResponse>로 반환 타입을 지정하여, 비동기 작업의 결과가 TResponse 타입임을 명시합니다.

그 다음으로, 함수 내부에서 실제 fetch 작업을 수행하고 JSON을 파싱합니다. as TResponse를 사용한 타입 단언이 필요한 이유는, response.json()의 반환 타입이 any이기 때문입니다.

실제로는 서버에서 받은 데이터가 TResponse 타입과 일치한다고 TypeScript에게 알려주는 것이죠. 마지막으로, 함수를 호출할 때 <User> 또는 <Product[]> 같은 구체적인 타입을 전달합니다.

이렇게 하면 반환되는 Promise가 정확히 그 타입의 데이터를 resolve한다는 것을 TypeScript가 알게 되어, 이후 코드에서 user.name이나 products[0].price 같은 속성에 안전하게 접근할 수 있습니다. 여러분이 이 패턴을 사용하면 모든 API 호출을 하나의 함수로 통일하면서도 각 엔드포인트의 응답 타입을 정확하게 관리할 수 있습니다.

타입 오류를 컴파일 시점에 잡을 수 있고, IDE의 자동완성으로 개발 속도도 빨라집니다. 또한 나중에 API 응답 구조가 바뀌면 인터페이스만 수정하면 되므로 유지보수가 쉬워집니다.

실전 팁

💡 제네릭 함수를 만들 때는 반환 타입을 명시적으로 작성하세요. TypeScript가 추론하게 두면 예상치 못한 타입이 나올 수 있습니다.

💡 화살표 함수에서 제네릭을 사용할 때는 const fetchData = <T>(url: string): Promise<T> => {...} 형식으로 작성합니다.

💡 제네릭 타입을 명시하지 않으면 TypeScript가 인자로부터 추론하려 시도합니다. 추론이 불가능하면 unknown이나 에러가 발생하므로 명시하는 것이 안전합니다.

💡 실무에서는 제네릭 함수에 에러 처리와 유효성 검증을 추가하세요. 런타임에 받은 데이터가 실제로 예상한 타입인지 확인하는 것이 중요합니다.

💡 제네릭 함수를 라이브러리로 만들 때는 JSDoc 주석으로 사용 예시를 제공하면 다른 개발자들이 쉽게 이해하고 사용할 수 있습니다.


3. 제네릭 인터페이스

시작하며

여러분이 API 응답의 공통 구조를 정의하려고 할 때, 데이터 부분만 달라지는 상황을 겪어본 적 있나요? 예를 들어, 모든 API 응답이 { success: boolean, data: ???, message: string } 형태인데, data의 타입만 매번 다른 경우입니다.

이런 패턴은 실제 백엔드 API 설계에서 표준처럼 사용됩니다. 페이지네이션 결과, API 응답 래퍼, 상태 관리 스토어 등에서 구조는 같지만 담기는 데이터 타입만 다른 경우가 많습니다.

각각 별도의 인터페이스를 만들면 코드가 지나치게 중복되고, 공통 구조를 변경할 때 모든 인터페이스를 수정해야 합니다. 바로 이럴 때 필요한 것이 제네릭 인터페이스입니다.

인터페이스에 타입 매개변수를 추가하면 구조는 공유하되 내부 타입만 유연하게 변경할 수 있어서, DRY(Don't Repeat Yourself) 원칙을 지키면서도 타입 안정성을 확보할 수 있습니다.

개요

간단히 말해서, 제네릭 인터페이스는 타입 매개변수를 받는 인터페이스로, 같은 구조를 가지지만 내부 타입이 다른 여러 객체를 표현할 수 있습니다. 일반 인터페이스가 고정된 타입 구조를 정의한다면, 제네릭 인터페이스는 "틀"만 정의하고 실제 타입은 사용할 때 결정합니다.

이를 통해 API 응답 객체, 컨테이너 객체, 설정 객체 등 반복되는 패턴을 하나의 인터페이스로 표현할 수 있습니다. 예를 들어, Redux 액션, API 클라이언트 응답, 페이지네이션 결과 등을 하나의 제네릭 인터페이스로 타입 정의할 수 있습니다.

기존에는 UserResponse, ProductResponse, OrderResponse처럼 비슷한 인터페이스를 여러 개 만들어야 했다면, 이제는 ApiResponse<T> 하나로 모든 응답 타입을 표현할 수 있습니다. 제네릭 인터페이스의 핵심 특징은 첫째, 코드 중복을 크게 줄입니다.

공통 구조를 한 번만 정의하면 되죠. 둘째, 타입 간의 관계를 명확히 표현할 수 있습니다.

"이 객체는 무언가를 담는 컨테이너다"라는 의미를 타입으로 나타낼 수 있습니다. 셋째, 여러 제네릭 매개변수를 사용하여 복잡한 타입 관계도 표현 가능합니다.

이러한 특징들이 대규모 애플리케이션에서 일관된 타입 시스템을 구축하는 데 핵심적입니다.

코드 예제

// 제네릭 인터페이스 정의 - API 응답의 공통 구조
interface ApiResponse<T> {
  success: boolean;
  data: T;
  message: string;
  timestamp: number;
}

// 다양한 타입에 재사용
interface User {
  id: number;
  name: string;
}

// 각각 다른 데이터를 담는 응답 타입
type UserResponse = ApiResponse<User>;
type UsersResponse = ApiResponse<User[]>;
type LoginResponse = ApiResponse<{ token: string; expiresIn: number }>;

// 실제 사용 예시
const response: UserResponse = {
  success: true,
  data: { id: 1, name: "Kim" },
  message: "User fetched successfully",
  timestamp: Date.now()
};

설명

이것이 하는 일: ApiResponse<T> 인터페이스는 모든 API 응답의 공통 구조를 정의하고, T 타입 매개변수로 실제 데이터 타입을 받아서 완전한 응답 타입을 만듭니다. 첫 번째로, interface ApiResponse<T> 선언에서 <T>는 이 인터페이스가 타입 매개변수를 받는다는 것을 나타냅니다.

인터페이스 내부에서 T를 data 속성의 타입으로 사용함으로써, 사용하는 쪽에서 전달한 타입이 정확히 data에 반영됩니다. success, message, timestamp는 모든 응답에서 동일하므로 구체적인 타입으로 고정합니다.

그 다음으로, type UserResponse = ApiResponse<User>처럼 실제로 사용할 때 구체적인 타입을 전달합니다. 이렇게 하면 UserResponse는 자동으로 { success: boolean; data: User; message: string; timestamp: number } 타입이 됩니다.

TypeScript가 제네릭 매개변수를 실제 타입으로 치환해주는 것이죠. 마지막으로, 정의된 타입을 객체에 적용하면 TypeScript가 구조를 검증합니다.

response 객체는 모든 필수 속성을 가져야 하고, data는 정확히 User 타입이어야 합니다. 만약 data에 { id: "1", name: "Kim" }처럼 id를 문자열로 넣으면 컴파일 에러가 발생합니다.

여러분이 이 패턴을 사용하면 API 응답 처리 코드가 일관성 있고 예측 가능해집니다. 새로운 엔드포인트를 추가할 때 응답 구조를 고민할 필요 없이, 데이터 타입만 정의하고 ApiResponse<T>로 래핑하면 됩니다.

또한 나중에 공통 속성(예: errorCode)을 추가하려면 인터페이스 한 곳만 수정하면 모든 응답 타입에 자동으로 반영됩니다.

실전 팁

💡 제네릭 인터페이스는 2개 이상의 타입 매개변수도 받을 수 있습니다: interface KeyValue<K, V> { key: K; value: V; } 같은 형태로 사용하세요.

💡 제네릭 인터페이스를 확장할 때도 제네릭을 유지할 수 있습니다: interface PagedResponse<T> extends ApiResponse<T> { totalPages: number; }

💡 타입 별칭(type alias)으로도 제네릭을 만들 수 있지만, 인터페이스는 선언 병합이 가능하고 에러 메시지가 더 명확해서 공개 API에는 인터페이스가 권장됩니다.

💡 제네릭 인터페이스를 너무 많이 중첩하면 타입이 복잡해져서 가독성이 떨어집니다. 2-3단계 이상 중첩되면 type alias로 간단한 이름을 만들어주세요.

💡 실무에서는 제네릭 인터페이스에 JSDoc을 추가하여 T가 어떤 종류의 타입이어야 하는지 설명하면 팀원들이 이해하기 쉽습니다.


4. 제네릭 클래스

시작하며

여러분이 데이터를 저장하고 관리하는 스토어 클래스를 만들 때, User용 스토어, Product용 스토어, Order용 스토어를 각각 따로 만들고 계신가요? 각 클래스가 add, get, remove 같은 똑같은 메서드를 가지는데도 타입만 다르다는 이유로 코드를 반복하고 있다면 비효율적입니다.

이런 상황은 상태 관리, 캐시 시스템, 컬렉션 관리 등에서 매우 흔합니다. 로직은 동일한데 다루는 데이터 타입만 다른 경우가 많죠.

각 타입마다 별도의 클래스를 만들면 유지보수 비용이 증가하고, 새로운 기능을 추가할 때마다 모든 클래스를 수정해야 합니다. 바로 이럴 때 필요한 것이 제네릭 클래스입니다.

클래스에 타입 매개변수를 추가하면 하나의 클래스로 여러 타입의 데이터를 타입 안전하게 관리할 수 있어서, 객체지향 프로그래밍의 재사용성과 TypeScript의 타입 안정성을 모두 활용할 수 있습니다.

개요

간단히 말해서, 제네릭 클래스는 타입 매개변수를 받는 클래스로, 인스턴스를 생성할 때 구체적인 타입을 지정하여 타입 안전한 객체를 만들 수 있습니다. 일반 클래스가 고정된 타입으로 동작한다면, 제네릭 클래스는 인스턴스마다 다른 타입으로 동작할 수 있습니다.

이를 통해 범용적인 데이터 구조(스택, 큐, 스토어 등)를 만들거나, 타입별로 다른 동작이 필요한 서비스 클래스를 구현할 수 있습니다. 예를 들어, LocalStorage를 래핑한 제네릭 스토어 클래스를 만들면 각 키마다 정확한 타입의 데이터를 저장하고 불러올 수 있습니다.

기존에는 UserStore, ProductStore 같은 클래스를 각각 만들거나, any 타입으로 모든 것을 처리했다면, 이제는 Store<T> 하나로 모든 타입의 데이터를 타입 안전하게 관리할 수 있습니다. 제네릭 클래스의 핵심 특징은 첫째, 인스턴스 생성 시 타입이 고정되어 일관성이 보장됩니다.

userStore는 항상 User만 다루고, productStore는 항상 Product만 다루죠. 둘째, 클래스 내부의 모든 메서드가 동일한 타입 컨텍스트를 공유합니다.

타입을 매번 지정할 필요가 없습니다. 셋째, 상속과 결합하여 더 강력한 타입 시스템을 만들 수 있습니다.

이러한 특징들이 엔터프라이즈 애플리케이션에서 복잡한 비즈니스 로직을 타입 안전하게 구현하는 데 필수적입니다.

코드 예제

// 제네릭 클래스 - 데이터 저장소
class DataStore<T> {
  private items: T[] = [];

  // 아이템 추가 - T 타입만 허용
  add(item: T): void {
    this.items.push(item);
  }

  // ID로 조회 - T 타입 반환
  getById(id: number): T | undefined {
    return this.items.find((item: any) => item.id === id);
  }

  // 모든 아이템 반환
  getAll(): T[] {
    return [...this.items];
  }

  // 개수 반환
  get count(): number {
    return this.items.length;
  }
}

// 타입별로 다른 스토어 인스턴스 생성
const userStore = new DataStore<User>();
const productStore = new DataStore<Product>();

userStore.add({ id: 1, name: "Kim" }); // OK
// userStore.add({ id: 1, title: "Book" }); // 에러! User 타입이 아님

설명

이것이 하는 일: DataStore<T> 클래스는 타입 매개변수 T를 받아서, 그 타입의 데이터만 저장하고 관리하는 타입 안전한 저장소를 제공합니다. 첫 번째로, class DataStore<T> 선언에서 클래스 레벨의 제네릭 타입을 정의합니다.

이 T는 클래스의 모든 메서드와 속성에서 사용할 수 있으며, 인스턴스가 생성될 때 구체적인 타입으로 결정됩니다. private items: T[]처럼 인스턴스 변수의 타입으로 사용하여, 이 스토어가 어떤 타입의 배열을 관리할지 결정합니다.

그 다음으로, 클래스의 각 메서드에서 T를 사용합니다. add(item: T) 메서드는 T 타입의 아이템만 받을 수 있고, getById(): T | undefined는 T 타입 또는 undefined를 반환합니다.

이렇게 하면 클래스의 모든 동작이 일관된 타입으로 작동하며, 메서드 간에 타입 불일치가 발생할 수 없습니다. 마지막으로, new DataStore<User>()처럼 인스턴스를 생성할 때 구체적인 타입을 전달합니다.

이 순간 userStore의 모든 T는 User로 치환되어, userStore.add()는 User만 받고, userStore.getAll()은 User[]을 반환합니다. 다른 타입을 전달하려고 하면 즉시 컴파일 에러가 발생합니다.

여러분이 이 패턴을 사용하면 하나의 클래스 구현으로 무한한 타입의 스토어를 만들 수 있습니다. 코드 중복 없이 재사용성을 극대화하면서도, 각 인스턴스는 자신만의 타입 안정성을 보장받습니다.

새로운 메서드를 추가하거나 버그를 수정할 때도 한 곳만 수정하면 모든 타입의 스토어에 적용됩니다.

실전 팁

💡 제네릭 클래스의 생성자에서도 타입 매개변수를 사용할 수 있습니다: constructor(initialItems: T[]) { this.items = initialItems; }

💡 정적 메서드는 인스턴스의 제네릭 타입을 사용할 수 없습니다. 정적 메서드에서 제네릭이 필요하면 메서드 레벨 제네릭을 사용하세요: static create<U>(item: U): DataStore<U>

💡 제네릭 클래스를 상속할 때는 타입을 고정하거나 전달할 수 있습니다: class UserStore extends DataStore<User> 또는 class SortedStore<T> extends DataStore<T>

💡 제네릭 클래스 내부에서 T의 속성에 접근하려면 제약 조건을 사용해야 합니다. 그냥 T만 쓰면 어떤 속성도 보장되지 않습니다(다음 섹션에서 설명).

💡 실무에서는 제네릭 클래스에 여러 타입 매개변수를 사용하기도 합니다: class Map<K, V> 같은 형태로 키와 값의 타입을 각각 관리하세요.


5. 제네릭 제약 조건

시작하며

여러분이 제네릭 함수 내부에서 전달받은 타입의 속성에 접근하려고 하면 에러가 발생한 적 있나요? 예를 들어, function logName<T>(obj: T) { console.log(obj.name); }처럼 작성하면 "Property 'name' does not exist on type 'T'"라는 에러를 만나게 됩니다.

이런 문제는 제네릭을 실무에서 사용할 때 반드시 마주치는 상황입니다. 제네릭 타입 T는 "어떤 타입이든 될 수 있다"는 의미이기 때문에, TypeScript는 T에 어떤 속성이나 메서드가 있는지 전혀 알 수 없습니다.

그래서 안전을 위해 접근을 막아버리죠. 하지만 실제로는 "name 속성을 가진 객체만 받고 싶다"는 요구사항이 많습니다.

바로 이럴 때 필요한 것이 제네릭 제약 조건(Generic Constraints)입니다. extends 키워드를 사용하여 제네릭 타입이 특정 조건을 만족해야 한다고 명시하면, 타입 안정성을 유지하면서도 필요한 속성에 접근할 수 있습니다.

개요

간단히 말해서, 제네릭 제약 조건은 제네릭 타입 매개변수가 특정 타입을 확장하거나 특정 구조를 가져야 한다고 제한하는 기능입니다. 제약 조건 없는 제네릭이 "모든 타입을 허용"한다면, 제약 조건이 있는 제네릭은 "특정 조건을 만족하는 타입만 허용"합니다.

이를 통해 전달된 타입의 속성이나 메서드를 안전하게 사용할 수 있으면서도, 여전히 여러 타입에 대해 동작하는 범용 함수를 만들 수 있습니다. 예를 들어, "id 속성을 가진 모든 객체"를 받는 함수를 만들어서 User, Product, Order 등에 공통으로 사용할 수 있습니다.

기존에는 제네릭 내부에서 속성에 접근할 수 없어서 any로 타입을 풀거나, 제네릭을 포기하고 유니온 타입으로 모든 가능한 타입을 나열했다면, 이제는 제약 조건으로 "최소한 이런 속성은 있다"고 보장하면서 타입 안정성을 유지할 수 있습니다. 제네릭 제약 조건의 핵심 특징은 첫째, 타입의 "최소 요구사항"을 명시합니다.

그 이상의 속성을 가진 타입도 허용되죠. 둘째, 인터페이스, 타입 별칭, 클래스 등 모든 타입으로 제약할 수 있습니다.

셋째, 여러 제약을 조합할 수도 있습니다(인터섹션 타입 사용). 이러한 특징들이 실무에서 유연하면서도 안전한 제네릭 코드를 작성하는 핵심입니다.

코드 예제

// 제약 조건 없이는 에러 발생
// function logName<T>(obj: T) { console.log(obj.name); } // 에러!

// id 속성을 가진 객체만 허용하는 제약 조건
interface HasId {
  id: number;
}

function logId<T extends HasId>(obj: T): void {
  console.log(`ID: ${obj.id}`); // OK! T가 반드시 id를 가짐
}

// 다양한 타입에 사용 가능 - id만 있으면 됨
interface User {
  id: number;
  name: string;
}

interface Product {
  id: number;
  title: string;
  price: number;
}

logId({ id: 1, name: "Kim" }); // OK - User 타입
logId({ id: 2, title: "Book", price: 10000 }); // OK - Product 타입
// logId({ name: "Kim" }); // 에러! id가 없음

설명

이것이 하는 일: <T extends HasId> 제약 조건은 T가 반드시 HasId 인터페이스를 만족해야 한다고 명시하여, 함수 내부에서 obj.id에 안전하게 접근할 수 있게 합니다. 첫 번째로, interface HasId { id: number; }로 "최소한 id 속성을 가진 객체"를 나타내는 타입을 정의합니다.

이것은 실제 사용될 타입이 아니라, 제약 조건을 표현하기 위한 "계약"입니다. 어떤 타입이든 id: number 속성만 있으면 이 계약을 만족합니다.

그 다음으로, <T extends HasId>로 제네릭 타입 T가 HasId를 확장해야 한다고 선언합니다. 이는 "T는 어떤 타입이든 될 수 있지만, 최소한 HasId의 모든 속성은 가져야 한다"는 의미입니다.

이제 TypeScript는 T가 반드시 id 속성을 가진다는 것을 알기 때문에, obj.id에 접근할 때 에러를 발생시키지 않습니다. 마지막으로, 함수를 호출할 때 전달하는 객체가 제약 조건을 만족하는지 검사됩니다.

User와 Product는 모두 id: number를 가지므로 통과하지만, id가 없는 객체를 전달하면 컴파일 에러가 발생합니다. 중요한 점은 T가 id 외에 다른 속성을 가져도 문제없다는 것입니다.

제약 조건은 "최소 요구사항"이기 때문이죠. 여러분이 이 패턴을 사용하면 공통 속성을 기반으로 한 범용 함수를 안전하게 만들 수 있습니다.

예를 들어, "id를 가진 모든 객체를 처리하는 CRUD 유틸리티"를 만들면 다양한 엔티티에 재사용할 수 있습니다. 타입 안정성을 잃지 않으면서도 코드 재사용성을 극대화하는 것이죠.

실전 팁

💡 여러 인터페이스를 동시에 제약하려면 인터섹션을 사용하세요: <T extends HasId & HasTimestamp> 이렇게 하면 T는 두 인터페이스를 모두 만족해야 합니다.

💡 기본 타입으로도 제약할 수 있습니다: <T extends string> 하면 T는 string이거나 string 리터럴 타입만 가능합니다.

💡 클래스로 제약하면 해당 클래스의 인스턴스만 허용됩니다: <T extends Date> 하면 Date 객체만 받을 수 있습니다.

💡 제약 조건에 제네릭을 사용할 수도 있습니다: <T, K extends keyof T> 이렇게 하면 K는 T의 키만 가능합니다(나중에 자세히 설명).

💡 실무에서는 제약 조건을 너무 엄격하게 만들지 마세요. 필요한 최소한의 속성만 제약하면 더 많은 타입에 재사용할 수 있습니다.


6. 다중 제네릭 타입

시작하며

여러분이 객체의 특정 속성 값을 가져오는 함수를 만들려고 할 때, 객체 타입과 키 타입을 모두 안전하게 처리하고 싶다면 어떻게 해야 할까요? 예를 들어, getProperty(user, "name")은 string을 반환하고, getProperty(user, "id")는 number를 반환하도록 타입을 정확히 추론하고 싶은 경우입니다.

이런 상황은 실무에서 매우 자주 발생합니다. 두 개 이상의 타입이 서로 관련되어 있고, 한 타입이 다른 타입에 의존하는 경우가 많죠.

단일 제네릭으로는 이런 복잡한 타입 관계를 표현할 수 없어서, 결국 any를 쓰거나 타입 단언에 의존하게 됩니다. 바로 이럴 때 필요한 것이 다중 제네릭 타입입니다.

여러 개의 타입 매개변수를 사용하면 타입 간의 복잡한 관계를 정확히 표현할 수 있어서, 더욱 강력하고 안전한 타입 시스템을 구축할 수 있습니다.

개요

간단히 말해서, 다중 제네릭 타입은 함수나 클래스가 2개 이상의 타입 매개변수를 받아서 여러 타입 간의 관계를 표현하는 기능입니다. 단일 제네릭이 "하나의 타입을 변수로"한다면, 다중 제네릭은 "여러 타입을 동시에 변수로"하여 타입 간의 관계를 정의합니다.

이를 통해 키-값 쌍, 변환 함수, 매핑 객체 등 하나의 타입이 다른 타입에 의존하는 복잡한 상황을 타입 안전하게 처리할 수 있습니다. 예를 들어, 배열의 요소를 다른 타입으로 변환하는 map 함수는 입력 타입과 출력 타입 2개의 제네릭이 필요합니다.

기존에는 복잡한 타입 관계를 표현할 수 없어서 반환 타입을 명시적으로 타입 단언하거나, 오버로드를 여러 개 작성해야 했다면, 이제는 다중 제네릭으로 타입 간의 관계를 명확히 표현하여 TypeScript가 자동으로 추론하게 할 수 있습니다. 다중 제네릭 타입의 핵심 특징은 첫째, 타입 간의 관계를 코드로 표현할 수 있습니다.

"K는 T의 키여야 한다" 같은 제약을 만들 수 있죠. 둘째, 각 타입 매개변수는 독립적으로 추론되거나 명시될 수 있습니다.

셋째, 한 타입 매개변수를 다른 타입 매개변수의 제약 조건으로 사용할 수 있습니다. 이러한 특징들이 고급 타입 유틸리티를 만드는 핵심입니다.

코드 예제

// 다중 제네릭 타입 - 객체와 그 키의 관계 표현
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

interface User {
  id: number;
  name: string;
  email: string;
}

const user: User = {
  id: 1,
  name: "Kim",
  email: "kim@example.com"
};

// TypeScript가 정확한 반환 타입을 추론
const userId = getProperty(user, "id"); // number 타입
const userName = getProperty(user, "name"); // string 타입
// const invalid = getProperty(user, "age"); // 에러! "age"는 User의 키가 아님

// 다른 예시 - 배열 변환 함수
function mapArray<T, U>(arr: T[], transform: (item: T) => U): U[] {
  return arr.map(transform);
}

const numbers = [1, 2, 3];
const strings = mapArray(numbers, num => num.toString()); // string[] 타입

설명

이것이 하는 일: getProperty<T, K extends keyof T> 함수는 객체 타입 T와 그 키 타입 K를 받아서, 해당 키의 정확한 값 타입 T[K]를 반환합니다. 첫 번째로, <T, K extends keyof T> 선언에서 두 개의 타입 매개변수를 정의합니다.

T는 객체의 타입이고, K는 T의 키 중 하나여야 한다는 제약이 있습니다. keyof T는 T의 모든 키를 유니온 타입으로 만들어주는 TypeScript 연산자입니다.

User의 경우 "id" | "name" | "email"이 되죠. 그 다음으로, 매개변수와 반환 타입에서 이 타입들을 사용합니다.

obj: T는 어떤 객체든 받을 수 있고, key: K는 그 객체의 실제 키만 받을 수 있습니다. 반환 타입 T[K]는 인덱스 접근 타입으로, "T 타입의 K 키에 해당하는 값의 타입"을 의미합니다.

User["name"]은 string이고, User["id"]는 number입니다. 마지막으로, 함수를 호출하면 TypeScript가 전달된 인자로부터 T와 K를 추론합니다.

getProperty(user, "id")를 호출하면 T는 User, K는 "id" 리터럴 타입으로 추론되고, 반환 타입은 자동으로 User["id"] 즉 number가 됩니다. 존재하지 않는 키를 전달하면 K extends keyof T 제약을 위반하여 컴파일 에러가 발생합니다.

여러분이 이 패턴을 사용하면 객체 속성에 안전하게 접근하는 유틸리티를 만들 수 있습니다. 폼 값 가져오기, 설정 읽기, 상태 업데이트 등 객체를 다루는 모든 곳에서 타입 안정성을 확보할 수 있습니다.

mapArray 예시처럼 입력과 출력 타입이 다른 변환 함수도 정확히 타입을 추론할 수 있죠.

실전 팁

💡 타입 매개변수는 관례적으로 T, U, V 순서로 사용하거나, TInput, TOutput처럼 의미 있는 이름을 사용하세요.

💡 한 타입 매개변수가 다른 타입에 의존할 때는 순서가 중요합니다: <T, K extends keyof T> (O), <K extends keyof T, T> (X - T가 아직 정의 안 됨)

💡 모든 타입 매개변수를 명시적으로 지정하려면 getProperty<User, "name">(user, "name") 처럼 작성하세요. 일부만 지정하는 것은 불가능합니다.

💡 다중 제네릭에서도 기본값을 사용할 수 있습니다: <T, U = T> 이렇게 하면 U를 지정하지 않으면 T와 같은 타입이 됩니다.

💡 3개 이상의 타입 매개변수는 복잡도를 크게 높입니다. 정말 필요한 경우가 아니면 2개 이하로 유지하는 것이 좋습니다.


7. 제네릭 유틸리티 타입

시작하며

여러분이 기존 타입의 모든 속성을 선택적으로 만들고 싶거나, 특정 속성만 추출하고 싶을 때 어떻게 하시나요? 일일이 새로운 인터페이스를 만들어서 모든 속성에 물음표를 붙이거나, 필요한 속성만 복사해서 정의하는 것은 비효율적이고 유지보수가 어렵습니다.

이런 상황은 실무에서 정말 자주 발생합니다. API 업데이트 요청은 모든 필드가 선택적이어야 하고, 폼 초기값은 일부 속성만 필요하며, 읽기 전용 뷰는 모든 속성을 수정 불가능하게 만들어야 하죠.

매번 새로운 타입을 정의하면 원본 타입이 변경될 때 모든 파생 타입을 수동으로 업데이트해야 합니다. 바로 이럴 때 필요한 것이 TypeScript의 내장 제네릭 유틸리티 타입입니다.

Partial, Pick, Omit, Readonly 같은 유틸리티 타입을 사용하면 기존 타입을 변형하여 새로운 타입을 자동으로 생성할 수 있어서, 타입 정의가 DRY하고 유지보수하기 쉬워집니다.

개요

간단히 말해서, 제네릭 유틸리티 타입은 TypeScript가 기본 제공하는 제네릭 타입들로, 기존 타입을 변형하거나 조작하여 새로운 타입을 만드는 강력한 도구입니다. 이 유틸리티 타입들은 모두 제네릭으로 구현되어 있어서, 어떤 타입이든 전달하면 원하는 형태로 변환해줍니다.

타입을 일일이 재정의할 필요 없이, 원본 타입과의 관계를 명확히 유지하면서 파생 타입을 만들 수 있습니다. 예를 들어, User 타입에서 업데이트용 타입을 만들 때 Partial<User>를 쓰면, User가 변경되어도 자동으로 반영됩니다.

기존에는 타입을 변형할 때마다 새로운 인터페이스를 만들고 모든 속성을 다시 작성해야 했다면, 이제는 유틸리티 타입으로 한 줄로 원하는 변형을 표현할 수 있습니다. 제네릭 유틸리티 타입의 핵심 특징은 첫째, 원본 타입과 동기화가 자동입니다.

원본이 바뀌면 파생 타입도 자동으로 업데이트되죠. 둘째, 타입 간의 관계를 명확히 표현합니다.

UpdateUserDto가 Partial<User>라는 것을 보면 즉시 이해할 수 있습니다. 셋째, 조합하여 더 복잡한 타입 변환도 가능합니다.

Readonly<Partial<T>> 같은 형태로 여러 변환을 적용할 수 있습니다. 이러한 특징들이 타입 시스템을 간결하고 유지보수하기 쉽게 만듭니다.

코드 예제

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

// Partial<T> - 모든 속성을 선택적으로
type UpdateUser = Partial<User>;
const update: UpdateUser = { name: "Lee" }; // OK - 일부만 제공 가능

// Required<T> - 모든 속성을 필수로
type RequiredUser = Required<Partial<User>>; // 다시 모두 필수로

// Readonly<T> - 모든 속성을 읽기 전용으로
type ImmutableUser = Readonly<User>;
const immutable: ImmutableUser = { id: 1, name: "Kim", email: "k@e.com", age: 30 };
// immutable.name = "Lee"; // 에러! 읽기 전용

// Pick<T, K> - 특정 속성만 선택
type UserPreview = Pick<User, "id" | "name">;
const preview: UserPreview = { id: 1, name: "Kim" }; // email, age 불필요

// Omit<T, K> - 특정 속성 제외
type UserWithoutId = Omit<User, "id">;
const newUser: UserWithoutId = { name: "Park", email: "p@e.com", age: 25 };

// Record<K, T> - 키-값 쌍의 객체 타입 생성
type UserRoles = Record<string, boolean>;
const roles: UserRoles = { admin: true, editor: false };

설명

이것이 하는 일: TypeScript의 내장 제네릭 유틸리티 타입들은 기존 타입을 받아서 속성을 선택적/필수적/읽기전용으로 만들거나, 특정 속성만 추출/제외하여 새로운 타입을 생성합니다. 첫 번째로, Partial<User>는 User의 모든 속성에 ?를 붙인 것과 같은 타입을 만듭니다.

{ id?: number; name?: string; ... } 형태가 되어, API PATCH 요청처럼 일부 필드만 업데이트할 때 유용합니다.

반대로 Required<T>는 선택적 속성을 모두 필수로 만듭니다. 그 다음으로, Pick<User, "id" | "name">은 User에서 id와 name 속성만 가진 새 타입을 만듭니다.

목록 화면에서 전체 정보가 필요 없을 때 유용하죠. 반대로 Omit<User, "id">는 id를 제외한 나머지 속성만 가진 타입을 만듭니다.

새 사용자 생성 시 id는 서버에서 생성되므로 제외할 때 사용합니다. 마지막으로, Readonly<User>는 모든 속성 앞에 readonly를 붙인 타입을 만들어 불변성을 보장합니다.

Record<K, T>는 키 타입 K와 값 타입 T를 받아 객체 타입을 만듭니다. 동적 키를 가진 객체를 타입 안전하게 정의할 때 사용하죠.

여러분이 이 유틸리티 타입들을 사용하면 타입 정의가 간결해지고 유지보수가 쉬워집니다. User 인터페이스에 새 속성을 추가하면 Partial<User>, Pick<User, ...> 등 모든 파생 타입이 자동으로 업데이트됩니다.

타입 간의 관계도 명확히 표현되어 코드 이해도가 높아집니다.

실전 팁

💡 유틸리티 타입을 조합할 수 있습니다: Readonly<Partial<User>> 하면 모든 속성이 선택적이면서 읽기 전용입니다.

💡 ReturnType<T>, Parameters<T> 같은 고급 유틸리티도 있습니다. 함수의 반환 타입이나 매개변수 타입을 추출할 때 유용합니다.

💡 Exclude<T, U>, Extract<T, U> 같은 유니온 타입 조작 유틸리티도 제공됩니다. 타입에서 특정 타입을 제거하거나 추출할 수 있죠.

💡 실무에서는 Pick과 Omit을 자주 사용합니다. DTO(Data Transfer Object)를 만들 때 필요한 속성만 선택하거나 민감한 정보를 제외할 때 유용합니다.

💡 커스텀 유틸리티 타입도 만들 수 있습니다: type DeepPartial<T> = { [P in keyof T]?: DeepPartial<T[P]>; } 같은 형태로 중첩 객체도 Partial하게 만들 수 있습니다.


8. keyof와 제네릭

시작하며

여러분이 객체의 속성 이름들을 타입으로 다루고 싶을 때, 어떻게 하시나요? 예를 들어, 객체의 특정 속성을 업데이트하는 함수를 만들 때 속성 이름을 문자열이 아닌 타입 안전한 키로 받고 싶은 경우입니다.

그냥 string으로 받으면 존재하지 않는 속성 이름도 허용되어 런타임 에러가 발생할 수 있습니다. 이런 상황은 상태 관리, 폼 처리, 객체 매핑 등에서 매우 흔합니다.

객체의 키를 동적으로 다루면서도 타입 안정성을 유지하려면 복잡한 타입 체크나 타입 단언이 필요했죠. 결국 any를 쓰거나, 각 속성마다 별도의 함수를 만들게 됩니다.

바로 이럴 때 필요한 것이 keyof 연산자와 제네릭의 조합입니다. keyof를 사용하면 객체 타입의 모든 키를 유니온 타입으로 추출할 수 있고, 제네릭과 결합하면 타입 안전한 동적 속성 접근이 가능해집니다.

개요

간단히 말해서, keyof는 객체 타입의 모든 키를 문자열 리터럴 유니온 타입으로 추출하는 TypeScript 연산자이며, 제네릭과 함께 사용하면 객체 속성을 타입 안전하게 다룰 수 있습니다. keyof T는 T 타입의 모든 속성 이름을 유니온으로 만듭니다.

예를 들어 User 타입에 id, name, email이 있다면 keyof User"id" | "name" | "email" 타입이 됩니다. 이를 제네릭 제약 조건과 결합하면 "T 타입의 실제 키만 허용"하는 강력한 타입 안정성을 구현할 수 있습니다.

객체 업데이트, 속성 선택, 동적 접근 등 모든 객체 조작을 타입 안전하게 만들 수 있죠. 기존에는 객체 키를 string 타입으로 받아서 런타임에 존재 여부를 확인하거나, 각 키마다 오버로드를 만들어야 했다면, 이제는 keyof와 제네릭으로 컴파일 타임에 키의 유효성을 검증하고 정확한 값 타입도 추론할 수 있습니다.

keyof와 제네릭의 핵심 특징은 첫째, 객체의 키를 타입으로 다룰 수 있습니다. 문자열이 아닌 정확한 키만 허용하죠.

둘째, 키와 값 타입의 관계를 유지합니다. 특정 키를 전달하면 그 키에 해당하는 값 타입이 자동으로 추론됩니다.

셋째, 리팩토링 안정성이 높습니다. 속성 이름을 변경하면 모든 사용처에서 에러가 발생해 놓치지 않습니다.

이러한 특징들이 대규모 애플리케이션에서 안전한 객체 조작을 가능하게 합니다.

코드 예제

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

// keyof User는 "id" | "name" | "email" | "age" 타입
type UserKeys = keyof User;

// 객체의 특정 속성 업데이트 함수 - 타입 안전
function updateProperty<T, K extends keyof T>(
  obj: T,
  key: K,
  value: T[K]
): void {
  obj[key] = value;
}

const user: User = { id: 1, name: "Kim", email: "k@e.com", age: 30 };

updateProperty(user, "name", "Lee"); // OK - name은 string
updateProperty(user, "age", 31); // OK - age는 number
// updateProperty(user, "age", "31"); // 에러! age는 number여야 함
// updateProperty(user, "address", "Seoul"); // 에러! address는 User의 키가 아님

// 여러 속성 선택하여 추출
function pluck<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>;
  keys.forEach(key => result[key] = obj[key]);
  return result;
}

const preview = pluck(user, ["id", "name"]); // { id: number; name: string } 타입

설명

이것이 하는 일: keyof T 연산자는 T의 모든 속성 이름을 문자열 리터럴 유니온으로 추출하고, K extends keyof T 제약으로 K가 T의 실제 키만 될 수 있게 하여 타입 안전성을 보장합니다. 첫 번째로, keyof User는 User 타입을 분석하여 모든 속성 이름을 추출합니다.

결과는 "id" | "name" | "email" | "age" 같은 문자열 리터럴 유니온 타입이 됩니다. 이는 단순 string이 아니라 정확히 이 4개의 값만 허용하는 타입이죠.

타입 별칭으로 저장하거나 제네릭 제약 조건에 바로 사용할 수 있습니다. 그 다음으로, <T, K extends keyof T> 제약 조건은 "K는 T의 키 중 하나여야 한다"고 명시합니다.

이제 key 매개변수는 obj 타입의 실제 키만 받을 수 있습니다. value: T[K]는 인덱스 접근 타입으로, K에 해당하는 값의 타입을 정확히 나타냅니다.

"name"을 전달하면 T[K]는 string이 되고, "age"를 전달하면 number가 됩니다. 마지막으로, 함수를 호출할 때 TypeScript가 모든 것을 검증합니다.

존재하지 않는 키를 전달하면 K extends keyof T를 위반하여 에러가 발생하고, 값의 타입이 맞지 않으면 T[K]와 불일치하여 에러가 발생합니다. pluck 예시처럼 여러 키를 배열로 받아도 Pick<T, K>로 정확한 결과 타입을 만들 수 있습니다.

여러분이 이 패턴을 사용하면 객체를 다루는 모든 유틸리티 함수를 타입 안전하게 만들 수 있습니다. setState, updateField, 폼 핸들러 등 동적으로 속성을 다루는 코드에서 런타임 에러를 사전에 방지할 수 있습니다.

리팩토링할 때도 속성 이름을 바꾸면 모든 사용처에서 에러가 나서 놓치지 않습니다.

실전 팁

💡 keyof는 인터페이스, 타입 별칭, 클래스 모두에 사용 가능합니다. 어떤 객체 타입에도 적용할 수 있죠.

💡 number 인덱스 시그니처가 있으면 keyof는 string | number를 반환합니다. 배열의 경우 인덱스도 키이기 때문입니다.

💡 keyof typeof obj 패턴으로 값으로부터 키 타입을 추출할 수 있습니다. 런타임 객체의 키를 타입으로 만들 때 유용합니다.

💡 mapped type과 결합하면 더 강력합니다: { [K in keyof T]: T[K] } 형태로 모든 속성을 순회하며 변환할 수 있습니다.

💡 실무에서는 K extends keyof T 패턴을 폼 라이브러리, 상태 관리 라이브러리에서 매우 자주 사용합니다. React Hook Form, Redux Toolkit 등이 이 패턴을 활용하죠.


9. 조건부 제네릭 타입

시작하며

여러분이 제네릭 타입의 실제 타입에 따라 다른 타입을 반환하고 싶을 때가 있나요? 예를 들어, 배열을 전달하면 요소 타입을 반환하고, 배열이 아니면 그대로 반환하는 타입을 만들고 싶은 경우입니다.

일반 제네릭으로는 이런 조건부 로직을 표현할 수 없습니다. 이런 상황은 고급 타입 유틸리티를 만들 때 자주 발생합니다.

타입의 구조를 분석하여 다르게 동작하는 타입 시스템이 필요한 경우가 많죠. Promise를 unwrap하거나, 함수 타입에서 반환 타입을 추출하거나, 옵셔널 타입을 처리하는 등 복잡한 타입 변환이 필요할 때입니다.

바로 이럴 때 필요한 것이 조건부 타입(Conditional Types)입니다. `T extends U ?

X : Y` 형태로 타입 레벨의 if-else를 작성하여, 제네릭 타입의 구조에 따라 다른 타입을 반환할 수 있습니다.

개요

간단히 말해서, 조건부 타입은 삼항 연산자처럼 타입 조건을 평가하여 참일 때와 거짓일 때 다른 타입을 반환하는 고급 제네릭 기능입니다. 일반 제네릭이 "타입을 받아서 그대로 사용"한다면, 조건부 타입은 "타입을 받아서 분석한 후 조건에 따라 변환"합니다.

T extends U로 T가 U에 할당 가능한지 확인하고, 참이면 첫 번째 타입을, 거짓이면 두 번째 타입을 선택합니다. 이를 통해 타입의 구조를 분석하고, 중첩된 타입을 추출하고, 유니온 타입을 필터링하는 등 복잡한 타입 변환을 구현할 수 있습니다.

기존에는 오버로드를 여러 개 만들거나 타입 단언으로 우회해야 했던 복잡한 타입 로직을, 이제는 조건부 타입으로 선언적으로 표현할 수 있습니다. 조건부 타입의 핵심 특징은 첫째, 타입 레벨의 제어 흐름을 만들 수 있습니다.

if-else 로직을 타입으로 표현하죠. 둘째, infer 키워드로 타입의 일부를 추출할 수 있습니다.

Promise<T>에서 T를 꺼내오는 등의 작업이 가능합니다. 셋째, 유니온 타입에 적용하면 분배 법칙이 적용됩니다.

각 유니온 멤버에 조건부 타입이 개별 적용되죠. 이러한 특징들이 TypeScript의 타입 시스템을 튜링 완전하게 만드는 핵심입니다.

코드 예제

// 기본 조건부 타입 - 배열이면 요소 타입, 아니면 그대로
type Unwrap<T> = T extends Array<infer U> ? U : T;

type Num = Unwrap<number[]>; // number
type Str = Unwrap<string>; // string

// Promise unwrap
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type User = { id: number; name: string };
type UserPromise = Promise<User>;
type UnwrappedUser = UnwrapPromise<UserPromise>; // User 타입

// 함수 반환 타입 추출
type ReturnTypeCustom<T> = T extends (...args: any[]) => infer R ? R : never;

function getUser(): User {
  return { id: 1, name: "Kim" };
}

type UserReturnType = ReturnTypeCustom<typeof getUser>; // User 타입

// null/undefined 제거
type NonNullable<T> = T extends null | undefined ? never : T;

type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>; // string

설명

이것이 하는 일: T extends U ? X : Y 형태의 조건부 타입은 T가 U에 할당 가능하면 X 타입을, 아니면 Y 타입을 반환하며, infer 키워드로 타입의 일부를 추출할 수 있습니다.

첫 번째로, T extends Array<infer U>에서 T가 배열인지 확인합니다. extends는 여기서 "할당 가능한가?"를 의미하며, Array<무언가> 형태라면 참입니다.

infer U는 "배열의 요소 타입을 U라는 이름으로 추론해서 저장하라"는 의미입니다. 조건이 참이면 추론된 U를 반환하고, 거짓이면 T를 그대로 반환합니다.

그 다음으로, Promise<infer U> 패턴으로 Promise가 감싼 타입을 추출합니다. Promise<User>가 전달되면 U는 User로 추론되고, 조건이 참이므로 User가 반환됩니다.

함수 타입 (...args: any[]) => infer R은 "어떤 매개변수든 받아서 R을 반환하는 함수" 패턴으로, R에 실제 반환 타입이 추론됩니다. 마지막으로, `T extends null | undefined ?

never : T는 T가 null이나 undefined면 never(불가능한 타입), 아니면 T를 반환합니다. 유니온 타입에 적용되면 분배 법칙으로 string | nullNonNullable<string> | NonNullable<null>처럼 각각 적용되어, 결과는 string | never`가 되고 never는 사라져서 최종적으로 string만 남습니다.

여러분이 이 패턴을 사용하면 고급 타입 유틸리티를 직접 만들 수 있습니다. async 함수의 실제 반환 타입 추출, 중첩 배열 평탄화, 옵셔널 속성 필터링 등 복잡한 타입 변환을 선언적으로 표현할 수 있습니다.

TypeScript의 내장 유틸리티 타입들도 대부분 조건부 타입으로 구현되어 있습니다.

실전 팁

💡 조건부 타입은 중첩할 수 있습니다: T extends A ? X : T extends B ? Y : Z 형태로 여러 조건을 체크할 수 있습니다.

💡 infer는 조건부 타입의 extends 절에서만 사용 가능합니다. 다른 곳에서는 사용할 수 없죠.

💡 never 타입을 적절히 활용하면 유니온에서 특정 타입을 필터링할 수 있습니다. never는 유니온에서 자동으로 제거됩니다.

💡 조건부 타입이 너무 복잡해지면 type alias를 중간에 만들어서 단계적으로 처리하세요. 가독성이 중요합니다.

💡 실무에서는 조건부 타입으로 함수 오버로드를 대체할 수 있습니다. 타입 레벨 로직으로 처리하면 더 간결하고 유지보수하기 쉽습니다.


10. 제네릭 기본값

시작하며

여러분이 제네릭 함수나 타입을 만들 때, 대부분의 경우 같은 타입을 사용하는데 매번 타입을 명시하는 것이 번거로운 적이 있나요? 예를 들어, API 응답이 대부분 { success: boolean; data: any } 형태인데, data 타입을 매번 지정하지 않으면 에러가 나는 경우입니다.

이런 상황은 라이브러리나 유틸리티를 만들 때 자주 발생합니다. 사용자가 대부분 기본값으로 사용할 타입이 있는데, 선택적으로 다른 타입을 지정할 수도 있어야 하는 경우가 많죠.

제네릭 타입을 필수로 만들면 매번 타입을 지정해야 해서 불편하고, 선택적으로 만들면 타입 추론이 불명확해집니다. 바로 이럴 때 필요한 것이 제네릭 기본값(Default Generic Type)입니다.

<T = DefaultType> 형태로 타입 매개변수에 기본값을 지정하면, 타입을 명시하지 않았을 때 기본값이 사용되고, 필요할 때만 다른 타입을 지정할 수 있습니다.

개요

간단히 말해서, 제네릭 기본값은 타입 매개변수에 기본 타입을 지정하여, 사용자가 타입을 명시하지 않아도 동작하도록 만드는 기능입니다. 일반 함수의 매개변수 기본값처럼, 제네릭도 기본값을 가질 수 있습니다.

<T = string>처럼 선언하면 T를 명시하지 않았을 때 자동으로 string이 사용됩니다. 이를 통해 가장 흔한 사용 케이스는 간편하게 만들고, 특수한 경우에만 타입을 지정하도록 설계할 수 있습니다.

예를 들어, 대부분 string 배열을 다루는 함수는 <T = string>으로 만들면 편리합니다. 기존에는 오버로드를 만들어 타입 있는 버전과 없는 버전을 따로 정의하거나, 항상 타입을 명시하도록 강제했다면, 이제는 기본값으로 편의성과 유연성을 동시에 제공할 수 있습니다.

제네릭 기본값의 핵심 특징은 첫째, 사용자 경험을 개선합니다. 가장 흔한 경우에는 타입을 생략할 수 있죠.

둘째, 타입 추론과 함께 작동합니다. 인자로부터 타입 추론이 가능하면 기본값보다 추론된 타입이 우선됩니다.

셋째, 여러 타입 매개변수가 있을 때 일부만 기본값을 가질 수 있습니다. 이러한 특징들이 라이브러리 API를 사용하기 쉽게 만듭니다.

코드 예제

// 제네릭 기본값 - data는 기본적으로 any
interface ApiResponse<T = any> {
  success: boolean;
  data: T;
  message: string;
}

// 타입 지정 안 하면 any 사용
const response1: ApiResponse = {
  success: true,
  data: { anything: "goes" }, // any 타입
  message: "OK"
};

// 타입 지정하면 해당 타입 사용
const response2: ApiResponse<User> = {
  success: true,
  data: { id: 1, name: "Kim" }, // User 타입
  message: "OK"
};

// 함수의 제네릭 기본값
function createArray<T = string>(length: number, value: T): T[] {
  return Array(length).fill(value);
}

// 타입 지정 안 하면 string으로 추론
const strings = createArray(3, "hello"); // string[]

// 타입 지정하면 해당 타입 사용
const numbers = createArray<number>(3, 42); // number[]

// 여러 타입 매개변수에 기본값
function merge<T = {}, U = T>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

설명

이것이 하는 일: <T = DefaultType> 형태로 제네릭 타입 매개변수에 기본값을 지정하면, 타입을 명시하지 않았을 때 자동으로 기본값이 사용되고, 타입을 명시하면 해당 타입이 사용됩니다. 첫 번째로, interface ApiResponse<T = any>에서 T의 기본값을 any로 설정합니다.

이제 ApiResponse를 사용할 때 타입 매개변수를 생략하면 자동으로 ApiResponse<any>가 되어, data의 타입이 any가 됩니다. 빠른 프로토타이핑이나 타입이 중요하지 않은 경우에 편리합니다.

그 다음으로, ApiResponse<User>처럼 타입을 명시하면 기본값은 무시되고 지정된 타입이 사용됩니다. 함수의 경우 createArray(3, "hello")처럼 호출하면 두 번째 인자 "hello"로부터 T가 string으로 추론되어, 기본값보다 추론이 우선됩니다.

하지만 추론이 불가능한 경우에는 기본값이 사용되죠. 마지막으로, <T = {}, U = T> 같은 형태로 여러 타입 매개변수에 기본값을 지정할 수 있습니다.

여기서 U의 기본값을 T로 설정하여, U를 지정하지 않으면 T와 같은 타입이 되도록 만듭니다. 타입 매개변수 간에도 의존 관계를 표현할 수 있는 것이죠.

여러분이 이 패턴을 사용하면 라이브러리나 유틸리티를 만들 때 사용자 친화적인 API를 제공할 수 있습니다. 간단한 사용 케이스에서는 타입을 생략하게 하고, 복잡한 케이스에서는 정확한 타입을 지정하게 하여, 단순함과 유연성을 모두 제공할 수 있습니다.

TypeScript의 많은 내장 타입들도 기본값을 활용합니다.

실전 팁

💡 기본값은 타입 매개변수 선언 순서의 마지막부터 설정해야 합니다. <T, U = string>은 가능하지만 <T = string, U>는 불가능합니다.

💡 제약 조건과 기본값을 함께 사용할 수 있습니다: <T extends object = {}> 형태로 "object여야 하고, 기본값은 빈 객체"라고 지정할 수 있습니다.

💡 기본값으로 복잡한 타입도 사용 가능합니다: <T = Record<string, unknown>> 같은 형태로 유틸리티 타입을 기본값으로 설정할 수 있습니다.

💡 타입 추론이 가능한 경우 기본값은 사용되지 않으므로, 함수에서는 기본값보다 추론을 우선 고려하여 설계하세요.

💡 실무에서는 API 클라이언트, 제네릭 컴포넌트, 유틸리티 라이브러리에서 기본값을 자주 사용합니다. 사용자가 간단한 경우 타입을 생략하게 하여 개발 경험을 개선할 수 있습니다.


#TypeScript#Generics#TypeSafety#AdvancedTypes#Constraints

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

마이크로서비스 배포 완벽 가이드

Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.

Application Load Balancer 완벽 가이드

AWS의 Application Load Balancer를 처음 배우는 개발자를 위한 실전 가이드입니다. ALB 생성부터 ECS 연동, 헬스 체크, HTTPS 설정까지 실무에 필요한 모든 내용을 다룹니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.

고객 상담 AI 시스템 완벽 구축 가이드

AWS Bedrock Agent와 Knowledge Base를 활용하여 실시간 고객 상담 AI 시스템을 구축하는 방법을 단계별로 학습합니다. RAG 기반 지식 검색부터 Guardrails 안전 장치, 프론트엔드 연동까지 실무에 바로 적용 가능한 완전한 시스템을 만들어봅니다.

에러 처리와 폴백 완벽 가이드

AWS API 호출 시 발생하는 에러를 처리하고 폴백 전략을 구현하는 방법을 다룹니다. ThrottlingException부터 서킷 브레이커 패턴까지, 실전에서 바로 활용할 수 있는 안정적인 에러 처리 기법을 배웁니다.

AWS Bedrock 인용과 출처 표시 완벽 가이드

AWS Bedrock의 Citation 기능을 활용하여 AI 응답의 신뢰도를 높이는 방법을 배웁니다. 출처 추출부터 UI 표시, 검증까지 실무에서 바로 사용할 수 있는 완전한 가이드입니다.

이전3/3
다음