1. 프롤로그 - "any 지옥"에서 살아남기
타입스크립트를 처음 도입했을 때, 나는 완전히 잘못된 방식으로 시작했다. 모든 곳에 any를 뿌렸다. 왜냐하면 빨간 줄이 너무 많이 나왔고, 빨리 기능을 만들어야 했기 때문이다.
function getData(data: any): any {
return data;
}
function processUser(user: any) {
console.log(user.nmae); // 오타지만 에러 안 남
return user;
}
첫 주는 편했다. 빨간 줄이 다 사라졌으니까. 그런데 일주일 뒤, 프로덕션에서 앱이 터졌다. user.nmae가 undefined라는 에러였다. name을 nmae로 오타를 냈는데, any 때문에 타입스크립트가 이걸 잡아주지 못한 것이었다.
그때 이해했다. any는 타입스크립트의 보호막을 끄는 스위치라는 것을. any를 쓰면 타입스크립트를 쓰는 의미가 없다. 그냥 JavaScript를 쓰는 거랑 똑같다. 오히려 타입스크립트의 오버헤드만 더 생긴다.
하지만 현실적으로 "어떤 타입이든 받아야 하는" 상황은 있다. API 응답을 처리하는 래퍼 함수라거나, 데이터를 캐싱하는 유틸리티라거나. 이럴 때 any 없이 어떻게 하냐고?
제네릭(Generics)을 쓰면 된다. 타입 안전성을 유지하면서도 유연하게 여러 타입을 다룰 수 있다. 처음엔 어려웠지만, 이제는 제네릭 없이는 타입스크립트를 못 쓸 것 같다. 그만큼 강력하다.
2. "T가 뭔데?" - 제네릭과의 첫 만남
내가 처음 제네릭을 본 건 React 공식 문서에서였다.
function useState<T>(initialValue: T): [T, (value: T) => void] {
// ...
}
"T가 뭔데?" 이게 내 첫 반응이었다. 꺾쇠 괄호(<>)가 낯설었고, T라는 알파벳 하나가 뭘 의미하는지 몰랐다. 처음엔 수학 공식 같아서 겁부터 났다.
그런데 코드를 계속 읽다 보니, 패턴이 보였다. useState<number>(0)이라고 쓰면 T는 number가 되는 거였다. useState<string>("")이라고 쓰면 T는 string이 되는 거였다.
결국 이거였다. 제네릭은 "타입을 파라미터처럼 넘기는 것"이었다. 함수를 호출할 때 값을 인자로 넘기듯이, 타입을 인자로 넘기는 개념. 그러니까 제네릭은 "타입을 위한 함수" 같은 거였다.
붕어빵 틀 비유
이 개념이 와닿았던 건 붕어빵 틀 비유를 봤을 때였다.
- 틀(Generic):
Box<T>는 붕어빵 틀이다. - 재료(T):
T는 붕어빵에 들어갈 재료다. 팥이 될 수도 있고, 슈크림이 될 수도 있다. - 결과물:
Box<number>는 숫자가 든 상자,Box<string>은 문자열이 든 상자.
틀은 하나인데, 재료를 바꿔가며 여러 붕어빵을 만드는 것. 이게 제네릭의 핵심이다.
// 제네릭 인터페이스
interface Box<T> {
item: T;
}
const numberBox: Box<number> = { item: 123 };
const stringBox: Box<string> = { item: "hello" };
const userBox: Box<User> = { item: { id: 1, name: "Ratia" } };
같은 Box 구조를 재사용하면서, 들어가는 타입만 바꾼다. any를 썼으면 item이 뭐가 들어있는지 타입스크립트가 모른다. 하지만 제네릭을 쓰면 numberBox.item은 number로 타입이 추론된다.
3. 제네릭 함수 - 기본부터 시작하기
가장 간단한 형태는 제네릭 함수다. 들어온 값을 그대로 반환하는 identity 함수를 만들어보자.
// any를 쓴 버전 (❌ 나쁨)
function identityBad(arg: any): any {
return arg;
}
const result1 = identityBad(123); // result1의 타입: any (타입 정보 상실)
console.log(result1.toUpperCase()); // 에러 안 남! 런타임에 터짐
// 제네릭을 쓴 버전 (✅ 좋음)
function identity<T>(arg: T): T {
return arg;
}
const result2 = identity(123); // result2의 타입: number (타입 정보 유지)
console.log(result2.toUpperCase()); // 컴파일 에러! number엔 toUpperCase 없음
차이가 와닿았다. any는 타입 정보를 날려버리지만, 제네릭은 타입 정보를 보존한다. 타입 추론도 자동으로 된다. identity(123)이라고 쓰면 타입스크립트가 알아서 T를 number로 추론한다.
제네릭 인터페이스 - 재사용 가능한 타입 정의
인터페이스에도 제네릭을 쓸 수 있다. API 응답 같은 공통 구조를 정의할 때 유용하다.
// 제네릭 인터페이스
interface ApiResponse<T> {
code: number;
message: string;
data: T; // 이 부분만 변함
timestamp: number;
}
// 사용자 정보
interface User {
id: number;
name: string;
email: string;
}
// 상품 정보
interface Product {
id: number;
title: string;
price: number;
}
// 타입 안전한 API 함수
async function fetchUser(): Promise<ApiResponse<User>> {
const response = await fetch('/api/user');
return response.json();
}
async function fetchProduct(id: number): Promise<ApiResponse<Product>> {
const response = await fetch(`/api/product/${id}`);
return response.json();
}
// 사용
const userResponse = await fetchUser();
console.log(userResponse.data.name); // ✅ 자동완성 됨!
console.log(userResponse.data.price); // ❌ 에러! User엔 price 없음
const productResponse = await fetchProduct(123);
console.log(productResponse.data.price); // ✅ 가능
이 패턴을 프로젝트에 도입하고 나서 API 응답 처리 코드가 엄청 깔끔해졌다. 응답 구조가 일정하니까 code, message, data를 매번 타이핑할 필요가 없고, data 부분만 제네릭으로 바꿔주면 됐다.
제네릭 클래스 - 타입 안전한 컬렉션
클래스에도 제네릭을 쓸 수 있다. 예를 들어 간단한 스택(Stack) 자료구조를 만들어보자.
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
isEmpty(): boolean {
return this.items.length === 0;
}
size(): number {
return this.items.length;
}
}
// 숫자 스택
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(numberStack.pop()); // 3
// 문자열 스택
const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");
console.log(stringStack.peek()); // "world"
// 커스텀 객체 스택
interface Task {
id: number;
title: string;
done: boolean;
}
const taskStack = new Stack<Task>();
taskStack.push({ id: 1, title: "Learn Generics", done: true });
taskStack.push({ id: 2, title: "Build Project", done: false });
이제 Stack은 어떤 타입이든 담을 수 있지만, 타입 안전성은 유지된다. numberStack.push("string")이라고 쓰면 컴파일 에러가 난다.
4. 제네릭 제약조건(Constraints) - T를 제한하기
제네릭이 너무 자유로우면 문제가 생긴다. "아무거나 다 받으면 안 되는데..." 하는 순간이 온다.
예를 들어, 배열이나 문자열의 길이를 출력하는 함수를 만든다고 해보자.
// 이렇게 쓰면 에러 남
function logLength<T>(arg: T): void {
console.log(arg.length); // ❌ Error! T에 length가 있다는 보장이 없음
}
타입스크립트는 T가 뭔지 모르니까, T에 length 속성이 있는지 확인할 수 없다. 이럴 때 제약조건(Constraints)을 걸어야 한다.
extends로 제약하기
// T는 반드시 length 속성을 가져야 함
interface Lengthwise {
length: number;
}
function logLength<T extends Lengthwise>(arg: T): void {
console.log(arg.length); // ✅ 이제 가능
}
logLength("hello"); // OK (string은 length 있음)
logLength([1, 2, 3]); // OK (array는 length 있음)
logLength({ length: 10, value: 3 }); // OK (length 속성 있음)
logLength(10); // ❌ Error! number는 length 없음
T extends Lengthwise는 "T는 최소한 Lengthwise의 형태는 가져야 한다"는 뜻이다. 이렇게 하면 T의 자유도를 제한하면서도, 필요한 속성은 보장받을 수 있다.
여러 제네릭 파라미터 사용하기
제네릭 파라미터는 하나만 쓸 필요가 없다. T, U, K 등 여러 개를 동시에 쓸 수 있다.
// 두 값을 튜플로 반환
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const result1 = pair(1, "hello"); // [number, string]
const result2 = pair(true, { name: "Ratia" }); // [boolean, { name: string }]
이 패턴은 Map 같은 자료구조를 만들 때 자주 쓴다. 키와 값의 타입이 다르니까.
5. keyof와 제네릭 - 타입 안전한 객체 접근
객체의 속성에 안전하게 접근하고 싶을 때, keyof와 제네릭을 조합하면 정말 강력하다.
문제 상황 - 객체 속성 가져오기
// any를 쓴 버전 (❌ 나쁨)
function getPropertyBad(obj: any, key: string): any {
return obj[key]; // 오타 나도 모름
}
const user = { name: "Ratia", age: 30 };
getPropertyBad(user, "height"); // undefined 반환, 에러 안 남
"height"는 user 객체에 없는 키인데, 타입스크립트가 이걸 못 잡는다. 런타임에 undefined가 반환되고, 나중에 이걸 쓰려다가 에러가 난다.
해결 - keyof를 사용한 타입 안전 접근
// 제네릭 + keyof를 쓴 버전 (✅ 좋음)
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Ratia", age: 30 };
const name = getProperty(user, "name"); // ✅ name의 타입: string
const age = getProperty(user, "age"); // ✅ age의 타입: number
const height = getProperty(user, "height"); // ❌ 컴파일 에러!
keyof T는 T 객체의 키들의 유니온 타입이다. user의 경우 keyof typeof user는 "name" | "age"가 된다. K extends keyof T라고 하면, K는 T의 키 중 하나여야 한다는 뜻이다.
그리고 반환 타입도 정확하다. T[K]는 "T 객체의 K 키에 해당하는 값의 타입"을 의미한다. user["name"]은 string이고, user["age"]는 number다. 이게 자동으로 추론된다.
이 패턴을 처음 봤을 때 정말 신기했다. 오타를 원천 봉쇄할 수 있으니까. 이제 getProperty(user, "nmae") 같은 실수를 하면 IDE가 바로 빨간 줄을 그어준다.
구체적인 예제 - 타입 안전한 pick 함수
객체에서 특정 키들만 골라내는 함수를 만들어보자.
function pick<T, 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;
}
interface User {
id: number;
name: string;
email: string;
password: string;
}
const user: User = {
id: 1,
name: "Ratia",
email: "ratia@example.com",
password: "secret123"
};
// password를 제외한 정보만 골라냄
const publicUser = pick(user, ["id", "name", "email"]);
// publicUser의 타입: { id: number; name: string; email: string; }
console.log(publicUser.name); // ✅ 가능
console.log(publicUser.password); // ❌ 에러! password는 Pick에 없음
프로덕트를 만들 때 이 패턴을 자주 쓴다. API 응답에서 비밀번호 같은 민감한 정보를 빼고 프론트엔드로 보낼 때, pick이나 omit 함수로 타입 안전하게 처리할 수 있다.
6. Mapped Types: 타입 변환의 마법
Mapped Types는 객체 타입의 모든 속성을 순회하면서 새로운 타입을 만드는 기법이다. keyof와 제네릭을 조합해서 강력한 타입 변환을 할 수 있다.
모든 속성을 읽기 전용으로 만들기
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
interface User {
name: string;
age: number;
}
type ReadonlyUser = Readonly<User>;
// 결과:
// {
// readonly name: string;
// readonly age: number;
// }
const user: ReadonlyUser = { name: "Ratia", age: 30 };
user.name = "New Name"; // ❌ 에러! 읽기 전용 속성임
[K in keyof T]는 "T의 모든 키 K를 순회하면서"라는 뜻이다. 그리고 각 키에 대해 readonly를 붙여준다.
모든 속성을 옵션으로 만들기
type Partial<T> = {
[K in keyof T]?: T[K];
};
interface User {
name: string;
age: number;
email: string;
}
// 부분 업데이트 함수
function updateUser(userId: number, updates: Partial<User>) {
// DB에서 기존 유저를 가져와서 updates로 덮어씀
}
updateUser(1, { name: "New Name" }); // ✅ 일부만 전달해도 OK
updateUser(1, { age: 31, email: "new@example.com" }); // ✅ OK
실제로 CRUD 작업을 할 때 Partial은 거의 필수다. 사용자 정보를 수정할 때, 모든 필드를 다 받을 필요는 없으니까. 변경된 필드만 받아서 업데이트하면 된다.
7. Conditional Types와 infer - 고급 타입 추론
조건부 타입(Conditional Types)은 타입 레벨에서 if-else를 하는 것이다. 그리고 infer 키워드는 "타입스크립트야, 이 부분의 타입을 네가 알아서 추론해줘"라고 부탁하는 것이다.
처음엔 이게 제일 어려웠다. 마치 타입 레벨에서 프로그래밍을 하는 느낌이었다. 하지만 이해하고 나니, 정말 강력한 도구라는 걸 깨달았다.
기본 문법: T extends U ? X : Y
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
type C = IsString<"hello">; // "yes" (literal도 string의 subtype)
"T가 string을 확장(extend)하면 'yes', 아니면 'no'"라는 뜻이다.
infer: Promise 내부 타입 꺼내기
가장 자주 쓰는 예제는 Promise의 내부 타입을 추출하는 것이다.
// Promise<T>에서 T를 추출
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<string>>; // string
type B = Unwrap<Promise<number>>; // number
type C = Unwrap<string>; // string (Promise가 아니면 그대로)
// 구체적인 예제
async function fetchUser(): Promise<User> {
// ...
}
type UserType = Unwrap<ReturnType<typeof fetchUser>>; // User
infer U는 "여기 들어있는 타입이 뭔지 네가 알아내서 U라고 불러줘"라는 뜻이다. Promise<string>이 들어오면, 타입스크립트는 U를 string으로 추론한다.
처음엔 이게 마법처럼 느껴졌는데, 이제는 자주 쓴다. 특히 라이브러리 함수의 반환 타입을 추출할 때 유용하다.
함수의 파라미터 타입 추출하기
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
function greet(name: string, age: number): void {
console.log(`Hello, ${name}. You are ${age} years old.`);
}
type GreetParams = Parameters<typeof greet>; // [string, number]
infer P는 함수의 파라미터 타입들을 튜플로 추출한다. 이것도 타입스크립트 내장 유틸리티 타입으로 이미 있지만, 직접 만들어보면 원리를 이해할 수 있다.
배열 요소의 타입 추출하기
type ElementType<T> = T extends (infer U)[] ? U : T;
type A = ElementType<string[]>; // string
type B = ElementType<number[]>; // number
type C = ElementType<User[]>; // User
배열 타입에서 요소의 타입을 뽑아낸다. 이것도 자주 쓴다.
8. 유틸리티 타입 완전 정복
타입스크립트는 이미 많은 유틸리티 타입을 내장하고 있다. 이것들은 전부 제네릭으로 만들어져 있다. 각각이 어떻게 동작하는지 받아들였다.
Partial<T>: 모든 속성을 옵션으로
interface User {
name: string;
age: number;
email: string;
}
type PartialUser = Partial<User>;
// {
// name?: string;
// age?: number;
// email?: string;
// }
// 사용 예: 부분 업데이트
function updateUser(id: number, updates: Partial<User>) {
// ...
}
updateUser(1, { name: "New Name" }); // ✅ age, email은 안 줘도 됨
Required<T>: 모든 속성을 필수로
Partial의 반대다.
interface UserDraft {
name?: string;
age?: number;
email?: string;
}
type CompleteUser = Required<UserDraft>;
// {
// name: string;
// age: number;
// email: string;
// }
Pick<T, K>: 특정 키만 골라내기
interface User {
id: number;
name: string;
email: string;
password: string;
}
type PublicUser = Pick<User, "id" | "name" | "email">;
// {
// id: number;
// name: string;
// email: string;
// }
// API 응답에서 비밀번호 제외하고 반환
function getPublicUserInfo(user: User): PublicUser {
return {
id: user.id,
name: user.name,
email: user.email
};
}
Omit<T, K>: 특정 키만 제외하기
Pick의 반대다.
type UserWithoutPassword = Omit<User, "password">;
// {
// id: number;
// name: string;
// email: string;
// }
Pick이랑 결과는 같지만, 의도가 다르다. "이것만 포함"이냐, "이것만 제외"냐의 차이.
Record<K, T>: 키-값 쌍으로 객체 타입 만들기
type Role = "admin" | "user" | "guest";
type Permissions = Record<Role, string[]>;
// {
// admin: string[];
// user: string[];
// guest: string[];
// }
const permissions: Permissions = {
admin: ["read", "write", "delete"],
user: ["read", "write"],
guest: ["read"]
};
객체의 키와 값 타입을 한 번에 정의할 수 있다. 딕셔너리 같은 구조를 만들 때 유용하다.
ReturnType<T>: 함수의 반환 타입 추출
function createUser() {
return {
id: 1,
name: "Ratia",
email: "ratia@example.com"
};
}
type User = ReturnType<typeof createUser>;
// {
// id: number;
// name: string;
// email: string;
// }
함수의 반환 타입을 따로 인터페이스로 정의하지 않아도, ReturnType으로 추출할 수 있다. 특히 외부 라이브러리 함수의 반환 타입을 알고 싶을 때 유용하다.
Exclude<T, U>: 유니온 타입에서 특정 타입 제거
type AllColors = "red" | "blue" | "green" | "yellow";
type PrimaryColors = Exclude<AllColors, "green" | "yellow">;
// "red" | "blue"
Extract<T, U>: 유니온 타입에서 특정 타입만 추출
type AllTypes = string | number | boolean | null;
type PrimitiveNumbers = Extract<AllTypes, number>;
// number
NonNullable<T>: null과 undefined 제거
type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>;
// string
9. 커스텀 유틸리티 타입 만들기
내장 유틸리티 타입만으로 부족할 때가 있다. 직접 만들어야 할 때도 있다.
DeepPartial<T>: 중첩된 객체도 전부 옵션으로
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
interface Config {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
cache: {
enabled: boolean;
ttl: number;
};
}
type PartialConfig = DeepPartial<Config>;
const config: PartialConfig = {
database: {
host: "localhost" // port, credentials는 안 줘도 됨
}
// cache도 안 줘도 됨
};
Partial은 1단계 깊이까지만 옵션으로 만든다. 중첩된 객체의 속성까지 옵션으로 만들려면 DeepPartial이 필요하다.
NonNullableFields<T>: null/undefined 필드만 제거
type NonNullableFields<T> = {
[K in keyof T]: NonNullable<T[K]>;
};
interface User {
name: string;
age: number | null;
email: string | undefined;
}
type CleanUser = NonNullableFields<User>;
// {
// name: string;
// age: number;
// email: string;
// }
Mutable<T>: readonly 제거하기
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
interface ReadonlyUser {
readonly name: string;
readonly age: number;
}
type MutableUser = Mutable<ReadonlyUser>;
// {
// name: string;
// age: number;
// }
-readonly는 "readonly를 제거하라"는 뜻이다. 마이너스 기호로 속성을 뺄 수 있다.
10. 실제 패턴 - React에서 제네릭 활용하기
React를 쓰면서 제네릭의 진가를 느꼈다. 특히 커스텀 훅을 만들 때.
타입 안전한 useQuery 훅
interface QueryResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
function useQuery<T>(url: string): QueryResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url);
const json = await response.json();
setData(json);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [url]);
return { data, loading, error, refetch: fetchData };
}
// 사용
interface User {
id: number;
name: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data, loading, error } = useQuery<User>(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return <div>{data.name}</div>; // ✅ data.name은 string으로 타입 추론됨
}
제네릭 덕분에 useQuery<User>라고 쓰면, 반환된 data가 User | null 타입이 된다. 자동완성도 되고, 오타도 방지된다.
제네릭 컴포넌트: List<T>
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string | number;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>
{renderItem(item)}
</li>
))}
</ul>
);
}
// 사용
interface User {
id: number;
name: string;
}
function UserList({ users }: { users: User[] }) {
return (
<List
items={users}
renderItem={user => <div>{user.name}</div>}
keyExtractor={user => user.id}
/>
);
}
List 컴포넌트는 어떤 타입의 배열이든 렌더링할 수 있다. items의 타입에 따라 renderItem과 keyExtractor의 파라미터 타입이 자동으로 추론된다.
폼 상태 관리: useForm<T>
interface FormState<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
}
function useForm<T>(initialValues: T) {
const [state, setState] = useState<FormState<T>>({
values: initialValues,
errors: {},
touched: {}
});
const setValue = <K extends keyof T>(key: K, value: T[K]) => {
setState(prev => ({
...prev,
values: { ...prev.values, [key]: value }
}));
};
const setError = (key: keyof T, error: string) => {
setState(prev => ({
...prev,
errors: { ...prev.errors, [key]: error }
}));
};
const setTouched = (key: keyof T) => {
setState(prev => ({
...prev,
touched: { ...prev.touched, [key]: true }
}));
};
return {
values: state.values,
errors: state.errors,
touched: state.touched,
setValue,
setError,
setTouched
};
}
// 사용
interface LoginForm {
email: string;
password: string;
}
function LoginPage() {
const form = useForm<LoginForm>({
email: "",
password: ""
});
return (
<form>
<input
value={form.values.email}
onChange={e => form.setValue("email", e.target.value)}
onBlur={() => form.setTouched("email")}
/>
{form.errors.email && <span>{form.errors.email}</span>}
<input
type="password"
value={form.values.password}
onChange={e => form.setValue("password", e.target.value)}
onBlur={() => form.setTouched("password")}
/>
{form.errors.password && <span>{form.errors.password}</span>}
</form>
);
}
useForm<LoginForm>이라고 하면, setValue("email", value)에서 value는 자동으로 string 타입이어야 한다. setValue("age", 123) 같은 실수를 하면 컴파일 에러가 난다.
11. 정리해본다
제네릭을 처음 봤을 때는 어려웠다. 꺾쇠 괄호도 낯설고, T가 뭔지 몰랐다. 하지만 이제는 제네릭 없이는 타입스크립트를 못 쓸 것 같다.
결국 제네릭은 이거였다:
- 타입을 파라미터처럼 넘기는 것. 함수처럼, 타입도 인자를 받을 수 있다.
any의 안전한 대안. 유연성은 유지하면서도 타입 안전성을 보장한다.- 재사용성의 핵심. 같은 로직을 여러 타입에 적용할 수 있다.
실제로 가장 자주 쓰는 패턴:
- API 응답 래퍼:
ApiResponse<T> - 커스텀 훅:
useQuery<T>,useForm<T> - 유틸리티 함수:
pick,omit,getProperty - 제네릭 컴포넌트:
List<T>,Table<T>
제네릭을 마스터하면, 타입스크립트의 진정한 힘을 느낄 수 있다. 처음엔 어렵지만, 계속 쓰다 보면 자연스러워진다. 그리고 일단 익숙해지면, 다시 any로 돌아갈 수 없다.
12. 용어 사전 (Glossary)
- Generic (제네릭): 타입을 마치 함수의 인자처럼 파라미터화하여 재사용성을 높이는 기법.
- Type Parameter (타입 파라미터): 제네릭에서 사용하는 변수. 관례적으로
T,U,K등을 쓴다. - Type Inference (타입 추론): 타입을 명시하지 않아도 컴파일러가 문맥을 보고 타입을 알아내는 것.
- Constraints (
extends): 제네릭 타입T가 특정 형태를 따르도록 강제하는 것. keyofOperator: 객체 타입의 모든 키를 유니온 타입으로 추출하는 연산자.- Mapped Types: 객체 타입의 모든 속성을 순회하면서 새로운 타입을 만드는 기법.
- Conditional Types: 타입 레벨에서 조건문(
T extends U ? X : Y)을 사용하는 것. inferKeyword: 조건부 타입 내에서 타입을 추론하여 변수에 할당하는 키워드.- Utility Types: 타입스크립트가 기본 제공하는 제네릭 타입 도구들 (
Partial,Pick,Omit등). - Union Type:
A | B. A 또는 B. - Intersection Type:
A & B. A와 B의 속성을 모두 가짐.
13. FAQ & Common Questions
Q1: any와 unknown의 차이는?
둘 다 모든 값을 받을 수 있지만, unknown이 더 안전하다.
-
any: 타입 검사를 완전히 끈다. 아무 메서드나 호출 가능. 런타임 에러 위험.let value: any = "hello"; value.toUpperCase(); // OK value.foo.bar.baz(); // OK (컴파일 에러 안 남, 런타임에 터짐) -
unknown: 타입을 모르는 상태. 사용하기 전에 타입 체크 필요.let value: unknown = "hello"; value.toUpperCase(); // ❌ Error! unknown은 메서드 호출 불가 if (typeof value === "string") { value.toUpperCase(); // ✅ OK (타입 가드 후 가능) }
원칙: 모르면 any 대신 unknown을 써라. 더 안전하다.
Q2: 제네릭과 any의 차이는?
-
any: 타입 정보를 완전히 잃어버린다.function identity(arg: any): any { return arg; } const result = identity(123); // result의 타입: any -
제네릭: 타입 정보를 보존한다.
function identity<T>(arg: T): T { return arg; } const result = identity(123); // result의 타입: number
Q3: 인터페이스와 타입 별칭(type)의 차이는?
거의 비슷하지만 몇 가지 차이가 있다:
-
선언 병합(Declaration Merging):
- Interface: 같은 이름으로 여러 번 선언하면 합쳐진다.
interface User { name: string; } interface User { age: number; } // 결과: { name: string; age: number; } - Type: 같은 이름으로 재선언 불가. 에러 남.
- Interface: 같은 이름으로 여러 번 선언하면 합쳐진다.
-
표현력:
- Type: 유니온(
|), 인터섹션(&), 튜플 등 더 복잡한 타입 표현 가능.type ID = string | number; type Point = [number, number]; - Interface: 객체 형태만 가능.
- Type: 유니온(
-
확장(Extends):
- Interface:
extends키워드 사용.interface Animal { name: string; } interface Dog extends Animal { breed: string; } - Type: 인터섹션(
&) 사용.type Animal = { name: string; }; type Dog = Animal & { breed: string; };
- Interface:
원칙: 객체 타입은 interface, 유니온이나 복잡한 타입은 type을 쓴다. 라이브러리 타이핑은 선언 병합이 필요하니 interface를 선호.
Q4: 제네릭은 언제 써야 하나?
다음 상황에서 제네릭을 쓴다:
- 같은 로직, 다른 타입: 함수나 클래스가 여러 타입에 대해 동작해야 할 때.
- 타입 안전성 필요:
any를 쓰고 싶지만, 타입 안전성을 잃고 싶지 않을 때. - 재사용 가능한 컴포넌트/유틸: 라이브러리, 훅, 유틸리티 함수를 만들 때.
Q5: infer는 언제 쓰나?
주로 라이브러리나 유틸리티 타입을 만들 때 쓴다. 일반 애플리케이션 코드에서는 거의 안 쓴다. ReturnType, Parameters 같은 유틸리티 타입이 내부적으로 infer를 쓴다.
사용 예:
- Promise에서 내부 타입 추출
- 함수의 반환 타입이나 파라미터 추출
- 복잡한 타입에서 특정 부분만 뽑아낼 때