iBetter Books
수정

제네릭에 제약 걸기

클럽 입장을 생각해봅시다. "아무나 입장 불가 — 회원 카드 소지자만 입장 가능"이라는 규칙이 있습니다. 모든 사람을 받지는 않지만, 조건만 맞으면 누구든 됩니다. 학생이든 직장인이든 회원 카드가 있으면 들어올 수 있습니다.

제네릭의 extends 키워드가 이 역할입니다. "어떤 타입이든"이 아니라 "이 조건을 갖춘 타입이든"으로 좁힙니다.

왜 제약이 필요한가

제네릭 함수 안에서 타입 매개변수 T를 쓸 때 문제가 생깁니다. TypeScript는 T가 뭔지 모르므로, T가 가진 속성이나 메서드를 쓸 수 없습니다.

// 새 파일: constraint-problem.tsfunction getLength<T>(value: T): number {  return value.length;  // 오류: 'T' 형식에 'length' 속성이 없습니다.}

Tstring이라면 .length가 있지만, number라면 없습니다. TypeScript는 안전을 위해 오류를 냅니다.

해결책은 두 가지입니다. T.length가 있는 타입으로 제한하거나, 아예 제네릭을 안 쓰는 것입니다. 여기서 extends가 등장합니다.

extends로 타입 제한하기

T extends SomeType은 "TSomeType을 만족하는 타입이어야 한다"는 제약입니다.

// 새 파일: basic-constraint.ts// { length: number }를 가진 타입만 받습니다function getLength<T extends { length: number }>(value: T): number {  return value.length;}console.log(getLength("hello"));        // 5 — string은 length가 있습니다console.log(getLength([1, 2, 3]));      // 3 — 배열도 length가 있습니다console.log(getLength({ length: 10 })); // 10 — 명시적으로 length를 가진 객체도 됩니다// 오류 — number에는 length가 없습니다// getLength(42);   // 오류: 'number' 형식의 인수는 '{ length: number }' 형식의 매개 변수에 할당할 수 없습니다.

T extends { length: number } 덕분에 T는 최소한 length: number를 가진 타입이어야 합니다. stringlength가 있으니 됩니다. 배열도 됩니다. number는 없으니 거부됩니다.

인터페이스로 제약 표현하기

인터페이스를 제약 조건으로 쓸 수 있습니다. 더 명확하고 재사용하기 좋습니다.

// 새 파일: interface-constraint.tsinterface HasName {  name: string;}function greet<T extends HasName>(item: T): string {  return `안녕하세요, ${item.name}님!`;}interface User {  name: string;  age: number;}interface Product {  name: string;  price: number;}const user: User = { name: "Alice", age: 30 };const product: Product = { name: "노트북", price: 1500000 };console.log(greet(user));    // 안녕하세요, Alice님!console.log(greet(product)); // 안녕하세요, 노트북님!// name이 없는 타입은 오류가 납니다// greet({ age: 30 });   // 오류// greet(42);            // 오류

UserProduct 둘 다 name: string을 가지고 있으므로 HasName을 만족합니다. 서로 다른 타입이지만 공통 조건을 갖춘 덕분에 같은 함수를 쓸 수 있습니다.

THasName을 확장하므로, 반환값이나 함수 안에서 T 타입의 값에는 HasName에 정의된 속성들을 안전하게 쓸 수 있습니다.

keyof와 조합하기

keyof는 객체 타입의 키를 유니온 타입으로 만들어주는 연산자입니다. 제네릭과 함께 쓰면 객체 속성에 안전하게 접근할 수 있습니다.

// 새 파일: keyof-intro.tsinterface Config {  host: string;  port: number;  debug: boolean;}type ConfigKey = keyof Config;// ConfigKey의 타입은 "host" | "port" | "debug"const key1: ConfigKey = "host";    // 가능const key2: ConfigKey = "port";    // 가능// const key3: ConfigKey = "url";  // 오류 — "url"은 Config의 키가 아닙니다

이제 제네릭과 조합합니다.

// 새 파일: keyof-generic.ts// 객체 T에서 키 K에 해당하는 값을 반환합니다// K extends keyof T — K는 T의 키 중 하나여야 합니다function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {  return obj[key];}const user = {  name: "Alice",  age: 30,  email: "[email protected]",};const name = getProperty(user, "name");// T = { name: string; age: number; email: string }// K = "name"// 반환 타입 T[K] = stringconst age = getProperty(user, "age");// K = "age"// 반환 타입 T[K] = numberconsole.log(name.toUpperCase()); // ALICEconsole.log(age.toFixed(0));     // 30// 없는 키는 오류가 납니다// getProperty(user, "phone"); // 오류: '"phone"'은(는) 'user'의 키가 아닙니다.

K extends keyof T는 "K는 T의 키 중 하나"라는 제약입니다. T[K]는 "T에서 K라는 키의 값 타입"입니다. 이 덕분에 getProperty(user, "name")의 반환 타입이 string으로 정확하게 추론됩니다. 없는 키를 넘기면 컴파일 오류가 납니다.

여러 타입 매개변수에 제약 걸기

타입 매개변수끼리 관계를 지정할 수도 있습니다.

// 새 파일: multi-constraint.ts// T는 어떤 객체여도 됩니다// K는 T의 키 중 하나여야 합니다function pluck<T, K extends keyof T>(arr: T[], key: K): T[K][] {  return arr.map((item) => item[key]);}interface Person {  name: string;  age: number;  city: string;}const people: Person[] = [  { name: "Alice", age: 30, city: "서울" },  { name: "Bob", age: 25, city: "부산" },  { name: "Carol", age: 35, city: "대전" },];const names = pluck(people, "name");// 반환 타입은 string[]console.log(names); // ["Alice", "Bob", "Carol"]const ages = pluck(people, "age");// 반환 타입은 number[]console.log(ages);  // [30, 25, 35]const cities = pluck(people, "city");// 반환 타입은 string[]console.log(cities); // ["서울", "부산", "대전"]// 없는 키는 컴파일 오류// pluck(people, "email"); // 오류

pluck 함수는 JavaScript에서 유명한 유틸 함수입니다. 배열에서 특정 속성만 추출합니다. 제네릭 제약 덕분에 존재하지 않는 키를 넘기면 컴파일 단계에서 오류를 잡아줍니다.

제약이 있어도 T의 특성은 유지된다

extends로 제약을 걸어도 T 자체의 특성은 유지됩니다. THasName을 확장한다면, T 타입의 값은 HasName의 속성 외에도 T가 가진 모든 속성을 가집니다.

// 새 파일: constraint-preserves-type.tsinterface HasId {  id: number;}function clone<T extends HasId>(item: T): T {  return { ...item };}interface User {  id: number;  name: string;  age: number;}const original: User = { id: 1, name: "Alice", age: 30 };const copy = clone(original);// copy의 타입은 User — HasId가 아닙니다console.log(copy.id);    // 1console.log(copy.name);  // Alice — User의 속성도 유지됩니다console.log(copy.age);   // 30

clone 함수의 반환 타입은 HasId가 아니라 T입니다. TUser라면 반환 타입도 User입니다. HasId의 속성만 가진 것으로 좁혀지지 않습니다.

실전 패턴 — 최솟값 찾기

제약을 활용한 실용적인 예제입니다.

// 새 파일: constraint-practical.ts// 비교 가능한 타입 — length 속성이 있거나 숫자인 경우function longest<T extends { length: number }>(a: T, b: T): T {  return a.length >= b.length ? a : b;}const longerArray = longest([1, 2, 3], [4, 5]);// T = number[]// 반환 타입은 number[]console.log(longerArray); // [1, 2, 3]const longerString = longest("typescript", "js");// T = string// 반환 타입은 stringconsole.log(longerString); // "typescript"// 숫자는 length가 없어서 오류가 납니다// longest(10, 20); // 오류

기본 타입으로 제약하기

string, number 같은 기본 타입으로도 제약할 수 있습니다.

// 새 파일: primitive-constraint.ts// T는 string 또는 number 중 하나여야 합니다function displayValue<T extends string | number>(value: T): string {  return `값: ${value}`;}console.log(displayValue(42));       // 값: 42console.log(displayValue("hello"));  // 값: hello// displayValue(true);   // 오류 — boolean은 string | number가 아닙니다// displayValue([]);     // 오류 — 배열도 아닙니다

클럽 문 앞의 경비원처럼, extends는 제네릭의 입구를 지킵니다. 아무나 들어오지 못합니다. 하지만 조건만 맞으면 어떤 타입이든 환영합니다.

keyof와 조합하면 더 강력해집니다. 존재하지 않는 속성 키를 넘기는 실수를 컴파일 단계에서 잡아냅니다. 런타임까지 기다릴 필요가 없습니다.

다음 장에서는 TypeScript가 기본으로 제공하는 유틸리티 타입을 배웁니다. Partial, Required, Pick, Omit — 이것들도 모두 제네릭으로 만들어진 타입입니다.