제네릭
1. 제네릭
제네릭을 사용하면 타입을 마치 함수의 파라미터처럼 사용할 수 있습니다. 마치 데이터를 하나의 변수처럼 취급하는 것입니다. 제네릭은 함수, 클래스, 인터페이스 등에서 사용할 수 있습니다. 이렇게 사용하면 외부에서 타입을 지정할 수 있어 재사용성이 높아집니다.
1.1 제네릭 함수
여기서 함수 getValue
는 제네릭 타입 T
를 사용합니다. T
는 Type parameter의 약자입니다. 여기서 꼭 T
를 사용할 필요는 없습니다. 다른 문자를 사용해도 됩니다. 아래 코드를 작성하고 T
를 다른 글자로 바꿔보세요.
function getValue<T>(value: T): T {
return value;
}
const numberValue = getValue<number>(123); // number 타입
const stringValue = getValue<string>("hello"); // string 타입
console.log(numberValue);
console.log(stringValue);
function getValue<T>(value: T): T {
return value;
}
const numberValue = getValue<number>(123); // number 타입
const stringValue = getValue<string>("hello"); // string 타입
console.log(numberValue);
console.log(stringValue);
💡 제네릭을 사용하는데 꼭 T를 사용해야 하나요?
다른 문자를 사용해도 되지만 관례상 T
를 사용합니다. 다만 T
대신 명확한 타입 이름을 사용하는 것이 코드의 가독성을 높일 수 있습니다. 예를 들어, interface Licat<MPType>
, Licat<StatusType>
와 같은 형식으로도 사용할 수 있습니다.
여기서 getValue<number>(123);
로 호출하면 T
가 number
로 대체되어 function getValue<number>(value: number): number
와 같은 형태가 됩니다. 실제로는 function getValue(value: number): number
와 같은 형태가 됩니다.
좀 더 간단하게 사용하려면 <>
안에 타입을 지정하지 않아도 됩니다.
function getValue<T>(value: T) {
return value;
}
const numberValue = getValue(123); // number 타입
const stringValue = getValue("hello"); // string 타입
console.log(numberValue);
console.log(stringValue);
function getValue<T>(value: T) {
return value;
}
const numberValue = getValue(123); // number 타입
const stringValue = getValue("hello"); // string 타입
console.log(numberValue);
console.log(stringValue);
이렇게 사용하면 TypeScript가 타입을 추론하여 number
와 string
타입으로 지정합니다. return 값 또한 추론된 타입으로 반환됩니다.
1.2 제네릭 인터페이스
제네릭을 함수에서만 사용할 수 있는 것이 아닙니다. 인터페이스에서도 사용할 수 있습니다.
// 예제1
interface Container<T> {
value: T;
}
const numberContainer: Container<number> = { value: 10 };
const stringContainer: Container<string> = { value: "hello" };
console.log(numberContainer.value); // 10
console.log(stringContainer.value); // hello
// 예제1
interface Container<T> {
value: T;
}
const numberContainer: Container<number> = { value: 10 };
const stringContainer: Container<string> = { value: "hello" };
console.log(numberContainer.value); // 10
console.log(stringContainer.value); // hello
// 예제2
interface ApiResponse<T> {
status: number;
data: T;
error?: string;
}
interface UserData {
id: number;
name: string;
}
const response: ApiResponse<UserData> = {
status: 200,
data: {
id: 1,
name: "홍길동"
}
};
// 예제2
interface ApiResponse<T> {
status: number;
data: T;
error?: string;
}
interface UserData {
id: number;
name: string;
}
const response: ApiResponse<UserData> = {
status: 200,
data: {
id: 1,
name: "홍길동"
}
};
예제 1은 인터페이스를 정의하고 제네릭을 사용하였습니다. 간단하게 변수 하나를 가지고 있으며, 해당 변수는 제네릭으로 받은 타입을 가지고 있습니다. 예제 2는 조금 다릅니다. 인터페이스에 제네릭은 모든 변수에 적용되는 것이 아니라 특정 변수에 적용하였습니다.
이번에는 여러개의 인터페이스를 연결할 때 제네릭을 어떻게 사용하는지 살펴보도록 하겠습니다. 여기서 U
라는 키워드를 사용했는데 이는 추가적인 타입이 필요할 때 사용하는 것으로 T
와 같은 역할을 합니다. S
도 관례상 많이 사용합니다.
interface Cat<T> {
name: T;
age: number;
}
interface Licat<U> extends Cat<string> {
hp: number;
mp: U;
}
// mp를 number 타입으로 지정
const licat: Licat<number> = {
name: "licat",
age: 3,
hp: 100,
mp: 50
};
// mp를 string 타입으로 지정
const licat_bot: Licat<string> = {
name: "licat_bot",
age: 3,
hp: 100,
mp: "high"
};
interface Cat<T> {
name: T;
age: number;
}
interface Licat<U> extends Cat<string> {
hp: number;
mp: U;
}
// mp를 number 타입으로 지정
const licat: Licat<number> = {
name: "licat",
age: 3,
hp: 100,
mp: 50
};
// mp를 string 타입으로 지정
const licat_bot: Licat<string> = {
name: "licat_bot",
age: 3,
hp: 100,
mp: "high"
};
아래와 같이 여러개의 제네릭 타입을 사용할 수도 있습니다.
interface Cat<T> {
name: T;
age: number;
}
interface Licat<T, U> extends Cat<T> {
hp: number;
mp: U;
}
const licat: Licat<string, number> = {
name: "licat",
age: 3,
hp: 100,
mp: 50
};
const licat_bot: Licat<number, string> = {
name: 1, // name이 number 타입
age: 3,
hp: 100,
mp: "high"
};
interface Cat<T> {
name: T;
age: number;
}
interface Licat<T, U> extends Cat<T> {
hp: number;
mp: U;
}
const licat: Licat<string, number> = {
name: "licat",
age: 3,
hp: 100,
mp: 50
};
const licat_bot: Licat<number, string> = {
name: 1, // name이 number 타입
age: 3,
hp: 100,
mp: "high"
};
아래와 같이 인터페이스, class와 함께 사용할 수도 있습니다.
interface Container<T> {
value: T;
getValue(): T;
}
class StringContainer implements Container<string> {
value: string;
constructor(value: string) {
this.value = value;
}
getValue(): string {
return this.value;
}
}
const s = new StringContainer("hello");
console.log(s.getValue()); // hello
interface Container<T> {
value: T;
getValue(): T;
}
class StringContainer implements Container<string> {
value: string;
constructor(value: string) {
this.value = value;
}
getValue(): string {
return this.value;
}
}
const s = new StringContainer("hello");
console.log(s.getValue()); // hello
1.3 제네릭 클래스
클래스에서도 사용할 수 있습니다. 여기서 배열을 사용하는 Queue 클래스를 제네릭으로 만들어보겠습니다.
class Queue<T> {
private data: T[] = [];
push(item: T) {
this.data.push(item);
}
pop(): T | undefined {
return this.data.shift();
}
}
const numberQueue = new Queue<number>();
numberQueue.push(10); // OK
numberQueue.push("10"); // 에러: string 타입은 number 타입에 할당할 수 없습니다
class Queue<T> {
private data: T[] = [];
push(item: T) {
this.data.push(item);
}
pop(): T | undefined {
return this.data.shift();
}
}
const numberQueue = new Queue<number>();
numberQueue.push(10); // OK
numberQueue.push("10"); // 에러: string 타입은 number 타입에 할당할 수 없습니다
1.4 제네릭 사용 시 고려사항
제네릭을 사용할 때 몇 가지 주의사항이 있습니다.
// 안좋은 예
function printValue<T>(value: T): void {
console.log(value);
}
// 좋은 예
function printValue(value: any): void {
console.log(value);
}
// 안좋은 예
function printValue<T>(value: T): void {
console.log(value);
}
// 좋은 예
function printValue(value: any): void {
console.log(value);
}
위 코드는 제네릭을 사용할 필요가 없습니다. any
타입을 사용하면 어떤 타입이든 받을 수 있기 때문입니다. 이렇게 하면 '제네릭을 사용할 필요가 없겠다'라고 생각을 할 수 있습니다. 아래와 같은 경우를 가정해보겠습니다.
function addNumber(a: number, b: number): number {
return a + b;
}
function addString(a: string, b: string): string {
return a + b;
}
function addNumber(a: number, b: number): number {
return a + b;
}
function addString(a: string, b: string): string {
return a + b;
}
만약 이 2개의 함수를 합치고 싶었다고 가정해보도록 하겠습니다.
function add(a: any, b: any): any {
return a + b;
}
function add(a: any, b: any): any {
return a + b;
}
이렇게 하면 add
함수는 어떤 타입이든 받을 수 있지만, 반환값은 any
타입이 됩니다. 이렇게 하면 타입 안정성이 떨어지게 됩니다. 이런 경우에 제네릭을 사용하면 됩니다.
function add<T>(a: T, b: T): T {
return a + b; // 실제로는 에러가 발생합니다. 예시를 위한 코드입니다.
}
const result = add<number>(1, 2); // number 타입
const result2 = add<string>("hello", "world"); // string 타입
function add<T>(a: T, b: T): T {
return a + b; // 실제로는 에러가 발생합니다. 예시를 위한 코드입니다.
}
const result = add<number>(1, 2); // number 타입
const result2 = add<string>("hello", "world"); // string 타입
이렇게 하면 add
함수는 어떤 타입이든 받을 수 있고, 반환값도 받은 타입으로 반환됩니다.
2. 연습문제
-
다음 요구사항에 맞는 제네릭 함수를 작성해보세요.
- 배열을 입력받아 첫 번째 요소를 반환하는 함수
getFirstElement<T>
를 만드세요. - 배열이 비어있으면 undefined 반환해야 합니다.
- 모든 타입의 배열에 대해 동작해야 합니다.
- 배열을 입력받아 첫 번째 요소를 반환하는 함수
-
다음 요구사항에 맞는 제네릭 인터페이스와 함수를 작성해보세요.
- Key와 Value를 저장할 수 있는 제네릭 인터페이스
Storage<K, V>
를 만드세요. - Storage 인터페이스를 구현하는 setItem과 getItem 함수를 작성하세요.
- setItem은 key와 value를 받아서 저장하고, getItem은 key를 받아서 해당 value를 반환합니다.
- 저장된 key가 없는 경우 getItem은 undefined를 반환해야 합니다.
- Key와 Value를 저장할 수 있는 제네릭 인터페이스
3. 연습문제 정답
- 배열의 첫 번째 요소를 반환하는 제네릭 함수
function getFirstElement<T>(array: T[]): T | undefined {
return array.length > 0 ? array[0] : undefined;
}
// 사용 예시
const numbers = [1, 2, 3];
const firstNumber = getFirstElement(numbers); // 타입: number | undefined
const strings = ["a", "b", "c"];
const firstString = getFirstElement(strings); // 타입: string | undefined
const empty: number[] = [];
const noElement = getFirstElement(empty); // 타입: number | undefined, 값: undefined
console.log(firstNumber);
console.log(firstString);
console.log(noElement);
function getFirstElement<T>(array: T[]): T | undefined {
return array.length > 0 ? array[0] : undefined;
}
// 사용 예시
const numbers = [1, 2, 3];
const firstNumber = getFirstElement(numbers); // 타입: number | undefined
const strings = ["a", "b", "c"];
const firstString = getFirstElement(strings); // 타입: string | undefined
const empty: number[] = [];
const noElement = getFirstElement(empty); // 타입: number | undefined, 값: undefined
console.log(firstNumber);
console.log(firstString);
console.log(noElement);
- Key와 Value를 저장할 수 있는 제네릭 인터페이스와 함수
interface DataStorage<K extends string | number, V> {
setItem(key: K, value: V): void;
getItem(key: K): V | undefined;
}
class Store<K extends string | number, V> implements DataStorage<K, V> {
private data: Record<string, V> = {};
setItem(key: K, value: V): void {
this.data[String(key)] = value;
}
getItem(key: K): V | undefined {
return this.data[String(key)];
}
}
// 사용 예시
const store = new Store<string | number, string>();
store.setItem("name", "licat");
store.setItem(1, "mura");
console.log(store.getItem("name"));
console.log(store.getItem(1));
interface DataStorage<K extends string | number, V> {
setItem(key: K, value: V): void;
getItem(key: K): V | undefined;
}
class Store<K extends string | number, V> implements DataStorage<K, V> {
private data: Record<string, V> = {};
setItem(key: K, value: V): void {
this.data[String(key)] = value;
}
getItem(key: K): V | undefined {
return this.data[String(key)];
}
}
// 사용 예시
const store = new Store<string | number, string>();
store.setItem("name", "licat");
store.setItem(1, "mura");
console.log(store.getItem("name"));
console.log(store.getItem(1));