타입스크립트의 숨겨진 힘: keyof, 조건부 타입, infer 해부하기

typescriptkeyofinfergenerics

1. 제네릭, 나는 어디까지 써봤을까?

타입스크립트에서 제네릭은 재사용 가능한 컴포넌트를 만드는데 필수적인 기능이다.

function getFirstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}
 
const firstNum = getFirstElement([1, 2, 3]);       // firstNum의 타입은 number
const firstStr = getFirstElement(["a", "b", "c"]); // firstStr의 타입은 string

이것만으로도 제네릭은 충분히 강력하다. 실제로 이런식으로 제네릭을 많이 사용하기도 한다. 하지만 이것은 타입스크립트 제네릭이라는 거대한 빙산의 일각에 불과하다.
그렇다면 이런 문제는 어떨까?
"어떤 객체든, 그 객체가 가진 키(key)를 이용해 특정 속성의 값을 안전하게 뽑아오는 함수를 만들려면 어떻게 해야 할까?"
아마 이렇게 시도해볼 수 있을 것이다.

function getProperty<T>(obj: T, key: string) {
  // ❌ 에러: Element implicitly has an 'any' type because expression of type 'string'
  // can't be used to index type 'T'.
  // No index signature with a parameter of type 'string' was found on type 'T'.
  return obj[key];
}

타입스크립트는 똑똑하게도 이 코드를 막는다. 제네릭 타입 T가 어떤 구조인지 전혀 모르는 상태에서, 아무 string 타입의 key로 객체에 접근하는 것은 매우 위험하다는 것을 알기 때문이다. (person 객체에 person['nonExistentKey']로 접근하는 상황을 막아주는 것이다.)

이 문제를 해결하기 위해, 타입스크립트가 숨겨둔 세 가지 강력한 도구를 꺼내야 한다. 바로 keyof, 조건부 타입, 그리고 infer이다.

2. keyof: 객체의 키를 타입으로 탐색하기

앞서 getProperty 함수를 만들려다 마주한 에러는 나에게 하나의 질문을 가져왔다.
"어떻게 하면 타입스크립트에게, 함수로 들어온 객체(T)와 그 객체의 키(key) 사이의 관계를 알려줄 수 있을까?"
이 문제의 해답을 찾다가 keyof라는 연산자를 발견했다.

keyof 연산자는 객체 타입을 받아서, 그 객체가 가진 모든 public 키의 이름을 문자열 리터럴 유니언 타입(string literal union type)으로 만들어주는 역할을 한다.

interface User {
  id: number;
  name: string;
  email: string;
}
 
// 'keyof User'는 'id' | 'name' | 'email' 이라는 유니언 타입과 같아졌다.
type UserKey = keyof User;
 
const key1: UserKey = 'name'; // OK
const key2: UserKey = 'id';   // OK
// const key3: UserKey = 'age'; // 에러: Type '"age"' is not assignable to type 'keyof User'.

이 keyof를 제네릭에 적용하면 앞서 막혔던 문제를 해결할 수 있었다. 함수의 두 번째 매개변수 key의 타입을 단순히 string이 아니라, K extends keyof T로 제약하는 것이 핵심이었다. 이 제네릭 제약은 'K는 T가 가진 키들 중 하나여야만 해'라는 규칙을 타입스크립트에게 알려주는 것이었다.

이제 getProperty 함수를 다시 작성해보자.

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
 
const user: User = {
  id: 1,
  name: 'John Doe',
  email: 'john.doe@example.com',
};
 
// 이제 함수는 user 객체와 그 객체의 유효한 키만 인자로 받는다.
const userName = getProperty(user, 'name');     // userName의 타입은 string으로 완벽하게 추론된다.
const userId = getProperty(user, 'id');         // userId의 타입은 number로 추론된다.
 
// getProperty(user, 'age'); // ❌ 에러: "age"는 'id'|'name'|'email' 타입에 할당할 수 없다.

반환 타입인 T[K]도 인상적이었다. 이는 **'Indexed Access Type'**으로, T라는 타입에서 K라는 키에 해당하는 값의 타입을 그대로 가져온다는 의미다.

이로써 타입 안정성을 지키면서 어떤 객체에서든 원하는 속성 값을 꺼내오는 재사용 가능한 함수를 완성했다.

네, 좋습니다. keyof를 통해 얻은 깨달음을 바탕으로, 그 다음 마주했던 궁금증과 해답을 정리하는 방식으로 세 번째 섹션을 작성해 보겠습니다.

3. 조건부 타입 (Conditional Types): 타입 세계의 if문

그렇다면,

"타입에 따라 다른 타입을 반환하게 만들 수는 없을까?"

라는 의문이 들 수 있다.

예를 들어, 제네릭 타입 T가 string이면 string[] 타입을, number이면 number[] 타입을 반환하는 식으로 말이다. 마치 자바스크립트의 if문처럼, 타입스크립트의 사용에서도 조건 분기가 필요하다.

이 해답이 바로 조건부 타입(Conditional Types)이다. 자바스크립트의 삼항 연산자와 닮아있어 직관적으로 이해하기 쉽다.

T extends U ? X : Y

이 문법은 '만약 T가 U에 할당 가능하다면(즉, T가 U의 서브타입이라면) X 타입을 사용하고, 그렇지 않으면 Y 타입을 사용하라'는 의미이다.

이해를 돕기 위해 간단한 타입을 작성해보자. T가 null 또는 undefined일 가능성이 있는지 확인하는 CheckNull<T> 타입이다.

type CheckNull<T> = T extends null | undefined ? true : false;
 
// 'hello'는 null이나 undefined가 아니므로, CheckNull<'hello'>는 false 타입이 된다.
type A = CheckNull<'hello'>; // type A = false
 
// null은 null | undefined에 해당하므로, CheckNull<null>은 true 타입이 된다.
type B = CheckNull<null>;     // type B = true

이 원리를 응용하면, 처음에 우리가 원했던 '타입에 따라 다른 배열 타입을 만드는' ToArray<T> 타입도 쉽게 만들 수 있다.

type ToArray<T> = T extends string ? string[] : never;
 
// ToArray<string>은 string[] 타입이 된다.
type StrArray = ToArray<string>;   // type StrArray = string[]
 
// ToArray<number>는 조건에 맞지 않아 never 타입이 된다.
// never는 '절대 발생하지 않는 값'을 의미하는 타입으로, 조건부 타입의 'else'에서 유효하지 않은 케이스를 처리할 때 유용하게 쓰인다.
type NumArray = ToArray<number>;   // type NumArray = never

조건부 타입은 제네릭의 활용도를 폭발적으로 높여준다. 이제 단순히 타입을 받아 전달하는 것을 넘어, 들어온 타입의 '상황'에 따라 동적으로 타입을 분기하고 결정하는, 훨씬 더 지능적인 타입을 설계할 수 있게 된 것이다.

4. infer: 타입 안에서 타입 추론하고 꺼내오기

앞선 keyof와 조건부 타입의 개념을 바탕으로, 이 모든 것을 아우르는 가장 강력한 도구인 infer에 대해 알아보자.

"타입의 형태만 보고, 그 타입의 내부에 있는 특정 부분만 뽑아올 수는 없을까?"

예를 들어, Promise<string> 타입에서 string 부분만 추출하거나, (name: string) => number 라는 함수 타입에서 반환 타입인 number만 꺼내오고 싶은 경우가 왕왕 있을 것이다.

이 문제를 해결해 줄 수 있는 것이 바로 infer 키워드 이다.

infer는 '추론하다'라는 뜻 그대로, 조건부 타입의 extends 절 안에서만 사용되어 특정 위치의 타입을 추론해 새로운 타입 변수캡처하는 역할을 한다. 이것은 타입을 분해하고 재조립하는 기능이다.

이해를 위해 타입스크립트에 내장된 ReturnType<T>을 직접 만들어보자. 목표는 함수 타입 T를 받아 그 함수의 반환 타입을 추출하는 것이다.

// T가 '...args'를 받아 'R'을 반환하는 함수 형태라면, 그 'R'을 추론해서 사용하고, 아니라면 any 타입을 쓴다.
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
  1. T extends (...args: any[]) => infer R: 먼저 T가 함수 타입인지 검사한다. 여기서 ...args: any[]는 '어떤 매개변수든 상관없다'는 의미다.
  2. infer R: 이 부분이 핵심이다. 타입스크립트는 T가 함수의 형태와 일치한다고 판단하면, 그 함수의 반환 타입 위치에 있는 타입을 추론해서 R이라는 새로운 타입 변수에 '담아둔다'.
  3. ? R : any: 만약 T가 함수가 맞아서 R을 성공적으로 추론했다면 R 타입을 최종 결과로 사용하고, T가 함수가 아니라면 any 타입을 사용한다.

이 타입이 어떻게 동작하는지 확인해 보자.

type MyFunc = (name: string) => number;
 
// MyReturnType<MyFunc>는 MyFunc의 반환 타입인 number를 성공적으로 추론해냈다.
type FuncReturn = MyReturnType<MyFunc>; // type FuncReturn = number
 
// string은 함수 타입이 아니므로 'any'를 반환했다.
type NotAFunc = MyReturnType<string>;   // type NotAFunc = any

keyof로 객체의 키를, 조건부 타입으로 타입의 흐름을, 그리고 infer로 타입의 특정 부분을 제어하는 법을 익힐 수 있다.

5. 결론

keyof로 시작해 조건부 타입을 거쳐 infer에 이르기까지, 타입스크립트의 제네릭을 깊이 탐구하는 과정을 정리해 보았다. 처음에는 단순히 타입을 받아 재사용하는 수준에 머물렀다면, 이제는 타입을 분해하고, 조건을 걸어 분기시키고, 원하는 부분만 추출하여 새로운 타입으로 재조립하는 과정을 조금씩 응용해 볼 수 있을 것 같다.

타입스크립트를 사용한다는 것은 단순히 자바스크립트에 타입을 추가하는 행위를 넘어, 코드의 구조를 더 깊이 고민하고 논리적인 설계를 증명하는 과정임을 다시금 느낀다. 앞으로도 타입 시스템을 회피하는 것이 아니라, 그 안에서 문제를 해결하고 더 견고하고 예측 가능한 코드를 작성하기 위한 노력이 필요할 것이다.