Posts
typescript
TypeScript - Generic, Utility Type

참고자료

  • O’reilly 타입스크립트 프로그래밍
  • ChatGPT 4.0

Generic

1. TypeScript Generics 이해하기

TypeScript의 Generics은 함수나 클래스의 인스턴스 생성 시점에 그 타입을 선언하는 방식입니다. 이를 통해 재사용 가능한 코드를 만들 수 있으며, 타입 안정성을 유지하면서 유연성을 높일 수 있습니다.

Generics는 타입 변수로, 보통 T라는 글자를 많이 사용합니다. 이 변수는 아무 타입이나 대입될 수 있습니다.

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

2. Generics 예제

Generics는 다양한 방법으로 사용될 수 있습니다. 위의 예제는 가장 기본적인 형태이며,

이를 다양한 방식으로 확장할 수 있습니다.

2.1 함수와 Generics

function identity<T>(arg: T): T {
    return arg;
}
 
let output = identity<string>("myString");
 
console.log(output); // "myString"

2.2 배열과 Generics

function loggingIdentity<T>(arg: T[]): T[] {
    console.log(arg.length); // Array has a .length
    return arg;
}

참고: Array<T> = T[]

3. Generics에 제약조건 설정 : extends

Generics에 제약조건을 추가하는 것도 가능합니다. 이는 extends 키워드를 사용하여 수행됩니다.

interface Lengthwise {
    length: number;
}
 
function loggingIdentity<T extends Lengthwise>(arg: T): T {
    console.log(arg.length); // Now we know it has a .length property, so no more error
    return arg;

4. Generic Types and Classes

4.1 Generic Types

function identity<T>(arg: T): T {
    return arg;
}
 
let myIdentity: <T>(arg: T) => T = identity;

4.2 Generic Classes

 
class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}
 
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y; };
 

5. 언제 Generic Parameter가 한정되는가?

제네릭 함수와 제네릭 별칭의 차이

  • 제네릭 별칭(Generic Type Alias): 해당 타입 별칭(Type Alias)을 사용할 때 parameter에 타입이 한정된다.
  • 제네릭 함수(Generic Function) : 함수가 호출될 때 받은 parameter에 따라 타입이 한정된다.

차이가 뭐지? 말장난 같은데?라는 생각이 든다면 예시를 보자.

// Generic Type Alias
type SliceFruit<Fruit> = (fruit: Fruit): Fruit[];
 
const sliceBanana: SliceFruit<Banana> = (banana) => {
	// ...
}
const Component: React.FC<ComponentProps> = () => {}

위에서 FruitSliceFruit 범위로 한정되어있다.

그리고 해당 타입을 사용할 때 Fruit을 명시적으로 구체화해야한다.

SliceFruit<Banana> , React.FC<CompoenntProps> 부분이 타입을 사용할 때 이다.

이 때 인수를 꺽쇠 안에 넣어 제네릭 파라미터의 타입을 구체화하고 있다.

 
// Generic Function Type
type SliceFruit = <Fruit>(fruit: Fruit): Fruit[];
 
const sliceBanana: SliceFruit = (banana) => {};

여기서는 <Fruit>을 호출 시그니쳐의 일부로 선언하였기 때문에 함수가 호출되는 시점에서 Fruit의 타입이 추론될 것이다.

Fruit의 범위는 SliceFruit이라는 type이 아니라 함수 호출 시그니쳐 부분 (fruit: Fruit): Fruit[] 이라고 할 수 있다.

정리 - 제네릭 타입이 구체화되는 시점 비교

  • Generic Function Type: 함수로 전달된 인수를 통해서 함수가 호출될 때 제네릭 타입이 구체화된다.
  • Generic Type Alias : 해당 alias assertion등으로 사용할 때 꺽쇠 안에 타입 인수를 넘겨 제네릭 타입이 구체화된다.

개발에 적용한 부분

Mui에서 제공하는 Date/TimePicker 들은 모두 LocalizationProvider로 감싸주어 사용해야하는데, 여기에 사용하는 date 라이브러리의 dateAdaptor를 prop으로 넘겨준다.

이 때 DatePickerProps 등 내부 prop에서는 TDate라는 제네릭 파라미터를 사용하는데,

TDate의 타입은 분명 해당 컴포넌트가 LocalizationProvider 내에서 사용될 때 특정된다.

// mui DateCalendar.d.ts
 
export interface DateCalendarProps<TDate>extends ExportedDateCalendarProps<TDate>, ExportedUseViewsOptions<DateView>,
			SlotsAndSlotProps<DateCalendarSlotsComponent<TDate>,
			DateCalendarSlotsComponentsProps<TDate>> {
    /**
     * The selected value.
     * Used when the component is controlled.
     */
    value?: TDate | null;
    /**
     * The default selected value.
     * Used when the component is not controlled.
     */
    defaultValue?: TDate | null;

Generic Alias만 사용해본 나로서는, 어떻게 generic parameter가 함수가 사용되면서 특정되게 할 수 있는지 알 수 없었다. 항상 타입을 선언할 때 generic parameter를 특정해주어야했기 때문이다.

하지만 내부 mui 코드를 보면, DatePicker 등 Date 컴포넌트는 다음과 Generic Function Type을 사용하고 있다.

type DateCalendarComponent = (<TDate>(props: DateCalendarProps<TDate> & React.RefAttributes<HTMLDivElement>) => JSX.Element) & {
    propTypes?: any;
};
 
type DateTimeFieldComponent = (<TDate>(props: DateTimeFieldProps<TDate> & React.RefAttributes<HTMLDivElement>) => JSX.Element) & {
    propTypes?: any;
};

이와 같이 구현하려고 했던 DateRangePicker 컴포넌트도 Generic Function Type을 사용하여 호출될 때 generic parameter가 구체화될 수 있게 되었다.

type MyDateRangePickerComponent = <TDate>(
  props: MyDateRangePickerProps<TDate> & React.RefAttributes<HTMLDivElement>
) => JSX.Element;

5. Generics를 사용하는 이유

Generics를 사용하면 여러 종류의 타입을 한 가지 방법으로 처리할 수 있다.

⇒ 다양한 타입을 지원하는 함수나 클래스를 만드는 데 유용.

⇒ 코드의 재사용성을 높이고 타입 안전성을 보장하여 더욱 견고한 코드를 작성하는 데 도움이 됩니다.

그냥.. 개발하다보면 필요할 때가 온다.

이때다! 제네릭 너가 필요하구나! 드디어 써주지…

사용 예시 : Elice Kdt Survey

(원래 독립적인 패키지로 개발하려했으나 시간관계상 kdt 프로젝트에 붙은 survey 프로젝트)

대략적 프로젝트 설명 ; 설문을 위한 여러 문항 타입을 정의하고, 서버에서 받은 question.type에 따라 다른 문항타입을 렌더한다.

(opens in a new tab)

export type QuestionDataType =
  | TextInputQuestion
  | UrlInputQuestion
  | EmailInputQuestion
  | DateInputQuestion
  | PhoneNumberInputQuestion
  | AddressInputQuestion
  | SingleSelectQuestion
  | MultipleSelectQuestion;

이렇게 8개의 문항 타입이 있습니다.

그럼 8개의 문항 컴포넌트마다 Props type을 선언해야할텐데요,

예를 들면, DateInputminDateTime 같은 옵션을 받을 수 있겠죠.

option은 달라지겟지만, 그 외의 기본적인 문항 컴포넌트의 prop구조는 같기 때문에 다른점만 갈아끼우는 타입은 없을까? 생각했습니다.

export interface QuestionProps<T extends QuestionDataType> {
  question: T;
  control: Control;
}

그래서 이렇게 QuestionProps 하나만을 선언해서 사용하게 되었습니다.

문항 컴포넌트는 항상 control prop과 해당 문항 타입에 따라 다른 options을 받는 구조입니다.

export interface DateInputQuestion extends BaseQuestion {
  options?: QuestionOptions<DateInputOptions>;
}
  • BaseQuestion

이렇게 위에서 선언한 XXQuestion type은 BaeQuestion을 Extends합니다.

여기에 공통적인 부분들이 들어가있고, 유일한 차이점인 options 필드만 제네릭 파라미터에 의해 달라지게 됩니다.

export type QuestionOptions<T extends OptionsType> = T;

참고로 QuestionOptions<T>에 들어가는 T 파라미터 또한 OptionsType으로 한정지었습니다.

Untitled

이렇게 한정된 type에 포함되지 않는 경우 type error가 발생합니다.

결국 이렇게 컴포넌트 prop을 사용하고, 문항마다 다른 option 프롭을 사용할 수 있게됨!

// DateInput 컴포넌트 예시
const DateInput: React.FC<QuestionProps<DateInputQuestion>> = ({
  question,
  control,
}) => {
  const { options } = question;
 
 // 해당 option을 사용해서 문항에서 필요한 validation 구현
 
  <DatePickerComponent
          value={value}
          onChange={handleDateChange}
          inputRef={ref}
          // MUI DatePicker prop
          **mask={
            options?.getTime
              ? DATE_TIME_PICKER_INPUT_MASK
              : DATE_TIME_INPUT_MASK
          }
          inputFormat={
            options?.getTime
              ? DATE_TIME_PICKER_INPUT_FORMAT
              : DATE_PICKER_INPUT_FORMAT**
          }
   //... 
/>

Utility Type

쉬어가는 타임

1. Partial<T>

사용 대상 : 객체 타입

Partial<T> 유틸리티는 타입 T의 모든 속성을 선택적(Optional)으로 만듭니다. 이는 객체에서 일부 속성만 설정하려는 경우 유용합니다.

interface Todo {
    title: string;
    description: string;
}
 
function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {
    return { ...todo, ...fieldsToUpdate };
}

2. Readonly<T>

사용 대상: 배열, 객체, 튜플 타입

**Readonly<T>**는 타입 T의 모든 속성을 읽기 전용으로 만듭니다.

이를 사용하면 객체의 속성을 변경하는 것을 방지할 수 있습니다.

interface Todo {
    title: string;
}
 
const todo: Readonly<Todo> = {
    title: "Delete inactive users",
};
 
todo.title = "Hello"; // Error: title is read-only

3. Record<K,T>

**Record<K,T>**는 K의 모든 속성을 T로 매핑하여 새로운 타입을 생성합니다.

interface PageInfo {
    title: string;
}
 
type Page = "home" | "about" | "contact";
 
const nav: Record<Page, PageInfo> = {
    home: { title: "Home" },
    about: { title: "About" },
    contact: { title: "Contact" },
};

4. Pick<T, K>

사용 대상: 객체 타입

**Pick<T, K>**는 타입 T에서 K 속성을 선택하여 새로운 타입을 만듭니다.

interface Todo {
    title: string;
    description: string;
    completed: boolean;
}
 
type TodoPreview = Pick<Todo, "title" | "completed">; // 여러개 pick할 때 union 사용
 
const todo: TodoPreview = {
    title: "Clean room",
    completed: false,
};

5. Omit<T, K>

사용 대상: 객체 타입

**Omit<T, K>**는 타입 T에서 K 속성을 제외한 새로운 타입을 만듭니다.

interface Todo {
    title: string;
    description: string;
    completed: boolean;
}
 
type TodoPreview = Omit<Todo, "description">;
 
const todo: TodoPreview = {
    title: "Clean room",
    completed: false,
};

6. Exclude<T, U>

사용 대상: 유니온 타입

**Exclude<T, U>**는 T union 타입에서 특정 타입을 제외합니다.

(Omit의 union버전)

type T = number | string;
type U = Exclude<T, number>; // Result: string

7. Extract<T, U>

사용 대상: 유니온 타입

**Extract<T, U>**는 T에서 U에 할당 가능한 타입을 추출합니다.

type T = number | string;
type U = Extract<T, number>; // Result: number

8. NonNullable<T>

사용대상 : Nullable 타입

**NonNullable<T>**는 T에서 null과 undefined를 제외한 타입을 생성합니다.

type T = string | number | null | undefined;
type U = NonNullable<T>; // Result: string | number

9. ReturnType<T>

사용 대상: 함수 타입

**ReturnType<T>**는 함수 T의 반환 타입을 얻습니다.

⇒ 특정 함수타입에서 사용하는 리턴 타입을 Import할 필요가 없어서 편리할듯

TS Playground - An online editor for exploring TypeScript and JavaScript (opens in a new tab)

type T = () => string;
type U = ReturnType<T>; // Result: string

10. InstanceType<T>

사용 대상: 클래스 생성자 타입

**InstanceType<T>**는 생성자 함수 T의 인스턴스 타입을 얻습니다.

class C {
    x = 0;
    y = 0;
}
 
type T = InstanceType<typeof C>; // Result: C
 

이러한 유틸리티 타입들은 복잡한 타입 작업을 수행할 때 매우 유용하며, TypeScript에서 타입 안전성을 확보하는 데 큰 도움이 됩니다.

11. ContructorParameters<T>

사용대상 :클래스 생성자 타입

생성자 함수의 매개변수 타입들을 추출하는 데 사용됩니다.

class Person {
  constructor(name: string, age: number) {
    // 생성자 코드
  }
}
 
type PersonConstructorParams = ConstructorParameters<typeof Person>;
 
// PersonConstructorParams의 타입은 [string, number]
 

12. Parameters<T>

사용 대상 : 함수 타입

함수 매개변수 타입들로 구성된 튜플 타입을 얻습니다.

type MyFunction = (name: string, age: number) => void;
 
type MyFunctionParams = Parameters<MyFunction>;
 
// MyFunctionParams의 타입은 [string, number]

13. Required<T>

사용 대상: 객체 타입

객체의 모든 프로퍼티를 함수로 만듭니다.

export interface PhoneNumberOptions {
  countryOptionList?: CountryCode[];
  countryNameLanguage?: string;
}
 
type RequiredPhoneNumberOptions = Required<PhoneNumberOptions>
 
// PhoneNumberOptions의 타입은 { countryOptionList: CountryCode[], agcountryNameLanguagee: string }

14. ReadonlyArray<T>

사용 대상: 모든 타입

주어진 타입으로 불변 배열을 생성한다.

type MyReadonlyArray = ReadonlyArray<number>;
 
const arr: MyReadonlyArray = [1, 2, 3, 4];
 
arr.push(5); // Error: push 메서드는 읽기 전용 배열에 존재하지 않습니다.
arr[0] = 10; // Error: 배열 요소는 읽기 전용입니다.