Skip to content

제네릭 타입 (Generic Types)

제네릭이란, 특정한 타입에 고정되지 않고 여러 타입을 처리할 수 있도록 하는 문법입니다. 이는 코드 재사용성을 높이는 용도로 사용됩니다.

제네릭 맛보기

예시를 하나 들어보겠습니다.

우리는 지금 API 서버를 만들고 있습니다. 모든 API 응답은 JSON 형식으로 data와 error가 포함되며, 정상 응답일 때는 data에 요청에 대한 응답 데이터와 error는 null이며, 에러 응답일 때는 data는 null이며 error에 에러 정보가 포함되어 있습니다.

이걸 타입으로 표현해보면 다음과 같습니다.

type APIResponse =
| {
data: any;
error: null;
}
| {
data: null;
error: string;
};

👉 TS Playground에서 타입스크립트 컴파일러의 동작을 직접 확인해보세요.

정상 응답과 에러 응답을 유니온으로 잘 표현했습니다. 하지만 정상 응답의 data 타입이 any인게 마음에 걸립니다. 각기 다른 API마다 정해진 데이터 모양이 있을 것이기에, 이렇게 타입을 any로 두는건 타입스크립트의 장점을 활용하지 못하는 것 같습니다.

그렇다면 모든 API의 응답 타입을 따로 만드는 방식을 고민해 볼 수 있습니다.

type UserAPIResponse =
| {
data: { name: string; age: number };
error: null;
}
| {
data: null;
error: string;
};
type ProductAPIResponse =
| {
data: { name: string; price: number };
error: null;
}
| {
data: null;
error: string;
};

👉 TS Playground에서 타입스크립트 컴파일러의 동작을 직접 확인해보세요.

이제 각각의 API에 맞는 응답 타입을 만들었기에 data의 타입이 any가 아닌 실제 타입으로 정의할 수 있습니다. 하지만 이 방식 또한 아쉬움이 있습니다. 바로 API가 추가될 때마다 API 응답 타입을 계속 추가해야 한다는 점입니다. API가 많아지면 많아질수록 이는 더욱 번거로워질 것입니다. 번거로운 작업도 문제가 될 수 있지만 가장 큰 문제는 API 응답 형태의 수정이 생겨 무수히 생성된 각각의 API 타입을 수정하다 놓치면 문제가 발생 할 수 있습니다.

이런 상황에서 제네릭을 사용하면 좋습니다.

함수가 매개변수로 값을 받듯이, 제네릭은 매개변수로 타입을 받습니다.

먼저 API 요청의 성공, 실패 여부에 따라 data와 error가 배타적으로 존재하는 뼈대를 다시 준비합니다. 기존에 data의 타입을 any로 두었던 것을 제네릭 매개변수로 대체합니다.

type APIResponse<D> =
| {
data: D;
error: null;
}
| {
data: null;
error: string;
};

👉 TS Playground에서 타입스크립트 컴파일러의 동작을 직접 확인해보세요.

APIResponse 타입 별칭 뒤에 <>가 생겼습니다. 이것은 제네릭 매개변수를 선언하는 문법입니다. 예시에서는 D라는 이름의 제네릭 매개변수를 받아 data의 타입으로 사용했습니다. 이를 통해 APIResponse 타입 하나로 API 응답마다 다른 data 타입을 처리할 수 있게 되었습니다. 마치 함수에서 매개변수로 어떤 값이 들어오는지에 따라 결과가 달라지는 것처럼, 제네릭 타입을 사용하면 어떤 타입이 들어오느냐에 따라 타입이 달라집니다.

type APIResponse<D> =
| {
data: D;
error: null;
}
| {
data: null;
error: string;
};
type User = { name: string; age: number };
type UserAPIResponse = APIResponse<User>;
const userAPIResponse = {
data: { name: "김바코", age: 10 },
error: null,
};
type Product = { name: string; price: number };
type ProductAPIResponse = APIResponse<Product>;
const productAPIResponse = {
data: { name: "컴퓨터", price: 1000000 },
error: null,
};

👉 TS Playground에서 타입스크립트 컴파일러의 동작을 직접 확인해보세요.

이제 많은 API가 생겨도 API 응답 타입을 새롭게 정의할 필요 없이 APIResponse 제네릭 타입에 들어갈 data 타입만 정의해주면 됩니다. 그리고 API 응답 타입의 수정이 생겨도 모든 타입을 건들일 필요 없이 APIResponse 제네릭 타입만 수정하면 되기에 훨씬 안전한 코드가 되었다고 볼 수 있습니다.

이것이 제네릭의 힘입니다. 제네릭을 사용하면 코드의 재사용성을 높일 수 있습니다. 함수가 매개변수로 값을 받듯이, 제네릭은 매개변수로 타입을 받습니다. 이를 통해 타입을 고정하지 않고 여러 타입을 처리할 수 있게 되며, 이는 코드의 유연성을 높이고, 코드의 안정성을 높일 수 있습니다.

제네릭 타입의 종류

타입스크립트에서 제네릭을 적용할 수 있는 타입은 4개 입니다.

  1. 제네릭 인터페이스 (Generic Interface)
  2. 제네릭 타입 별칭 (Generic Type Alias)
  3. 제네릭 함수 (Generic Function)
  4. 제네릭 클래스 (Generic Class)

제네릭 인터페이스 (Generic Interface)

제네릭 인터페이스는 인터페이스에 제네릭을 적용한 것입니다. 제네릭 인터페이스를 선언할 때는 인터페이스 이름 뒤에 제네릭 매개변수를 선언합니다. 선언한 제네릭 매개변수 이름은 인터페이스 내부에서 타입으로 사용할 수 있습니다.

interface Person<Name> {
name: Name;
age: number;
}
interface KoreanName {
firstName: string;
lastName: string;
}
const korean: Person<KoreanName> = {
name: {
firstName: "",
lastName: "바코",
},
age: 10,
};
interface AmericanName {
firstName: string;
middleName: string;
lastName: string;
}
const american: Person<AmericanName> = {
name: {
firstName: "John",
middleName: "F.",
lastName: "Kennedy",
},
age: 10,
};

👉 TS Playground에서 타입스크립트 컴파일러의 동작을 직접 확인해보세요.

단순히 객체 내 프로퍼티의 타입에 제네릭 매개변수를 적용하는 것 뿐만 아니라, 인터페이스가 확장하는 인터페이스에도 제네릭을 적용할 수 있습니다. 이때는 인터페이스 이름 뒤에 선언한 제네릭 매개변수를, 확장하는 인터페이스 이름 뒤에도 제네릭 매개변수를 선언합니다.

interface Person<Name> {
name: Name;
age: number;
}
interface KoreanName {
firstName: string;
lastName: string;
}
interface Developer<Name> extends Person<Name> {
skills: string[];
}
const koreanDeveloper: Developer<KoreanName> = {
name: {
firstName: "",
lastName: "바코",
},
age: 10,
skills: ["JavaScript", "TypeScript"],
};

👉 TS Playground에서 타입스크립트 컴파일러의 동작을 직접 확인해보세요.

제네릭 타입 별칭 (Generic Type Alias)

제네릭 타입 별칭은 타입 별칭에 제네릭을 적용한 것입니다. 제네릭 타입 별칭을 선언할 때는 타입 별칭 이름 뒤에 제네릭 매개변수를 선언합니다. 선언한 제네릭 매개변수 이름은 타입 별칭 내부에서 타입으로 사용할 수 있습니다.

type Person<Name> = {
name: Name;
age: number;
};
type KoreanName = {
firstName: string;
lastName: string;
};
const korean: Person<KoreanName> = {
name: {
firstName: "",
lastName: "바코",
},
age: 10,
};
type AmericanName = {
firstName: string;
middleName: string;
lastName: string;
};
const american: Person<AmericanName> = {
name: {
firstName: "John",
middleName: "F.",
lastName: "Kennedy",
},
age: 10,
};

👉 TS Playground에서 타입스크립트 컴파일러의 동작을 직접 확인해보세요.

제네릭 함수 (Generic Function)

제네릭 함수는 함수에 제네릭을 적용한 것입니다. 제네릭 함수를 선언할 때는 함수 이름 뒤에 제네릭 매개변수를 선언합니다. 선언한 제네릭 매개변수 이름은 함수 내부에서 타입으로 사용할 수 있습니다.

function useState<S>(initialState: S): [S, (newState: S) => void] {
// useState를 흉내냈을뿐, 실제 useState 구현과 다릅니다.
let state = initialState;
const setState = (newState: S) => {
state = newState;
};
return [state, setState];
}
const [count, setCount] = useState<number>(0);
const [userName, setUsername] = useState<string>("김바코");

👉 TS Playground에서 타입스크립트 컴파일러의 동작을 직접 확인해보세요.

제네릭 클래스 (Generic Class)

제네릭 클래스는 클래스에 제네릭을 적용한 것입니다. 제네릭 클래스를 선언할 때는 클래스 이름 뒤에 제네릭 매개변수를 선언합니다. 선언한 제네릭 매개변수 이름은 클래스 내부에서 타입으로 사용할 수 있습니다.

class Person<Name> {
name: Name;
age: number;
constructor(name: Name, age: number) {
this.name = name;
this.age = age;
}
}
class Developer<Name> extends Person<Name> {
skills: string[];
constructor(name: Name, age: number, skills: string[]) {
super(name, age);
this.skills = skills;
}
}

👉 TS Playground에서 타입스크립트 컴파일러의 동작을 직접 확인해보세요.

제네릭 매개변수에 제약 걸기

제네릭을 이용해 여러 타입을 처리할 수 있게 되었지만, 때로는 특정 타입만을 처리하도록 제약을 걸어야 할 때가 있습니다.

예를 들어 객체의 length 프로퍼티를 사용하는 제네릭 함수가 있습니다. length 프로퍼티가 없다면 원치 않는 결과가 나올 수 있습니다. 이럴 때 제네릭 인수로 전달 될 타입이 length 프로퍼티를 가진 객체만 받도록 제약을 걸 수 있습니다.

제약은 제네릭 매개변수 이름 뒤에 extends 키워드를 사용하고, 제약을 걸 타입을 명시합니다.

// T는 반드시 length 프로퍼티를 가진 타입이어야 함
function logLength<T extends { length: number }>(item: T) {
console.log(`Length is ${item.length}`);
}
logLength({ length: 10 }); // 출력: 10
// 문자열은 length 프로퍼티를 가지고 있다
logLength("Hello World"); // 출력: 11
// 배열도 length 프로퍼티를 가지고 있다
logLength([1, 2, 3]); // 출력: 3
// 에러 - 객체가 아님
logLength(123);
// 에러 - length 프로퍼티가 없음
const obj = { name: "김바코" };
logLength(obj);

👉 TS Playground에서 타입스크립트 컴파일러의 동작을 직접 확인해보세요.

제네릭 매개변수 기본 타입

함수의 매개변수에 기본 값을 지정할 수 있듯이, 제네릭 매개변수에도 기본 타입을 지정할 수 있습니다. 제네릭 매개변수 뒤에 =를 사용하고 기본 타입을 지정합니다. 제네릭 매개변수에 기본 타입을 지정하면 제네릭 인수를 생략할 수 있습니다.

interface KoreanName {
firstName: string;
lastName: string;
}
interface AmericanName {
firstName: string;
middleName: string;
lastName: string;
}
// Name 제네릭 매개변수에 기본 타입 KoreanName을 지정
interface Person<Name = KoreanName> {
name: Name;
age: number;
}
// Name 제네릭 매개변수를 생략하면 기본 타입 KoreanName이 사용됨
// Person<KoreanName>과 동일
const person: Person = {
name: {
firstName: "바코",
lastName: "",
},
age: 10,
};

👉 TS Playground에서 타입스크립트 컴파일러의 동작을 직접 확인해보세요.

제네릭 매개변수 뒤에 기본 값이 없는 다른 제네릭 매개변수를 선언할 수 없습니다.

interface KoreanName {
firstName: string;
lastName: string;
}
// 에러
interface Person<Name = KoreanName, Address> {
name: Name;
age: number;
address: Address;
}

👉 TS Playground에서 타입스크립트 컴파일러의 동작을 직접 확인해보세요.

제네릭 매개변수 제약과 기본 타입 함께 사용하기

일반적으로 제네릭 매개변수 제약과 기본 타입은 함께 사용됩니다. 제네릭 매개변수 제약과 기본 타입을 함께 사용할 때는 제네릭 매개변수 제약을 먼저 선언하고, 그 뒤에 기본 타입을 선언합니다.

interface KoreanName {
firstName: string;
lastName: string;
}
// Name 제네릭 매개변수는 KoreanName 타입을 준수해야하며,
// 인수 전달을 생략하면 기본 타입로 KoreanName이 사용됩니다.
interface Person<Name extends KoreanName = KoreanName> {
name: Name;
age: number;
}

👉 TS Playground에서 타입스크립트 컴파일러의 동작을 직접 확인해보세요.