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 };
}
정리 표
| 제약 패턴 | 문법 | 의미 |
|---|---|---|
| 기본 extends | T extends U | T는 U의 서브타입 |
| 프로퍼티 제약 | T extends { name: string } | T는 name 프로퍼티를 가짐 |
| keyof 제약 | K extends keyof T | K는 T의 키 중 하나 |
| object 제약 | T extends object | T는 원시 타입이 아닌 객체 |
| 복합 제약 | T extends A & B | T는 A와 B 모두 충족 |
| 상호 제약 | U extends T | U는 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을 구현하는 방법도 배웁니다.