본문으로 건너뛰기
Advertisement

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을 구현하는 방법도 배웁니다.

Advertisement