Chapter 6

제네릭 (Generic)

타입을 매개변수로 만드는 마법


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가 흐르는 과정

핵심 원리

제네릭의 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)

제네릭 인터페이스 (Interface)


// 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()
};
        

제네릭 인터페이스 실전: Repository 패턴


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
        

제네릭 제약 조건 (Constraint)

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가 아님
        

자주 쓰는 제약 조건 패턴 모음

실전 패턴: 제약 조건은 "T가 최소한 무엇이어야 하는지"를 명시하여 타입 안전성을 높입니다.

// 패턴 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!
        

유틸리티 타입 (1): Partial, Required, Readonly


interface User {
  id: number;
  name: string;
  email: string;
  age?: number;
}
        

Partial<T>


// 모든 프로퍼티를 선택적으로
type PartialUser = Partial<User>;
// {
//   id?: number;
//   name?: string;
//   email?: string;
//   age?: number;
// }

// 업데이트 함수에 유용
function updateUser(
  id: number,
  updates: Partial<User>
) {
  // 일부 필드만 업데이트
}

updateUser(1, { name: "새이름" }); // OK!
            

Required<T> / Readonly<T>


// 모든 프로퍼티를 필수로
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!
            

유틸리티 타입 (2): Record, Pick, Omit


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">>;
        

조건부 타입 (Conditional Type)과 제네릭

삼항 연산자처럼 동작하는 타입 레벨(타입을 다루는 차원의) 조건문


// 기본 형태: 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[]
        

keyoftypeof 활용


// 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!
        

매핑된 타입 (Mapped Type) 기초

기존 타입을 변환하여 새 타입을 생성합니다. "기존 타입의 각 프로퍼티에 대해 이런 변환을 적용하라"는 뜻입니다.


// 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> 함수 반환 타입 추출 함수 결과 타입 재사용

실전 예제: 제네릭 API 클라이언트


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
        

제네릭 기본값 (Default Types)


// 타입 매개변수에 기본값 지정
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[]
        

Chapter 6 요약

핵심 정리

  • 제네릭: 타입을 매개변수화하여 재사용성과 타입 안전성 확보
  • 제약 조건: T extends U로 허용 범위 제한
  • 제네릭 인터페이스/클래스: 다양한 타입에 대응하는 구조 설계
  • 유틸리티 타입: Partial, Pick, Omit, Record 등 내장 제네릭
  • 조건부 타입: 타입 레벨(타입을 다루는 차원)의 if-else 분기
  • 매핑된 타입: 기존 타입 변환으로 새 타입 생성
  • keyof / typeof: 타입 정보 추출의 핵심 도구

← 이전: Chapter 5 - 클래스   |   다음 챕터: Chapter 7 - 타입 좁히기 →