타입을 매개변수로 만드는 마법
Generic Functions Constraints Utility Types
타입 안전성과 재사용성을 동시에 확보
// 방법 1: 각 타입마다 함수를 만든다?
function identityNumber(arg: number): number {
return arg;
}
function identityString(arg: string): string {
return arg;
}
// ... 타입마다 계속 만들어야 함
// 방법 2: any를 사용한다?
function identityAny(arg: any): any {
return arg;
}
// 타입 안전성이 사라짐!
const result = identityAny("hello");
result.toFixed(); // 런타임 (Runtime, 실행 시점) 에러!
// <T>는 "타입 매개변수" - 상자에 붙이는 라벨!
function identity<T>(arg: T): T {
return arg;
}
// 타입 추론: TypeScript가 자동으로 T를 알아냄
const str = identity("hello");
// str: string (T가 string으로 결정됨)
const num = identity(42);
// num: number (T가 number로 결정됨)
// 명시적으로 타입을 지정할 수도 있음
const bool = identity<boolean>(true);
// bool: boolean
// 타입 안전성 보장!
str.toUpperCase(); // OK
num.toFixed(); // OK
제네릭의 T는 호출 시점에 하나의 구체적 타입으로 치환되어, 함수 전체에 일관되게 흘러갑니다.
// 1단계: 제네릭 함수 정의 - T는 아직 미정
function identity<T>(arg: T): T {
return arg;
}
// 2단계: 호출 시 T가 결정됨
const result = identity<string>("hello");
// ^^^^^^
// T = string으로 결정!
// 3단계: T가 string으로 치환되어 흐름
// identity<string>(arg: string): string
// ^^^^^^ ^^^^^^
// 매개변수 타입 = string, 반환 타입 = string
// 4단계: 결과 타입 확정
// result: string → toUpperCase() 호출 가능!
// 또 다른 예: T = number로 흐르는 과정
function wrap<T>(value: T): { value: T } {
return { value };
}
const wrapped = wrap(42);
// T = number → 매개변수: number → 반환: { value: number }
// wrapped.value는 number 타입!
<T>는 타입 변수 - 호출 시 실제 타입으로 치환됩니다
// 다중 타입 매개변수
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const p1 = pair("이름", 30); // [string, number]
const p2 = pair(true, [1, 2, 3]); // [boolean, number[]]
// 배열 관련 유틸리티
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
function map<Input, Output>(
arr: Input[],
fn: (item: Input) => Output
): Output[] {
return arr.map(fn);
}
const names = firstElement(["Alice", "Bob"]); // string | undefined
const lengths = map(["hello", "world"], s => s.length); // number[]
T(Type), U(Second type), K(Key), V(Value), E(Element)
// API 응답을 위한 제네릭 인터페이스: 데이터 타입만 바뀌는 공통 구조
interface ApiResponse<T> {
success: boolean;
data: T;
error?: string;
timestamp: number;
}
interface User {
id: number;
name: string;
email: string;
}
interface Product {
id: number;
title: string;
price: number;
}
// 같은 구조, 다른 데이터 타입!
const userResponse: ApiResponse<User> = {
success: true,
data: { id: 1, name: "홍길동", email: "hong@mail.com" },
timestamp: Date.now()
};
const productResponse: ApiResponse<Product[]> = {
success: true,
data: [
{ id: 1, title: "TypeScript 책", price: 35000 },
{ id: 2, title: "React 책", price: 30000 }
],
timestamp: Date.now()
};
interface Repository<T, ID = number> {
findById(id: ID): Promise<T | null>;
findAll(): Promise<T[]>;
create(item: Omit<T, "id">): Promise<T>;
update(id: ID, item: Partial<T>): Promise<T>;
delete(id: ID): Promise<boolean>;
}
interface User {
id: number;
name: string;
email: string;
}
class UserRepository implements Repository<User> {
private users: User[] = [];
async findById(id: number): Promise<User | null> {
return this.users.find(u => u.id === id) || null;
}
async findAll(): Promise<User[]> {
return [...this.users];
}
async create(item: Omit<User, "id">): Promise<User> {
const user = { ...item, id: this.users.length + 1 };
this.users.push(user);
return user;
}
async update(id: number, item: Partial<User>): Promise<User> {
const index = this.users.findIndex(u => u.id === id);
this.users[index] = { ...this.users[index], ...item };
return this.users[index];
}
async delete(id: number): Promise<boolean> {
this.users = this.users.filter(u => u.id !== id);
return true;
}
}
// Stack<T>: T에 어떤 타입이든 넣을 수 있는 스택(쌓기) 자료구조
class Stack<T> {
private items: T[] = []; // 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];
}
get size(): number { // 현재 저장된 개수
return this.items.length;
}
isEmpty(): boolean { // 비어있는지 확인
return this.items.length === 0;
}
toArray(): T[] { // 배열로 변환
return [...this.items];
}
}
// 타입별로 안전한 스택
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
// numberStack.push("3"); // Error! number만 가능
const stringStack = new Stack<string>();
stringStack.push("hello");
const top = stringStack.peek(); // string | undefined
extends (확장/제한 키워드)로 타입 매개변수를 제한합니다. "이 타입은 최소한 이런 조건을 만족해야 한다"는 뜻입니다.
// 문제: T가 어떤 타입인지 모르니 .length에 접근 불가
// function logLength<T>(arg: T): void {
// console.log(arg.length); // Error: T에 length가 없을 수 있음
// }
// 해결: 제약조건으로 length가 있는 타입만 허용
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(arg: T): T {
console.log(`길이: ${arg.length}`);
return arg;
}
logLength("hello"); // OK: string에 length 있음
logLength([1, 2, 3]); // OK: 배열에 length 있음
logLength({ length: 10 }); // OK: length 프로퍼티 있음
// logLength(123); // Error: number에 length 없음
// keyof: 객체 타입의 모든 키(프로퍼티 이름)를 유니온 타입으로 추출
// 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: "홍길동", age: 30, email: "hong@mail.com" };
const name = getProperty(user, "name"); // string
const age = getProperty(user, "age"); // number
// getProperty(user, "phone"); // Error: "phone"은 keyof User가 아님
// 패턴 1: T extends string - 문자열 관련 타입만 허용
function formatLabel<T extends string>(label: T): `[${T}]` {
return `[${label}]` as `[${T}]`;
}
const tag = formatLabel("error"); // "[error]" (리터럴 타입!)
// 패턴 2: T extends object - 객체 타입만 허용 (원시 타입 제외)
function getKeys<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[];
}
getKeys({ name: "Kim", age: 30 }); // ("name" | "age")[]
// getKeys("hello"); // Error! string은 object가 아님
// 패턴 3: T extends { id: number } - 특정 속성을 가진 객체만 허용
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
// 패턴 4: T extends (...args: any[]) => any - 함수 타입만 허용
function measureTime<T extends (...args: any[]) => any>(fn: T): T {
return ((...args: any[]) => {
const start = performance.now();
const result = fn(...args);
console.log(`실행 시간: ${performance.now() - start}ms`);
return result;
}) as T;
}
하나의 타입 매개변수를 다른 타입 매개변수의 제약에 활용
// K는 T의 키 중 하나여야 함
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;
}
const user = {
id: 1,
name: "홍길동",
email: "hong@mail.com",
password: "secret123"
};
// 안전하게 필요한 필드만 추출
const publicUser = pick(user, ["id", "name", "email"]);
// 타입: Pick<typeof user, "id" | "name" | "email">
// { id: number; name: string; email: string }
// pick(user, ["phone"]); // Error: "phone"은 유효한 키가 아님
// 값 할당 시 타입 안전성
function assign<T extends object, U extends Partial<T>>(
target: T,
source: U
): T & U {
return { ...target, ...source };
}
const updated = assign(
{ name: "홍길동", age: 30 },
{ age: 31 } // OK: Partial<T>에 해당
);
// assign({ name: "홍길동" }, { foo: "bar" }); // Error!
interface User {
id: number;
name: string;
email: string;
age?: number;
}
// 모든 프로퍼티를 선택적으로
type PartialUser = Partial<User>;
// {
// id?: number;
// name?: string;
// email?: string;
// age?: number;
// }
// 업데이트 함수에 유용
function updateUser(
id: number,
updates: Partial<User>
) {
// 일부 필드만 업데이트
}
updateUser(1, { name: "새이름" }); // OK!
// 모든 프로퍼티를 필수로
type RequiredUser = Required<User>;
// age도 필수가 됨!
// 모든 프로퍼티를 읽기전용으로
type FrozenUser = Readonly<User>;
// {
// readonly id: number;
// readonly name: string;
// readonly email: string;
// readonly age?: number;
// }
const user: FrozenUser = {
id: 1, name: "홍길동",
email: "hong@mail.com"
};
// user.name = "변경"; // Error!
interface User {
id: number;
name: string;
email: string;
password: string;
role: "admin" | "user";
}
// Record<K, V>: 키 타입 K, 값 타입 V인 객체
type Roles = "admin" | "editor" | "viewer";
type RolePermissions = Record<Roles, string[]>;
const permissions: RolePermissions = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"]
};
// Pick<T, K>: T에서 K 프로퍼티만 선택
type UserProfile = Pick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }
// Omit<T, K>: T에서 K 프로퍼티만 제외
type UserWithoutPassword = Omit<User, "password">;
// { id: number; name: string; email: string; role: ... }
// 조합 활용: 생성 시 id 제외 + password 필수
type CreateUserDTO = Omit<User, "id">;
type UpdateUserDTO = Partial<Omit<User, "id" | "password">>;
삼항 연산자처럼 동작하는 타입 레벨(타입을 다루는 차원의) 조건문
// 기본 형태: T extends U ? X : Y
// "T가 U에 해당하면 X, 아니면 Y" (if-else와 비슷!)
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
type C = IsString<"hello">; // "yes"
// 실전 활용: 응답 타입 결정
type ApiEndpoint = "users" | "posts" | "comments";
interface User { id: number; name: string; }
interface Post { id: number; title: string; }
interface Comment { id: number; body: string; }
type ResponseType<T extends ApiEndpoint> =
T extends "users" ? User[] :
T extends "posts" ? Post[] :
T extends "comments" ? Comment[] :
never;
// 엔드포인트에 따라 반환 타입이 자동 결정!
async function fetchData<T extends ApiEndpoint>(
endpoint: T
): Promise<ResponseType<T>> {
const response = await fetch(`/api/${endpoint}`);
return response.json();
}
const users = await fetchData("users"); // User[]
const posts = await fetchData("posts"); // Post[]
keyof와 typeof 활용
// keyof (키 추출 연산자): 객체 타입의 모든 키를 유니온으로 추출
interface Config {
host: string;
port: number;
debug: boolean;
}
type ConfigKey = keyof Config; // "host" | "port" | "debug"
// typeof (타입 추출 연산자): 실제 값에서 타입 정보를 추출
const defaultConfig = {
host: "localhost",
port: 3000,
debug: false
} as const;
type ConfigType = typeof defaultConfig;
// { readonly host: "localhost"; readonly port: 3000; readonly debug: false }
// keyof + typeof + 제네릭 조합
function getConfigValue<K extends keyof typeof defaultConfig>(
key: K
): typeof defaultConfig[K] {
return defaultConfig[key];
}
const host = getConfigValue("host"); // "localhost" (리터럴 타입!)
const port = getConfigValue("port"); // 3000
// getConfigValue("unknown"); // Error!
기존 타입을 변환하여 새 타입을 생성합니다. "기존 타입의 각 프로퍼티에 대해 이런 변환을 적용하라"는 뜻입니다.
// Partial의 내부 구현 원리
// [K in keyof T] = "T의 각 키 K에 대해"
// ?: 선택적으로 만듦, T[K]: 원래 타입 유지
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Readonly의 내부 구현 원리
type MyReadonly<T> = {
readonly [K in keyof T]: T[K]; // 각 키에 readonly 추가
};
// Required의 내부 구현 원리
type MyRequired<T> = {
[K in keyof T]-?: T[K]; // -?로 선택적(?) 표시를 제거 = 필수로 만듦
};
// 커스텀 매핑: 모든 프로퍼티를 nullable로
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
interface User {
name: string;
age: number;
}
type NullableUser = Nullable<User>;
// { name: string | null; age: number | null }
// 모든 프로퍼티를 Promise로 감싸기
type Async<T> = {
[K in keyof T]: Promise<T[K]>;
};
type AsyncUser = Async<User>;
// { name: Promise<string>; age: Promise<number> }
| 유틸리티 타입 | 설명 | 사용 예 |
|---|---|---|
Partial<T> |
모든 프로퍼티 선택적 | 업데이트 DTO |
Required<T> |
모든 프로퍼티 필수 | 완전한 설정 객체 보장 |
Readonly<T> |
모든 프로퍼티 읽기전용 | 불변 상태 관리 |
Record<K, V> |
K를 키, V를 값으로 | 맵/사전 객체 정의 |
Pick<T, K> |
T에서 K만 선택 | 공개 API 응답 필터링 |
Omit<T, K> |
T에서 K 제외 | 비밀번호 제거 등 |
Extract<T, U> |
T에서 U에 할당 가능한 것만 | 유니온 필터링 |
Exclude<T, U> |
T에서 U에 할당 가능한 것 제외 | 유니온에서 제거 |
NonNullable<T> |
null, undefined 제거 | 안전한 값 보장 |
ReturnType<T> |
함수 반환 타입 추출 | 함수 결과 타입 재사용 |
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface PaginatedData<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasNext: boolean;
}
class ApiClient {
constructor(private baseUrl: string) {}
private async request<T>(
endpoint: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
return response.json();
}
async get<T>(endpoint: string): Promise<T> {
const result = await this.request<T>(endpoint);
return result.data;
}
async getList<T>(
endpoint: string,
page = 1
): Promise<PaginatedData<T>> {
return this.get<PaginatedData<T>>(
`${endpoint}?page=${page}`
);
}
async post<T, B>(endpoint: string, body: B): Promise<T> {
const result = await this.request<T>(endpoint, {
method: "POST",
body: JSON.stringify(body),
});
return result.data;
}
}
// 사용 - 완벽한 타입 추론!
const api = new ApiClient("https://api.example.com");
interface User { id: number; name: string; }
interface CreateUserDTO { name: string; email: string; }
const user = await api.get<User>("/users/1"); // User
const users = await api.getList<User>("/users"); // PaginatedData<User>
const newUser = await api.post<User, CreateUserDTO>(
"/users", { name: "홍길동", email: "hong@mail.com" }
); // User
// 타입 매개변수에 기본값 지정
interface EventEmitter<Events extends Record<string, any> = Record<string, any>> {
on<K extends keyof Events>(event: K, handler: (data: Events[K]) => void): void;
emit<K extends keyof Events>(event: K, data: Events[K]): void;
}
// 이벤트 맵 정의
interface AppEvents {
login: { userId: string; timestamp: number };
logout: { userId: string };
error: { message: string; code: number };
}
// 타입 안전한 이벤트!
declare const emitter: EventEmitter<AppEvents>;
emitter.on("login", (data) => {
console.log(data.userId); // OK: string
console.log(data.timestamp); // OK: number
});
emitter.emit("error", {
message: "서버 오류",
code: 500
});
// emitter.emit("login", { foo: "bar" }); // Error!
EventEmitter = EventEmitter<Record<string, any>>
// 1. 타입 안전한 Object.keys
function typedKeys<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[];
}
const user = { name: "홍길동", age: 30 };
const keys = typedKeys(user); // ("name" | "age")[]
// 2. 제네릭 상태 관리 훅
function useState<T>(initial: T): [() => T, (newValue: T) => void] {
let value = initial;
return [
() => value,
(newValue: T) => { value = newValue; }
];
}
const [getName, setName] = useState("홍길동");
setName("김철수");
// setName(123); // Error: string만 가능!
// 3. 제네릭 팩토리
function createArray<T>(length: number, value: T): T[] {
return Array.from({ length }, () => value);
}
const zeros = createArray(5, 0); // number[]
const hellos = createArray(3, "hello"); // string[]
T extends U로 허용 범위 제한