TypeScript 독학 가이드 8 - 제네릭

learning by Seven Fingers Studio 18분
TypeScript제네릭Generics타입시스템웹개발

“이 함수는 number도 받고 string도 받고 싶은데, 타입은 안전하게 유지하고 싶어요.” 이럴 때 제네릭이 답이에요. any 쓰면 타입 안전성이 사라지잖아요? 제네릭은 타입을 변수처럼 다룰 수 있어서 완전 강력해요.

제네릭이 왜 필요할까?

먼저 문제 상황을 봅시다:

function getFirstElement(arr: number[]): number {
  return arr[0];
}

const numbers = [1, 2, 3];
console.log(getFirstElement(numbers));  // OK

const strings = ["a", "b", "c"];
// console.log(getFirstElement(strings));  // 에러! string[] 안 받음

실행 결과:

✗ 타입 에러
Argument of type 'string[]' is not assignable to parameter of type 'number[]'

number 배열만 받을 수 있어서 string 배열은 사용할 수 없습니다

any 쓰면?

function getFirstElement(arr: any[]): any {
  return arr[0];
}

const result = getFirstElement([1, 2, 3]);
// result의 타입이 any... 타입 안전성 사라짐

제네릭 쓰면 완벽하게 해결!

function getFirstElement<T>(arr: T[]): T {
  return arr[0];
}

const num = getFirstElement([1, 2, 3]);        // number
const str = getFirstElement(["a", "b", "c"]);  // string
const bool = getFirstElement([true, false]);   // boolean

console.log(num);
console.log(str);
console.log(bool);

실행 결과:

✓ 컴파일 성공
1
a
true

제네릭으로 타입 안전성을 유지하면서 재사용 가능한 함수를 만들었습니다

<T>는 타입 변수예요. 함수를 호출할 때 타입이 결정됩니다.

제네릭 함수

기본 사용법

function identity<T>(value: T): T {
  return value;
}

console.log(identity<number>(123));
console.log(identity<string>("안녕"));
console.log(identity(true));  // 타입 추론으로 boolean

실행 결과:

✓ 컴파일 성공
123
안녕
true

제네릭 타입을 명시하거나 추론에 맡길 수 있습니다

여러 타입 변수

function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

const result1 = pair<number, string>(1, "one");
const result2 = pair(true, 123);  // 타입 추론

console.log(result1);
console.log(result2);

실행 결과:

✓ 컴파일 성공
[ 1, 'one' ]
[ true, 123 ]

두 개의 다른 타입을 동시에 다룰 수 있습니다

화살표 함수

const reverse = <T>(arr: T[]): T[] => {
  return arr.reverse();
};

console.log(reverse([1, 2, 3, 4, 5]));
console.log(reverse(["a", "b", "c"]));

실행 결과:

✓ 컴파일 성공
[ 5, 4, 3, 2, 1 ]
[ 'c', 'b', 'a' ]

화살표 함수에서도 제네릭을 사용할 수 있습니다

제네릭 인터페이스

인터페이스에도 제네릭을 사용할 수 있어요:

interface Box<T> {
  value: T;
}

const numberBox: Box<number> = { value: 123 };
const stringBox: Box<string> = { value: "안녕" };
const boolBox: Box<boolean> = { value: true };

console.log(numberBox);
console.log(stringBox);
console.log(boolBox);

실행 결과:

✓ 컴파일 성공
{ value: 123 }
{ value: '안녕' }
{ value: true }

제네릭 인터페이스로 다양한 타입의 Box를 만들 수 있습니다

API 응답 타입

실무에서 자주 쓰는 패턴:

interface ApiResponse<T> {
  success: boolean;
  data: T;
  timestamp: number;
}

interface User {
  id: number;
  name: string;
}

interface Product {
  id: number;
  title: string;
  price: number;
}

const userResponse: ApiResponse<User> = {
  success: true,
  data: { id: 1, name: "김철수" },
  timestamp: Date.now()
};

const productResponse: ApiResponse<Product> = {
  success: true,
  data: { id: 101, title: "노트북", price: 1500000 },
  timestamp: Date.now()
};

console.log(userResponse.data.name);
console.log(productResponse.data.title);

실행 결과:

✓ 컴파일 성공
김철수
노트북

같은 응답 구조로 다양한 데이터 타입을 처리합니다

제네릭 클래스

클래스에도 제네릭 사용 가능:

class Stack<T> {
  private items: T[] = [];

  push(item: T) {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }

  size(): number {
    return this.items.length;
  }
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(`마지막 요소: ${numberStack.peek()}`);
console.log(`꺼낸 요소: ${numberStack.pop()}`);
console.log(`스택 크기: ${numberStack.size()}`);

const stringStack = new Stack<string>();
stringStack.push("a");
stringStack.push("b");
console.log(`꺼낸 요소: ${stringStack.pop()}`);

실행 결과:

✓ 컴파일 성공
마지막 요소: 3
꺼낸 요소: 3
스택 크기: 2
꺼낸 요소: b

제네릭 클래스로 타입 안전한 자료구조를 만들었습니다

제네릭 제약조건 (Constraints)

“어떤 타입이든 받는데, 특정 속성은 있어야 해!” 이럴 때 제약조건 씁니다.

extends 키워드

interface Lengthwise {
  length: number;
}

function logLength<T extends Lengthwise>(item: T): void {
  console.log(`길이: ${item.length}`);
}

logLength("안녕하세요");        // string은 length 있음 - OK
logLength([1, 2, 3]);          // array도 length 있음 - OK
logLength({ length: 10 });     // 객체에 length 있으면 OK
// logLength(123);             // number는 length 없음 - 에러!

실행 결과:

✓ 컴파일 성공
길이: 5
길이: 3
길이: 10

length 속성이 있는 타입만 받을 수 있습니다

여러 제약조건

interface Named {
  name: string;
}

interface Aged {
  age: number;
}

function introduce<T extends Named & Aged>(person: T): string {
  return `${person.name}님은 ${person.age}살입니다`;
}

const person = { name: "김철수", age: 25, job: "개발자" };
console.log(introduce(person));

실행 결과:

✓ 컴파일 성공
김철수님은 25살입니다

name과 age 속성을 모두 가진 객체만 받습니다

keyof 연산자

객체의 키를 타입으로 쓸 수 있어요:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = {
  name: "김철수",
  age: 25,
  job: "개발자"
};

console.log(getProperty(person, "name"));
console.log(getProperty(person, "age"));
// console.log(getProperty(person, "salary"));  // 에러! salary 없음

실행 결과:

✓ 컴파일 성공
김철수
25

객체에 존재하는 키만 사용할 수 있습니다

keyof T는 T의 모든 키를 유니온 타입으로 만들어요. 엄청 유용합니다!

기본 타입 매개변수

제네릭에 기본값을 줄 수 있어요:

interface Response<T = string> {
  data: T;
  status: number;
}

const response1: Response = {
  data: "성공",
  status: 200
};

const response2: Response<number> = {
  data: 123,
  status: 200
};

console.log(response1);
console.log(response2);

실행 결과:

✓ 컴파일 성공
{ data: '성공', status: 200 }
{ data: 123, status: 200 }

타입을 지정하지 않으면 기본값인 string이 사용됩니다

실전 예제: 배열 유틸리티

제네릭으로 배열 유틸리티 함수 만들어볼게요:

function filterArray<T>(arr: T[], predicate: (item: T) => boolean): T[] {
  return arr.filter(predicate);
}

function mapArray<T, U>(arr: T[], mapper: (item: T) => U): U[] {
  return arr.map(mapper);
}

const numbers = [1, 2, 3, 4, 5];
const evenNumbers = filterArray(numbers, n => n % 2 === 0);
const doubled = mapArray(numbers, n => n * 2);

console.log(`짝수: ${evenNumbers}`);
console.log(`2배: ${doubled}`);

const words = ["hello", "world", "typescript"];
const longWords = filterArray(words, w => w.length > 5);
const upperWords = mapArray(words, w => w.toUpperCase());

console.log(`긴 단어: ${longWords}`);
console.log(`대문자: ${upperWords}`);

실행 결과:

✓ 컴파일 성공
짝수: 2,4
2배: 2,4,6,8,10
긴 단어: typescript
대문자: HELLO,WORLD,TYPESCRIPT

제네릭으로 타입 안전한 유틸리티 함수를 만들었습니다

정리하면

  • 제네릭: 타입을 변수처럼 다루는 기능
  • <T>: 타입 변수, 관례상 T, U, V 등 사용
  • 제네릭 함수: 함수 이름 뒤에 <T>
  • 제네릭 인터페이스: 인터페이스 이름 뒤에 <T>
  • 제네릭 클래스: 클래스 이름 뒤에 <T>
  • 제약조건: extends로 타입 제한
  • keyof: 객체 키를 타입으로 사용

다음 글에서는 유틸리티 타입에 대해 배워볼게요. Partial, Required, Pick, Omit 같은 TypeScript 내장 타입들입니다!


다음 글 보기

← 이전 글
TypeScript 독학 가이드 7 - 타입 별칭과 유니온 타입
다음 글 →
TypeScript 독학 가이드 9 - 유틸리티 타입
← 블로그 목록으로