제네릭 타입 쉽게 이해하기 ☺

박희수·2024년 5월 7일
0
post-thumbnail

🙋‍♀️ 이 포스트는 '우아한 타입스크립트 with React' 도서를 읽고 적은 글입니다.

1. 제네릭이란? 🎈

제네릭의 개념을 간단히 설명하자면 함수, 타입, 클래스 등에서 내부적으로 사용할 타입을 미리 정해두지 않고 타입 변수를 사용해 해당 위치를 비워둔 다음, 실제 그 값을 사용할 때 외부에서 타입을 지정해 사용하는 것을 말합니다.

다른 정적 언어에서 타입 간 재사용성을 높이기 위해 사용하곤 하는데요, 타입스크립트도 정적 타입을 가지는 언어이기 때문에 제네릭 문법을 지원하고 있습니다.

우선 바로 간단한 예시를 보겠습니다.

type ExampleArrayType<T> = T[];

const array1 : ExampleArrayType<string> = ['치킨', '피자', '우동'];

-> 위의 예시와 같이 타입 변수를 일반적으로 와 같이 꺾쇠 괄호 내부에 정의되며, 사용할 때는 함수에 매개변수를 넣는 것과 유사하게 원하는 타입을 넣어주면 됩니다. 위의 예시에서는 string 타입을 넣어 사용하고 있네요.

이런식으로 제네릭 사용하게 되면 함수, 타입, 클래스 등 여러 타입에 대해 하나하나 따로 정의하지 않아도 되기 때문에 재사용성이 크게 향상됩니다.


한가지 덧붙이자면, 제네릭 함수를 호출할 때 반드시 꺾쇠괄호(<>) 안에 타입을 명시해야 하는 것은 아닙니다. 타입을 명시하는 부분을 생략해도, 컴파일러가 인수를 보고 타입을 추론합니다.

function exampleFunc<T>(arg:T) : T[] {
  return new Array(3).fill(arg);
}

exampleFunc('hello'); // T는 string으로 추론됩니다.

2. 다양한 제네릭 예시

제네릭의 다양한 예시를 보겠습니다.

2-1. 함수의 제네릭

가장 기본적으로 함수에서 매개변수나 반환 값에 다양한 타입을 넣고 싶을 때 제네릭을 사용합니다.


function ReadOnlyRepository<T>(target : ObjectType<T> | EntitySchema<T> | string) : Repository<T> {
	return getConnection('ro').getRepository(target);
}

-> 위 코드에서는 target이라는 매개변수를 ObjectType, EntitySchema의 제네릭 타입에 원하는 타입을 넣어 반환되는 타입 또는 string을 타입으로 가질 수 있습니다. 또한, 반환값의 타입은 Repository 제네릭 타입에 원하는 타입을 넣어 반환되는 타입으로 가집니다.

📌 저는 책에서 이 코드를 보고 직접 실행을 해보고 싶어서 간단하게 바꿔 에러가 나는지, 잘 굴러가는지 확인해봤습니다.

type ObjectType<T> = T;
type EntitySchema<T> = T;
type Repository<T> = T[];

function ReadOnlyRepository<T>(target: ObjectType<T> | EntitySchema<T> | string): Repository<T> {
  return new Array(4).fill(target);
}

ReadOnlyRepository<number>(3);

위의 예시가 어렵다면, 이런식으로 간단히 타입을 지정해서 테스트한 코드를 봐도 좋을 것 같습니다.

2-2. 호출 시그니처의 제네릭

호출 시그니처란?
-> 호출 시그니처는 타입스크립트의 함수 타입 문법으로 함수의 매개변수와 반환 타입을 미리 선언하는 것을 말합니다. 호출 시그니처를 사용함으로써 개발자는 함수 호출시 필요한 타입을 별도로 지정할 수 있게 됩니다.

export type UseRequesterHookType = <RequestData = void, ResponseData = void>(baseURL?: string | Headers, defaultHeader?: Headers) 
	=> [RequestStatus, Requester<RequestData, ResponseData>];

-> 위의 예시와 같이 함수 호출시 필요한 타입을 별도로 지정할 때는 매개변수를 가리키는 괄호 앞에 제네릭 타입을 구체 타입으로 한정할 수 있습니다.
위 코드에서 RequestData와 ResponseData라는 제네릭 타입을 받고, 반환 타입을 지정할 때 이를 사용하는 것을 알 수 있습니다.

2-3. 제네릭 클래스

제네릭 클래스는 외부에서 입력된 타입을 클래스 내부에 적용할 수 있는 클래스입니다.
제네릭 클래스는 아래와 같은 형태로 선언됩니다.

class LocalDB<T> {
// ...
async put(table : string, row : T):Promise<T> {
 return new Promise<T>((resolved, rejected) => {
  // T 타입의 데이터를 DB에 저장});
}

async get(table : string, key : any):Promise<T>{
 return new Promise<T>((resolved, rejected) => {
  // T 타입의 데이터를 DB에서 가져옴 });
});
 }

async getTable(table : string):Promise<T[]>{
 return new Promise<T[]>((resolved, rejected) => {
  });
}
}

export default class IndexDB implements ICacheStore {
private _DB?: LocalDB<{ key : string; value : Promise<Record<string, unknown>>; cacheTTL : number }>;

private DB() {
 if (!this._DB){
 	this._DB = new LocalDB('localCache', {ver : 6, tables : [{ name : TABLE_NAME, keyPath : 'key' }] });
	}
	return this._DB;
 }
// ... 
}

-> 클래스 이름 뒤에 타입 매개변수인 를 선언해줍니다.
는 메서드의 매개변수나 반환 타입으로 사용될 수 있습니다. LocalDB 클래스는 외부에서 { key : string; value : Prmise<Record<string, unknown>>; cacheTTL : number} 타입을 받아야들려 클래스 내부에서 사용될 제네릭 타입으로 결정됩니다.

클래스도 크게 다르게 사용되는 건 아니지만, 제네릭 클래스를 사용하면 클래스 전체에 걸쳐 타입 매개변수가 적용됩니다. 특정 메서드만을 대상으로 제네릭을 적용하려면 해당 메서드를 제네렉 메서드로 선언하면 됩니다.

2-4. 제한된 제네릭

타입스크립트에서 제한된 제네릭은 타입 매개변수에 대한 제약 조건을 설정하는 기능을 말합니다.

예를 들어 string 타입으로 제약하려면 타입 매개변수는 특정 타입을 상속(extends)해야 합니다. 즉, 제네릭 타입으로 들어오는 타입을 string이거나 string으로 평가될 수 있는 하위타입이어야 한다고 제약을 걸어두는 것입니다.

type ErrorRecord<Key extends string> = Exclude<Key, ErrorCodeType> extends never ? Partial<Record<Key, boolean>> : never;

-> 위 코드는 제네릭 타입 ErrorRecord를 정의하고 있습니다.
✔ 이 타입은 Key라는 제네릭 타입 매개변수를 받으며, Key는 string 타입에 할당될 수 있는 타입이어야 합니다.
✔ Exclude<Key, ErrorCodeType> 부분은 Key 타입에서 ErrorCodeType에 할당할 수 있는 모든 타입을 제외한 새로운 타입을 만듭니다.

✔ 만약, Key와 ErrorCodeType 사이에 겹치는 타입이 없다면, 결과적으로 Key타입 자체가 됩니다. 하지만 만약 모든 Key가 ErrorCodeType에 할당 가능하다면, 결과는 never 타입이 됩니다.

✔ extends never 조건은 Exclude의 결과가 never인지를 체크합니다. 즉, Key와 ErrorCodeType 사이에 겹치는 타입이 전혀 없을 경우를 체크합니다.

🌼 요약하자면, 이 타입은 Key가 ErrorCodeType에 포함되지 않는 문자열 타입일 경우에만 유효한 에러 레코드 타입을 생성합니다. 만약 key가 ErrorCodeType에 완전히 포함된다면 이 타입은 유효하지 않습니다. 이런 코드는 특정한 에러 코드를 제외한 나머지 에러들에 대한 레코드 타입을 안전하게 생성하고자 할 때 유용할 수 있습니다.

짧은 코드에서 설명이 길어졌는데, 타입 상속이 가능한 범위는 기본 뿐만 아니라 인터페이스나 클래스도 사용 가능합니다. 또한 유니온 타입을 상속해서 선언할 수도 있습니다.

2-5. 확장된 제네릭

제네릭 타입은 여러 타입을 상속받을 수 있으며 타입 매개변수를 여러 개 둘 수도 있습니다.

<Key extends string>

사실, 타입을 이렇게 제약하면 제네릭의 유연성을 잃을 수 있습니다. 제네릭의 유연성을 잃지 않으면서 타입을 제약해야 할 때는 타입 매개변수에 유니온 타입을 상속해서 선언하면 됩니다.

<Key extends string | number>

유니온 타입으로 T가 여러 타입을 받게 할 수는 있지만, 타입 매개변수가 여러 개일 때는 처리할 수 없습니다. 이럴 때는 매개변수를 하나 더 추가해 선언할 수 있습니다.


3. 제네릭 사용하기

제네릭의 장점은 다양한 타입을 받게 함으로써 코드를 효율적으로 재사용할 수 있는 것입니다. 그렇다면 실제 현업에서 가장 많이 제네릭이 활용될 때는 언제일까요?
바로 API 응답 값의 타입을 지정할 때입니다.

export interface MobileApiResponse<Data> {
  data : Data;
  statusCode : string;
  statusMessage?: string;
}

위 코드를 보면, API 응답 값에 따라 달라지는 data를 제네릭타입 Data로 선언하고 있습니다.
이렇게 만든 MobileApiResponse는 실제 API 응답 값의 타입을 지정할 때 아래와 같이 사용된다고 합니다.

export const fetchPriceInfo = (): Promise<MobileApiResponse<PriceInfo>> => {
  const priceUrl = 'https:~~'; 
  
  return request({
  	method:'GET',
  	url : priceUrl,
  });
};
export const fetchOrderInfo = (): Promise<MobileApiResponse<Order>> => {
  const orderUrl = 'https:~~';
  
  return request({
  	method : 'GET', 
  	url : orderUrl,
  });
};

이처럼 다양한 API 응답 값의 타입에 MobileApiResponse을 활용해서 코드를 효율적으로 재사용할 수 있습니다.

profile
프론트엔드 개발자입니다 :)

0개의 댓글