Menu

TypeScript 제네릭 제약: extends와 keyof T

제네릭 제약 T extends Something은 타입 매개변수가 받는 타입을 제한하고, 함수가 제약이 보장하는 것을 쓸 수 있게 합니다. 객체 모양과 인터페이스에 대한 extends, 안전한 속성 접근을 위한 K extends keyof T, 원시 타입에 대한 제약, 그리고 만나게 될 오류를 다룹니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

제네릭 제약은 타입 매개변수가 무엇이 될 수 있는지 제한합니다. <T extends { length: number }>는 숫자 length를 가진 타입(문자열, 배열, 직접 만든 객체)만 받고, 그 약속 덕분에 함수는 .length를 읽을 수 있습니다.

반환 타입은 { length: number }가 아니라 여전히 T입니다. 숫자 배열 두 개를 넘기면 number[]가 돌아오고, 객체는 tag를 유지합니다. 제약은 최소한을 기술할 뿐이고, 호출하는 쪽의 타입은 보존됩니다.

제약이 필요한 이유

제네릭 함수 안에서 제약 없는 T는 무엇이든 될 수 있습니다. 문자열, 숫자, null, 함수까지요. 그래서 TypeScript는 모든 타입에 동작하는 것만 허용하는데, 그런 것은 거의 없습니다.

컴파일러는 이렇게 출력합니다.

index.ts(3,12): error TS2339: Property 'length' does not exist on type 'T'.
index.ts(3,24): error TS2339: Property 'length' does not exist on type 'T'.

해결책은 필요한 것을 말하는 것입니다: <T extends { length: number }>. 제약은 "어떤 타입이든"을 "이것을 가진 어떤 타입이든"으로 바꾸며, 대개 제네릭 함수가 뜻하는 것이 그것입니다.

인터페이스로 제약하기

제약은 이미 있는 인터페이스나 타입 별칭을 포함해 어떤 타입이든 될 수 있습니다. 그러면 함수는 적어도 그 멤버를 가진 무엇과도 동작하고, 여전히 호출하는 쪽의 전체 타입을 반환합니다.

평범한 매개변수 타입과 비교해 보세요. function byId(items: HasId[], id: number): HasId | undefined는 같은 배열을 받지만 결과는 HasId뿐이므로 found.name은 오류일 것입니다. 제네릭 버전은 전체 타입을 그대로 전달합니다.

K extends keyof T: 안전한 속성 접근

가장 흔한 제약은 두 타입 매개변수를 관계 짓습니다. K extends keyof T는 K가 T의 속성 이름 중 하나여야 한다는 뜻이고, T[K]는 그 속성의 타입입니다.

철자가 틀리거나 없는 키는 컴파일 오류이고, 반환 타입은 키를 따라갑니다. "year"는 number를, "title"은 string을 줍니다. 인덱스 시그니처와 함께일 때의 동작을 포함해 keyof 자체는 keyof 페이지에서 다룹니다.

원시 타입으로 제약하기

제약은 원시 타입이나 그 유니언일 수도 있습니다. 유용한 부수 효과가 있습니다. T extends string(또는 number)이면 TypeScript가 인수를 넓히는 대신 리터럴 타입으로 추론합니다.

제약이 없으면 createEvent("user.saved")는 { type: string }을 반환합니다. TypeScript가 T에 대해 추론한 리터럴을 string으로 넓히기 때문입니다. T extends string이면 리터럴이 유지되며, 타입이 있는 이벤트와 라우팅 헬퍼가 평범한 문자열 인수에서 정확한 이름을 얻는 방법이 이것입니다.

흔한 오류

제약에만 맞는 것을 반환하기. T를 반환하는 함수는 제약에 맞는 아무 값이 아니라 T를 반환해야 합니다.

interface HasId {
  id: number;
}

function reset<T extends HasId>(item: T): T {
  return { id: item.id };
}
// error TS2322: Type '{ id: number; }' is not assignable to type 'T'.
//   '{ id: number; }' is assignable to the constraint of type 'T', but 'T' could be
//   instantiated with a different subtype of constraint 'HasId'.

T가 { id: number; name: string }이라면 새 객체에는 name이 없으므로 T가 아닙니다. 모든 속성을 유지하는 입력의 펼침 return { ...item, id: 0 };을 반환하거나, 그것만 만든다면 반환 타입을 HasId로 선언하세요.

제약을 깨는 타입 인수 넘기기. 명시적 타입 인수도 검사됩니다. function scale<T extends number>(x: T)에서 scale<string>("2") 호출은 Type 'string' does not satisfy the constraint 'number'.(TS2344)로 실패합니다.

제네릭이 필요 없는데 제약하기. T가 매개변수에만 나온다면 function print<T extends HasId>(item: T): void는 문법만 늘어난 function print(item: HasId): void일 뿐입니다. 제약은 T가 반환 타입이나 다른 매개변수에도 나올 때 의미가 있습니다.

기본값이 있는 제약

타입 매개변수는 제약과 기본값을 둘 다 가질 수 있습니다. 기본값은 제약을 만족해야 합니다.

interface Store<TState extends object = Record<string, unknown>> {
  get(): TState;
  set(next: Partial<TState>): void;
}

type AnyStore = Store;                              // Store<Record<string, unknown>>
type CounterStore = Store<{ count: number }>;       // fine
type BadStore = Store<number>;
// error TS2344: Type 'number' does not satisfy the constraint 'object'.

같은 extends 키워드는 조건부 타입 T extends string ? A : B에도 나오는데, 거기서는 제한이 아니라 검사입니다. 그 형태는 조건부 타입 페이지에서 다룹니다.

자주 묻는 질문

TypeScript 제네릭에서 T extends는 무슨 뜻인가요?

<T extends Constraint>는 T가 Constraint에 대입 가능한 어떤 타입이든 될 수 있다는 뜻입니다. 호출하는 쪽은 그런 타입만 넘길 수 있고, 함수 안에서는 제약이 보장하는 모든 것을 쓸 수 있습니다. <T extends { length: number }>는 문자열, 배열, 숫자 length를 가진 모든 객체를 받고, value.length를 읽게 해 줍니다.

K extends keyof T는 무슨 뜻인가요?

K가 T의 속성 이름 중 하나여야 한다는 뜻입니다. 인덱스 접근 타입 T[K]와 함께 쓰면 속성 접근에 정확한 타입을 붙입니다. function get<T, K extends keyof T>(obj: T, key: K): T[K]는 obj의 실제 키만 받고, 반환 타입은 그 속성의 타입입니다.

"Property does not exist on type T"는 왜 나나요?

제약 없는 T는 number나 null을 포함해 어떤 타입이든 될 수 있으므로, TypeScript는 타입 특정적인 것을 아무것도 허용하지 않습니다(TS2339). 그 속성을 약속하는 제약 <T extends { name: string }>을 더하거나, 이미 있는 인터페이스로 제약하세요: <T extends User>.

"T could be instantiated with a different subtype of constraint"는 무슨 뜻인가요?

오류 TS2322입니다. 함수는 T를 반환한다고 약속하는데, 제약에만 맞는 값을 반환했다는 뜻입니다. T가 { id: number; name: string }이라면 새로 만든 { id: 1 }은 T가 아닙니다. 받은 값(또는 그것을 펼친 값, 그에 맞게 타입을 지정해서)을 반환하거나, 반환 타입을 제약으로 바꾸세요.

제네릭의 extends와 클래스나 인터페이스의 extends는 어떻게 다른가요?

같은 키워드, 관련된 개념입니다. class Dog extends Animal과 interface B extends A에서는 상속을 선언합니다. <T extends A>에서는 제약을 선언합니다. T는 A에 대입 가능해야 합니다. 조건부 타입 T extends A ? X : Y에서는 두 타입 중 하나를 고르는 검사입니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기