제네릭 제약 — extends와 keyof

TypeScript 제네릭의 extends 제약, keyof 연산자, 인덱스 접근 타입 T[K], 생성자 제약, 재귀 제약을 예제 중심으로 완전히 정리합니다. 과도한 제약이 오히려 유연성을 해치는 경우도 다룹니다.

· 5 min read · PALDYN Team

지난 글에서 제네릭의 기본 개념과 타입 매개변수 추론을 살펴봤다. 이번에는 **제네릭 제약(Generic Constraints)**을 다룬다. 제약은 타입 매개변수 T가 가질 수 있는 타입의 범위를 한정하여, T에서 특정 속성이나 메서드를 안전하게 사용할 수 있게 한다.

extends 제약 — 기본

extends 키워드로 T가 반드시 특정 구조를 가지도록 강제한다.

extends 제약 — T의 범위를 한정한다

// T는 name: string을 가져야 한다
function greet<T extends { name: string }>(entity: T): string {
  return `Hello, ${entity.name}`;
}

greet({ name: "Alice", age: 30 }); // ✅ 추가 속성 허용
greet({ name: "Bob" });            // ✅
greet({ age: 25 });                // TS2345 ❌ name 없음
greet("Alice");                    // TS2345 ❌ string에 name 없음

extends에 지정한 타입은 최소 조건이다. T가 그 구조를 충족하는 한 추가 속성이 있어도 된다.

keyof — 객체 키를 타입으로

keyof T는 타입 T의 모든 키를 유니언으로 반환한다.

interface Config {
  host:    string;
  port:    number;
  secure:  boolean;
}

type ConfigKey = keyof Config;
// "host" | "port" | "secure"

// keyof typeof — 값 객체의 키 추출
const STATUS = { active: 1, inactive: 0, pending: 2 } as const;
type StatusKey = keyof typeof STATUS;
// "active" | "inactive" | "pending"

keyof는 제네릭과 결합했을 때 특히 강력해진다.

T[K] — 인덱스 접근 타입

T[K]는 타입 T에서 키 K에 해당하는 값의 타입을 추출한다.

type Age = Config["port"];    // number
type AllValues = Config[keyof Config]; // string | number | boolean

// 배열 요소 타입 추출
type Element = string[][number]; // string

K extends keyof TT[K]를 조합하면 런타임에서나 가능했던 동적 접근을 타입 안전하게 만들 수 있다.

실전 패턴

제약 패턴 — 실전 활용

// 특정 타입의 키만 허용하는 pluck
function pluck<T, K extends keyof T>(arr: T[], key: K): T[K][] {
  return arr.map(item => item[key]);
}

const users = [
  { id: 1, name: "Alice", role: "admin" },
  { id: 2, name: "Bob",   role: "user"  },
];

const names = pluck(users, "name"); // string[]
const ids   = pluck(users, "id");   // number[]
pluck(users, "email");              // TS2345 ❌ 존재하지 않는 키

조건부 extends — 타입 수준의 분기

extends는 조건부 타입에서도 사용된다. 두 문맥을 구분하는 것이 중요하다.

// 제약의 extends — T는 반드시 U를 충족
function fn<T extends string>(val: T) { ... }

// 조건부 타입의 extends — T가 U를 충족하면 A, 아니면 B
type IsString<T> = T extends string ? "yes" : "no";
type R1 = IsString<string>;  // "yes"
type R2 = IsString<number>;  // "no"

제약의 함정 — 과도한 제약

제약이 항상 좋은 것은 아니다. 지나치게 구체적인 제약은 함수의 유연성을 떨어뜨린다.

// 과도한 제약 — 배열 타입 전체를 요구
function firstElement<T extends any[]>(arr: T): T[0] {
  return arr[0];
}

// 더 나은 버전 — 요소 타입만 매개변수화
function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}

제약은 “이 함수 내에서 T의 어떤 기능을 사용해야 하는가”라는 질문에서 출발해야 한다. 함수 본문에서 사용하지 않는 속성을 제약에 포함하면 불필요하게 호출 범위가 좁아진다.

복합 제약 패턴

// 여러 인터페이스 동시 충족
function persist<T extends Identifiable & Timestamped>(
  entity: T
): Promise<T> {
  // ...
}

interface Identifiable { id: number }
interface Timestamped  { createdAt: Date; updatedAt: Date }

// 클래스 제약 — 특정 클래스의 하위 클래스만 허용
function process<T extends Error>(err: T): string {
  return `[${err.name}] ${err.message}`;
}

process(new TypeError("bad type")); // ✅
process(new Error("generic"));      // ✅
process("not an error");            // TS2345 ❌

인터섹션 &으로 여러 제약을 동시에 적용할 수 있다.


지난 글: 제네릭 기초 — 재사용 가능한 타입 추상화

다음 글: 조건부 타입 — 타입 수준의 분기 처리


읽어주셔서 감사합니다. 😊