본문으로 건너뛰기

5.2 제네릭 제약

제약이 필요한 이유

제네릭은 강력하지만, 제한 없이 사용하면 타입 파라미터 안에서 할 수 있는 일이 매우 적습니다.

function getLength<T>(value: T): number {
return value.length; // Error: Property 'length' does not exist on type 'T'
}

T가 어떤 타입인지 모르므로 .length가 있다는 보장이 없습니다. 이때 제약(Constraint) 을 사용해 "T는 반드시 length 프로퍼티를 가진 타입이어야 한다"고 명시합니다.

function getLength<T extends { length: number }>(value: T): number {
return value.length; // OK
}

getLength("hello"); // 5 (string.length)
getLength([1, 2, 3]); // 3 (array.length)
getLength({ length: 10 }); // 10 (객체의 length)
getLength(42); // Error: number에는 length가 없음

extends 제약 기초

T extends U는 "T는 U의 서브타입이어야 한다"는 의미입니다. U가 가진 모든 프로퍼티를 T도 가지고 있어야 합니다.

// 기본 extends 제약
function printName<T extends { name: string }>(item: T): void {
console.log(item.name);
}

printName({ name: "Alice" }); // OK
printName({ name: "Bob", age: 30 }); // OK (추가 프로퍼티 허용)
printName({ age: 30 }); // Error: name 프로퍼티 없음

// 클래스/인터페이스로 제약
interface Serializable {
serialize(): string;
}

function saveToStorage<T extends Serializable>(item: T, key: string): void {
localStorage.setItem(key, item.serialize());
}

// 원시 타입으로 제약
function add<T extends number | string>(a: T, b: T): string {
return `${a} + ${b}`;
}

add(1, 2); // OK
add("a", "b"); // OK
add(1, "b"); // Error: string은 number에 할당 불가 (T = number로 추론 시)

제약의 계층적 적용

// 여러 프로퍼티를 동시에 제약
interface Identifiable {
id: number;
}

interface Timestamped {
createdAt: Date;
updatedAt: Date;
}

// 교차 타입으로 여러 인터페이스 동시 요구
function updateEntity<T extends Identifiable & Timestamped>(
entity: T,
updates: Partial<Omit<T, "id" | "createdAt">>
): T {
return {
...entity,
...updates,
updatedAt: new Date(),
};
}

interface User extends Identifiable, Timestamped {
name: string;
email: string;
}

const user: User = {
id: 1,
name: "Alice",
email: "alice@example.com",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
};

const updated = updateEntity(user, { name: "Alice Updated" });

keyof 제약

keyof T는 타입 T의 모든 키를 유니온 타입으로 반환합니다. K extends keyof T는 "K는 T의 키 중 하나여야 한다"는 제약입니다.

// keyof 기본
interface Person {
id: number;
name: string;
age: number;
email: string;
}

type PersonKeys = keyof Person; // "id" | "name" | "age" | "email"

// K extends keyof T: 객체 프로퍼티 안전 접근
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

const person: Person = { id: 1, name: "Alice", age: 30, email: "alice@example.com" };

const name = getProperty(person, "name"); // string
const age = getProperty(person, "age"); // number
const id = getProperty(person, "id"); // number

getProperty(person, "phone"); // Error: 'phone'은 Person의 키가 아님

// 타입 안전한 setter
function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): T {
return { ...obj, [key]: value };
}

const updated = setProperty(person, "name", "Bob"); // OK
const invalid = setProperty(person, "name", 42); // Error: string에 number 할당 불가

Mapped Types와 keyof 결합

// 객체의 모든 값을 변환
function mapValues<T extends object, U>(
obj: T,
fn: (value: T[keyof T], key: keyof T) => U
): Record<keyof T, U> {
const result = {} as Record<keyof T, U>;
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[key as keyof T] = fn(obj[key as keyof T], key as keyof T);
}
}
return result;
}

const person2 = { name: "Alice", age: 30 };
const stringified = mapValues(person2, (v) => String(v));
// { name: string, age: string }

// 특정 키만 선택적으로 업데이트
function pick<T extends object, 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 subset = pick(person, ["name", "email"]);
// { name: string, email: string }

조건부 타입과 제약 결합

// 제약 + 조건부 타입으로 정교한 타입 표현
type StringsOnly<T> = T extends string ? T : never;

type Test1 = StringsOnly<"hello" | 42 | "world" | boolean>;
// "hello" | "world"

// 제약이 있는 제네릭 + 조건부 타입
function processIfString<T>(
value: T
): T extends string ? string : never {
if (typeof value === "string") {
return value.toUpperCase() as T extends string ? string : never;
}
throw new Error("string이 아닙니다.");
}

// 조건부 타입으로 타입 변환
type Unwrap<T> = T extends Array<infer Item> ? Item : T;

type UnwrappedNumber = Unwrap<number[]>; // number
type UnwrappedString = Unwrap<string>; // string (배열이 아니므로 그대로)

제약 체이닝

여러 제약을 조합해 더 구체적인 타입 범위를 표현할 수 있습니다.

// object 제약: 원시 타입 제외
function cloneObject<T extends object>(obj: T): T {
return JSON.parse(JSON.stringify(obj)) as T;
}

cloneObject({ a: 1 }); // OK
cloneObject([1, 2, 3]); // OK (배열도 object)
cloneObject(42); // Error: number는 object가 아님
cloneObject("hello"); // Error: string은 object가 아님 (primitive)

// Record<string, unknown> 제약: 인덱스 시그니처 보장
function getKeys<T extends Record<string, unknown>>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>;
}

const keys = getKeys({ a: 1, b: 2, c: 3 }); // ("a" | "b" | "c")[]

// 복합 제약 체이닝
interface HasId {
id: number;
}

interface HasName {
name: string;
}

// 여러 제약을 & 로 연결
function processItem<T extends HasId & HasName & { createdAt: Date }>(
item: T
): string {
return `[${item.id}] ${item.name} (${item.createdAt.toISOString()})`;
}

// 제약의 제약: 타입 파라미터끼리 제약
function copyFrom<Target extends Source, Source>(
source: Source,
factory: () => Target
): Target {
return Object.assign(factory(), source);
}

Record와 제약 결합

// 동적 키를 가진 객체 처리
function transformRecord<
K extends string,
V,
R
>(
record: Record<K, V>,
transform: (value: V, key: K) => R
): Record<K, R> {
const result = {} as Record<K, R>;
for (const key in record) {
if (Object.prototype.hasOwnProperty.call(record, key)) {
result[key] = transform(record[key], key);
}
}
return result;
}

const prices = { apple: 1.5, banana: 0.5, cherry: 3.0 };
const discounted = transformRecord(prices, (price) => price * 0.9);
// { apple: number, banana: number, cherry: number }

extends vs implements 제약 차이

TypeScript의 제네릭 제약에는 extends만 사용합니다. implements는 클래스 선언에서만 사용합니다.

// 올바른 제약 표현
interface Printable {
print(): void;
}

// implements는 클래스 선언에서만 사용
class Document implements Printable {
print(): void {
console.log("문서 출력");
}
}

// 제네릭 제약에서는 extends 사용 (인터페이스도 extends로 제약)
function printAll<T extends Printable>(items: T[]): void {
items.forEach((item) => item.print());
}

// T extends Printable: "T는 Printable 인터페이스를 충족하는 타입"
// 인스턴스인지 여부와 관계없이 구조적으로 만족하면 됨
const obj = {
print() { console.log("익명 객체 출력"); }
};

printAll([obj]); // OK — 구조적으로 Printable 충족
printAll([new Document()]); // OK — Document는 Printable implements
// 추상 클래스 제약
abstract class Animal {
abstract speak(): string;
move(): void {
console.log(`${this.speak()} 하며 이동`);
}
}

// 추상 클래스도 extends로 제약
function makeNoise<T extends Animal>(animal: T): string {
return animal.speak();
}

class Dog extends Animal {
speak(): string { return "멍멍"; }
}

class Cat extends Animal {
speak(): string { return "야옹"; }
}

makeNoise(new Dog()); // "멍멍"
makeNoise(new Cat()); // "야옹"

실전 예제

getProperty — 타입 안전한 프로퍼티 접근

// 중첩 객체의 안전한 접근 (1단계)
function getProperty<T extends object, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key];
}

// 중첩 객체의 안전한 접근 (2단계 — 타입 추론 포함)
function getNestedProperty<
T extends object,
K1 extends keyof T,
K2 extends keyof T[K1]
>(obj: T, key1: K1, key2: K2): T[K1][K2] {
return (obj[key1] as T[K1])[key2];
}

interface Config {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
server: {
host: string;
port: number;
};
}

const config: Config = {
database: {
host: "localhost",
port: 5432,
credentials: {
username: "admin",
password: "secret",
},
},
server: {
host: "0.0.0.0",
port: 8080,
},
};

const dbHost = getNestedProperty(config, "database", "host"); // string
const serverPort = getNestedProperty(config, "server", "port"); // number

정렬 가능한 배열

// Comparable 제약으로 정렬 가능성 보장
interface Comparable<T> {
compareTo(other: T): number;
}

function sortArray<T extends Comparable<T>>(arr: T[]): T[] {
return [...arr].sort((a, b) => a.compareTo(b));
}

class Temperature implements Comparable<Temperature> {
constructor(private celsius: number) {}

compareTo(other: Temperature): number {
return this.celsius - other.celsius;
}

toString(): string {
return `${this.celsius}°C`;
}
}

const temperatures = [
new Temperature(30),
new Temperature(15),
new Temperature(25),
new Temperature(5),
];

const sorted = sortArray(temperatures);
sorted.forEach((t) => console.log(t.toString()));
// 5°C, 15°C, 25°C, 30°C

// 원시 타입 정렬 — 제네릭 + extends로 타입 안전하게
function sortPrimitive<T extends string | number | bigint>(arr: T[]): T[] {
return [...arr].sort((a, b) => {
if (a < b) return -1;
if (a > b) return 1;
return 0;
});
}

sortPrimitive([3, 1, 4, 1, 5, 9]); // number[]
sortPrimitive(["banana", "apple", "cherry"]); // string[]
sortPrimitive([{}, {}]); // Error: object는 string | number | bigint가 아님

깊은 객체 접근 (옵셔널 체이닝 결합)

// 안전한 깊은 경로 접근
type DeepGet<T, Path extends string> =
Path extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? DeepGet<T[Key], Rest>
: never
: Path extends keyof T
? T[Path]
: never;

// 실용적인 대안: 런타임 안전성과 타입 안전성 결합
function safeGet<T extends object>(
obj: T,
path: string,
defaultValue?: unknown
): unknown {
const keys = path.split(".");
let current: unknown = obj;

for (const key of keys) {
if (current === null || current === undefined) {
return defaultValue;
}
if (typeof current !== "object") {
return defaultValue;
}
current = (current as Record<string, unknown>)[key];
}

return current ?? defaultValue;
}

const deepObj = {
user: {
profile: {
name: "Alice",
address: {
city: "Seoul",
},
},
},
};

console.log(safeGet(deepObj, "user.profile.name")); // "Alice"
console.log(safeGet(deepObj, "user.profile.address.city")); // "Seoul"
console.log(safeGet(deepObj, "user.missing.key", "N/A")); // "N/A"

고수 팁

제약 강도와 재사용성의 균형

// 너무 엄격한 제약 — 재사용성 낮음
function processUser<T extends {
id: number;
name: string;
email: string;
age: number;
role: "admin" | "user";
}>(user: T): string {
return `${user.name} (${user.email})`;
}
// 문제: name과 email만 필요한데 너무 많은 프로퍼티를 요구

// 적절한 제약 — 필요한 것만 요구
function processUser2<T extends { name: string; email: string }>(user: T): string {
return `${user.name} (${user.email})`;
}
// 어떤 객체든 name과 email만 있으면 사용 가능

// 너무 느슨한 제약 — 타입 안전성 손실
function processAny<T>(item: T): string {
return String(item); // item의 구조를 모름 — toString 이외 아무것도 못함
}

// 균형 잡힌 제약: 실제로 사용하는 프로퍼티만 요구
function formatEntity<T extends { id: number | string; toString(): string }>(
entity: T
): string {
return `[${entity.id}] ${entity.toString()}`;
}

조건부 타입과 제약을 활용한 정밀 타입 추론

// 배열이면 요소 타입, 아니면 그대로
type ElementOf<T> = T extends (infer E)[] ? E : T;

function first<T>(collection: T): ElementOf<T> {
if (Array.isArray(collection)) {
return collection[0] as ElementOf<T>;
}
return collection as ElementOf<T>;
}

const n = first([1, 2, 3]); // number
const s = first("hello"); // string (배열이 아님)

// 객체 타입에서 특정 값 타입만 필터링
type KeysWithValueType<T, V> = {
[K in keyof T]: T[K] extends V ? K : never
}[keyof T];

interface Mixed {
id: number;
name: string;
active: boolean;
score: number;
label: string;
}

type StringKeys = KeysWithValueType<Mixed, string>; // "name" | "label"
type NumberKeys = KeysWithValueType<Mixed, number>; // "id" | "score"

function getStringProp<T extends object>(
obj: T,
key: KeysWithValueType<T, string>
): string {
return obj[key] as string;
}

const mixed: Mixed = { id: 1, name: "Alice", active: true, score: 95, label: "A" };
getStringProp(mixed, "name"); // OK
getStringProp(mixed, "label"); // OK
getStringProp(mixed, "id"); // Error: id는 number

타입 파라미터 전파 최소화

// 나쁜 패턴: 불필요하게 많은 타입 파라미터
function badMerge<
T extends object,
U extends object,
K1 extends keyof T,
K2 extends keyof U
>(obj1: T, obj2: U, key1: K1, key2: K2): T[K1] & U[K2] {
return { ...(obj1[key1] as object), ...(obj2[key2] as object) } as T[K1] & U[K2];
}

// 좋은 패턴: 필요한 제약만 최소로
function goodMerge<T extends object, U extends object>(
obj1: T,
obj2: U
): T & U {
return { ...obj1, ...obj2 };
}

정리 표

제약 패턴문법의미
기본 extendsT extends UT는 U의 서브타입
프로퍼티 제약T extends { name: string }T는 name 프로퍼티를 가짐
keyof 제약K extends keyof TK는 T의 키 중 하나
object 제약T extends objectT는 원시 타입이 아닌 객체
복합 제약T extends A & BT는 A와 B 모두 충족
상호 제약U extends TU는 T의 서브타입
Record 제약T extends Record<string, unknown>T는 인덱스 시그니처를 가진 객체
배열 제약T extends unknown[]T는 배열 타입

다음 장에서는...

5.3절에서는 내장 유틸리티 타입(Built-in Utility Types) 을 다룹니다. TypeScript가 기본으로 제공하는 Partial, Required, Readonly, Pick, Omit, Record 등의 유틸리티 타입을 살펴보고, 각각을 직접 구현해 원리를 이해합니다. 또한 유틸리티 타입을 조합해 복잡한 DTO 변환 패턴과 DeepPartial을 구현하는 방법도 배웁니다.