Steady-Dev
2024.12.03.
TypeScript의 Generic

TIL

제네릭(Generic)타입이란?

  • 제네릭이란 타입을 유연하게 제공해야할 때 사용하는 타입입니다.
    • 타입의 유연성이란 여러 타입을 사용할 수 있게 해주는 것입니다.
    • 제네릭은 함수가 호출될 때 동적으로 타입이 결정되기 때문에 타입의 유연성을 제공합니다.
  • 제네릭은 타입을 직접적으로 명시하지 않고 변수를 통해 언제든지 변할 수 있는 타입으로 만드는 것입니다. 즉 타입을 변수화 하는것입니다.

선언과 해석

// 기본적인 제네릭 선언 방식
function add<T>(a: T): T {
  return a + a;
}

T가 어떤 타입인지는 모르겠지만, 파라미터 a의 타입이 T 라는 것과, 리턴타입의 타입이 T라는 것을 알 수 있습니다. 즉, add 함수는 string 타입의 값을 넣으면 string 타입이 반환되고, number 타입의 값을 넣으면 number 타입이 반환된다는 것을 확인할 수 있습니다.

아래는 제네릭의 기본적인 사용 예시입니다.

function getText<T>(arg: T): T {
  return arg;
}

// 호출방법1. 함수에 파라미터 타입을 명시하는 방법
getText<string>('string');
getText<number>(123);
getText<boolean>(true);

// 호출방법2. 컴파일러가 타입을 자동으로 정하게 하는 방법 (타입 인수 추론 이용)
// 보편적인 방식
getText('string');
getText(123);
getText(true);

getText 함수에 T라는 타입변수를 추가했습니다. T의 타입은 getText("string");이 호출되면 T의 타입변수 값으로 string을 전달합니다. 즉, 아래와 같이 정의됩니다.

function getText<string>(text: string): string {
  return text;
}

이쯤에서 궁금증이 생겼습니다. 타입의 유연성을 제공하는 다양한 타입들이 이미 존재하는데, 왜 제네릭이 필요한걸까요?


제네릭을 사용해야 하는 이유?

any를 사용하면 안되는 이유

any를 사용하게 되면 함수가 반환할 때 어떤 타입인지에 대한 정보는 잃게 됩니다. number 타입을 넘긴다고 해도 any타입이 반환이 됩니다.

반면 제네릭을 사용하게 되면 함수를 호출할때 넘긴 타입에 대해 타입스크립트가 추정할 수 있게 됩니다.

function func1(arg: any): any {
  return arg;
}

const example1 = func1(1); // example1: any
example1.split('');
// any type이기 때문에 컴파일 에러가 발생하지 않는다.
// ❗️ Uncaught Error: example1.split is not a function: 런타임 에러 발생

function func2<T>(arg: T): T {
  return arg;
}

const example2 = func2(1); // example2: 1
example2.split('');
// ❗️ error: Property 'split' does not exist on type '1': 컴파일 에러 발생

example1의 타입은 any이고, example2의 type은 1(number)입니다. 그래서 컴파일 단계에서 example2.split(""); 에러가 표출됩니다.

반면 example1의 경우, 타입이 any로 세팅되어 컴파일 에러가 발생하지 않습니다. 그러나 런타임 에러(Uncaught Error: example1.split is not a function)가 발생합니다. ts를 사용하는 이유는 컴파일 단계에서 타입을 검사하여 오류를 미리 잡아내는 것인데 any를 사용하면 타입스크립트를 사용하는 의미 자체가 없어집니다.

union을 사용하면 되지 않을까?

  1. 인자와 리턴타입 간의 연결성이 유지되지 않습니다.
// union으로 구현한 경우 value의 값이 number인지 string인지 알 수 없음
function wrapUnion(value: number | string): { value: number | string } {
  return { value };
}

const result = wrapUnion(123); // { value: number | string }

// 제네릭으로 구현한 경우 정확한 값을 추론 가능
function func<T>(value: T): { value: T } {
  return { value };
}

const wrappedNumber = func(123); // { value: number }
const wrappedString = func('hello'); // { value: string }
  1. 코드 재사용성과 확장성 부족

새로운 타입을 추가하거나 변경하려면 유니언 타입은 코드를 수정해야 하지만 제네릭은 특정 타입에 의존하지 않으므로 더 높은 재사용성과 확장성을 제공합니다.

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

identity(42); // number
identity('hello'); // string
identity(true); // boolean

function identityUnion(
  value: number | string | boolean,
): number | string | boolean {
  return value;
}
  1. 제약조건 적용 불가

유니언으로는 아래와 같은 제약사항을 구현하기 어렵습니다.

interface HasId {
  id: number;
}

function processWithId<T extends HasId>(value: T): number {
  return value.id;
}

processWithId({ id: 1, name: 'Item' }); // 정상 작동
processWithId({ name: 'Item' }); // 컴파일 에러: 'id' 속성이 없음

위와 이유로 제네릭의 사용을 권장합니다.


주의할 점

배열 제네릭은 배열임을 명시해야합니다.

function func<T>(arg: T[]): T[] {
  console.log(arg.length);
  return arg;
}

const firstArr = func([1, 2, 3]); // 💡 firstArr: number[]
const secondArr = func(['a', 'b', 'c']); // 💡 secondArr: string[]

제네릭의 활용

인터페이스 + 제네릭

인터페이스와 제네릭을 함께 사용하면 코드 유연성과 타입 안전성을 강화할 수 있습니다.

아래의 코드는 metadata를 제네릭으로 타입 지정하여 다양한 타입으로 선언 가능한 코드입니다.

interface MetaData {
  sex: string;
  height: 'tall' | 'short';
  favouriteNumber: number;
}

interface Person<T> {
  id: number;
  name: string;
  age: number;
  metadata: T; // 💡 metadata를 제네릭으로 선언
}

const personOne: Person<(number | string)[]> = {
  id: 1,
  name: 'Jeff',
  age: 31,
  metadata: ['male', 'tall', 22], // 💡 다양한 값 선언 가능
};

const personTwo: Person<MetaData> = {
  id: 1,
  name: 'Jeff',
  age: 31,
  // 💡 다양한 값 선언 가능
  metadata: {
    sex: 'female',
    height: 'tall',
    favouriteNumber: 45,
  },
};

아래는 조금 더 실용적인 예시입니다. ApiResponse의 data값을 제네릭으로 선언하여 유연한 타입을 명시하였습니다.

interface ApiResponse<T> {
  data: T; // 💡 data를 제네릭으로 선언
  success: boolean;
  error?: string;
}

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

const userResponse: ApiResponse<User> = {
  data: { id: 1, name: 'Alice' }, // 💡 다양한 값 선언 가능
  success: true,
};

const stringResponse: ApiResponse<string> = {
  data: 'Operation Successful', // 💡 다양한 값 선언 가능
  success: true,
};

제네릭 제약조건 (Generic Constraints)

특정 타입들로만 동작하는 제네릭 함수를 만들고 싶을 때 사용합니다.

아래는 length의 값을 number type으로 제약조건을 주는 예시입니다.

interface Length {
  length: number;
}

// 💡 interface를 extends하기
function func<T extends Length>(arg: T): T {
  console.log(arg.length);
  return arg;
}

func(3); // Argument of type 'number' is not assignable to parameter of type 'Length'.ts(2345)
func({ length: 10, value: 3 }); // 10
func([1, 2, 3]); // 3

객체의 키값을 제약조건으로 활용한 예시입니다.

// K는 T의 key로 제약조건
function func<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

const x = { a: 1, b: 2, c: 3, d: 4 };

func(x, 'a');
// m은 x의 key로 존재하지 않는다
func(x, 'm'); // Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.ts

아래는 key값에 제약을 두는 제네릭 인터페이스의 예시입니다.

interface Dictionary<K extends string | number, V> {
  // 키 값이 string이거나 number로 제약조건
  key: K;
  value: V;
}

const numberToString: Dictionary<number, string> = { key: 1, value: 'One' };
const stringToBoolean: Dictionary<string, boolean> = {
  key: 'isActive',
  value: true,
};

아래는 type alias 사용한 예시입니다.

type TG<T> = T[] | T;

const number_arr: TG<number> = [1, 2, 3, 4, 5];
const number_arr2: TG<number> = 12345;

const string_arr: TG<string> = ['1', '2', '3', '4', '5'];
const string_arr2: TG<string> = '12345';

Generic 이름 규칙

보편적으로 통용되는 generic 이름 규칙입니다.

인자 설명
T Type, 타입
E Element, 요소
K Key, 키
V Value, 값
N Number, 숫자

Reference

https://www.typescriptlang.org/ko/docs/handbook/2/generics.html

© 2023 datoybi.com
Powered By Gatsby. Hosted By Netlify.