TIL 75 | TS Advanced

이재승님의 실전 리액트 프로그래밍을 읽고 정리한 내용입니다.

타입스크립트의 고급 기능에는 제네릭, 맵드 타입, 조건부 타입이 있다.

제네릭

제네릭은 타입 정보가 동적으로 결정되는 타입이다. 제네릭을 통해 같은 규칙을 여러 타입에 적용할 수 있기 때문에 타입 코드를 작성할 때 발생할 수 있는 중복 코드를 제거할 수 있다. 예시를 통해 중복된 코드를 어떻게 제네릭으로 해결하는지 알아보자.

리팩토링이 필요한 코드

function makeNumberArray(defaultValue: number, size: number): number[] {
  const arr: number[] = [];
  for (let i = 0; i < size; i++) {
    arr.push(defaultValue);
  }
  return arr;
}
function makeStringArray(defaultValue: string, size: number): string[] {
  const arr: string[] = [];
  for (let i = 0; i < size; i++) {
    arr.push(defaultValue);
  }
  return arr;
}
const arr1 = makeNumberArray(1, 10);
const arr2 = makeStringArray('empty', 10);

각각 숫자 배열과 문자열 배열을 생성하는 함수인데 중복된 코드가 보인다.

함수 오버로드로 개선한 코드

function makeArray(defaultValue: number, size: number): number[];
function makeArray(defaultValue: string, size: number): string[];
function makeArray(defaultValue, size) {
  const arr = [];
  for (let i = 0; i < size; i++) {
    arr.push(defaultValue);
  }
  return arr;
}

숫자와 문자열만 필요하다면 위와 같이 개선할 수 있지만, 타입을 추가할 때마다 코드도 추가해야 하고, 타입의 종류가 많아지는 경우 코드의 가독성이 떨어진다는 문제가 있다.

제네릭으로 해결한 코드

제네릭은 <> 기호를 이용해서 정의하며, 이름은 자유롭게 지정할 수 있다.

function makeArray<T>(defaultValue: T, size: number): T[] {
  const arr: T[] = [];
  for (let i = 0; i < size; i++) {
    arr.push(defaultValue);
  }
  return arr;
}
const arr1 = makeArray<number>(1, 10);
const arr2 = makeArray<string>('empty', 10);
const arr3 = makeArray(1, 10); // 첫 번째 매개변수를 알면 타입 T를 알 수 있다.

타입 T는 함수를 사용하는 시점에 입력되기 때문에 어떤 타입인지 결정되지 않았다. 함수 내부에서도 T의 타입 정보를 이용할 수 있다.

제네릭으로 스택 구현하기

제네릭은 데이터 타입에 다양성을 부여해주기 때문에 자료구조에서 많이 사용된다.

class Stack<D> {
  private items: D[] = [];
  push(item: D) {
    this.items.push(item);
  }
  pop() {
    return this.items.pop();
  }
}

const numberStack = new Stack<number>();
numberStack.push(10);
const v1 = numberStack.pop();
const stringStack = new Stack<string>();
stringStack.push('a');
const v2 = stringStack.pop();

let myStack: Stack<number>;
myStack = numberStack;
myStack = stringStack; // 숫자 스택에 문자열 스택 할당 불가

extends 키워드로 제네릭 타입 제한하기

제네릭 타입에는 아무 타입이나 입력할 수 있었지만 리액트와 같은 라이브러리의 API는 입력 가능한 값의 범위를 제한한다. extends 키워드를 이용하면 제네릭 타입으로 입력할 수 있는 타입의 종류를 제한할 수 있다.

function identity<T extends number | string>(p1: T): T {
  return p1;
}
identity(1);
identity('a');
identity([]); // 타입 에러

제네릭 T의 타입을 숫자와 문자열로 제한했기 때문에 배열을 할당하려고 시도하면 타입 에러가 발생한다.

  1. Korean 인터페이스는 Person을 확장해서 만들었다. 따라서 Korean 타입은 Person 타입에 할당이 가능하다.
  2. 제네릭 T는 Person에 할당 가능한 타입이어야 한다. 제네릭 K는 Person의 속성 이름이어야 한다.
  3. keyof 키워드는 인터페이스의 모든 속성 이름을 유니온 타입으로 만들어 준다. *
  4. p1, p2는 Person에 할당 가능하기 때문에 타입 에러가 발생하지 않는다.
interface Product {
  name: string;
  age: number;
}
const p1: Product = {
  name: '시계',
  price: 1000,
};
const p2: Product = {
  name: '자전거',
  price: 2000,
};
function swapProperty<T extends Person, K extends keyof Person>(
  p1: T,
  p2: T,
  name: K
): void {
  const temp = p1[name];
  p1[name] = p2[name];
  p2[name] = temp;
  console.log(p1, p2);
}
swapProperty(p1, p2, 'name'); // 타입 에러

Product는 Person에 할당 가능하지 않기 때문에 타입 에러가 발생한다.

맵드 타입

맵드 타입을 이용하면 몇 가지 규칙으로 새로운 인터페이스를 만들 수 있다. 맵드 타입은 인터페이스의 모든 속성을 선택 속성이나 읽기 전용으로 만들때 사용된다.

interface Person { // 맵드 타입의 입력으로 사용될 인터페이스
  name: string;
  age: number;
}
interface PersonOptional { // Person에 맵드 타입을 적용해 만들 예
  name?: string;
  age?: number;
}
interface PersonReadOnly {
  readonly name: string;
  readonly age: number;
}

맵드 타입은 in 키워드를 사용해서 정의한다.

두 개의 속성을 불 타입으로 만드는 맵드 타입

type T1 = { [K in 'prop1' | 'prop2']: boolean };
// { prop1: boolean; prop2: boolean; }

in 키워드 오른쪽에는문자열의 유니온 타입이 올 수 있다.

인터페이스의 모든 속성을 불 타입 및 선택 속성으로 만들어 주는 맵드 타입

type MakeBoolean<T> = { [P in keyof T]?: boolean };
const pMap: MakeBoolean<Person> = {};
pMap.name = true;
pMap.age = false;

Partial과 Readonly 내장 타입

타입스크립트 내장 타입인 Partial과 Readonly는 맵드 타입으로 만들어졌다.

// 인터페이스에서 특정 속성의 타입을 추출할 때 사용되는 문법, 맵드 타입에서 많이 쓰인다.
type T1 = Person['name']; // string
//인터페이스의 모든 속성을 읽기 전용으로 만들어주는 맵드 타입이다.
// T[P]는 인터페이스의 T에 있는 속성 P의 타입을 그대로 사용하겠다는 의미이다.
type Readonly<T> = { readonly [P in keyof T]: T[P] };
// 인터페이스의 모든 속성을 선택 속성으로 만들어주는 맵드 타입이다.
type Partial<T> = { [P in keyof T]?: T[P] };

type T2 = Partial<Person>;
type T3 = Readonly<Person>;

Pick 내장 타입

타입스크립트 내장 타입인 Pick은 인터페이스에서 원하는 속성만 추출할 때 사용된다.

type Pick<T, K extends keyof T> = { [P in K]: T[P] };
interface Person {
  name: string;
  age: number;
  language: string;
}
type T1 = Pick<Person, 'name' | 'language'>;
// type T1 = { name: string; language: string }

Record 내장 타입

Record는 입력된 모든 속성을 같은 타입으로 만들어 주는 맵드 타입이다.

type Record<K extends string, T> = { [P in K]: T };
type T1 = Record<'p1' | 'p2', Person>;
// type T1 = { p1: Person, p2: Person }

K는 문자열의 서브타입이다. K로 입력된 모든 문자열을 속성 이름으로 하면서 R를 각 속성의 타입으로 만든다.

열거형 타입과 맵드 타입

맵드 타입을 이용하면 열거형 타입의 활용도를 높일 수 있다.

enum Fruit {
  Apple,
  Banana,
  Orange,
}
const FRUIT_PRICE = {
  [Fruit.Apple]: 1000,
  [Fruit.Banana]: 1500,
  [Fruit.Orange]: 2000,
}

Fruit 열거형 타입에 새로운 과일을 추가한다면 FRUIT_PRICE에도 새로운 과일의 가격 정보를 추가하는 것이 일반적이지만 Fruit 열거형 타입에 과일을 추가하고 가격 정보를 깜빡해도 에러는 발생하지 않는다. 아래와 같이 맵드 타입을 사용하면 FRUIT_PRICE 객체가 Fruit의 모든 원소를 속성으로 가지는 것이 보장된다.

enum Fruit {
  Apple,
  Banana,
  Orange,
}
const FRUIT_PRICE: { [key in Fruit]: number } { // 타입 에러
  [Fruit.Apple]: 1000,
  [Fruit.Banana]: 1500,
}
// Orange 속성을 추가해야 타입 에러가 사라진다.

조건부 타입

조건부 타입은 입력된 제네릭 타입에 따라 타입을 결정할 수 있는 기능이다.

// T extends U ? X : Y
type IsStringType<T> = T extends string ? 'yes' : 'no';
type T1 = IsStringType<string>; // 'yes'
type T2 = IsStringType<number>; // 'no'

조건부 타입의 기본 구조이다. 입력된 제네릭 타입 T가 타입 U의 서브 타입이면 타입 X를 사용하고 그렇지 않으면 타입 Y를 사용한다.

조건부 타입에서 유니온 타입을 이용하면 유용한 유틸리티 타입을 많이 만들 수 있다.

type T1 = IsStringType<string | number>; // 'yes' | 'no'
type T2 = IsStringType<string> | IsStringType<number>;
// 조건부 타입에 유니온 타입이 입력되면 하나씩 검사해서 타입을 결정하고 결과는 유니온 타입으로 만들어 진다.
// 결과적으로 T1과 T2는 같은 타입이다.

Exclude, Extract 내장 타입

타입스크립트에 내장된 Exclude, Extract 타입은 조건부 타입으로 만들 수 있다.

type T1 = number | string | never; // string | number
type Exclude<T, U> = T extends U ? never | T;
type T2 = Exclude<1 | 3 | 5 | 7, 1 | 5 | 9>; // 3 | 7
type T3 = Exclude<string | numebr | (() => void), Function>; // string | number;
type Extract<T, U> = T extends U ? T : never;
type T4 = Extract<1 | 3 | 5 | 7, 1 | 5 | 9>; // 1 | 5

유니온 타입에 있는 never 타입은 제거되는데, 이는 조건부 타입에서 자주 사용되는 기능이다. Exclude 타입은 U의 서브 타입을 제거해주고 Extract는 Exclude와 반대로 동작하는 유틸리티 타입이다.

ReturnType 내장 타입

조건부 타입으로 만들어진 ReturnType은 함수의 반환 타입을 추출한다.

// 입력된 타입 T가 함수면 함수의 반환 타입이 사용되고, 그렇지 않으면 any 타입이 사용된다.
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
type T1 = ReturnType<() => string>; // string
function f1(s: string): number {
  return s.length;
}
type T2 = ReturnType<typeof f1>; // number

타입 추론을 위해 infer 키워드를 사용했다. infer 키워드 덕분에 함수의 반환 타입을 R이라는 변수에 담을 수 있다. infer 키워드는 조건부 타입을 정의할 때 extends 키워드 뒤에 사용된다. 아래와 같이 중첩 사용도 가능하다.

// 타입 T가 U의 배열이면 U가 사용된다.
type Unpacked<T> = T extends (infer U)[]
	? U
	// 함수면 반환 타입이 사용된다.
	: T extends (...args: any[]) => infer U
		? U
		// 프로미스면 프로미스에 입력된 제네릭 타입이 사용된다.
		: T extends Promise<infer U> ? U : T;
// 아무것도 만족하지 않으면 자기 자신이 된다. 
type T0 = Unpacked<string>; // string
type T1 = Unpacked<string[]>; // string
type T2 = Unpacked<() => string>; // string
type T3 = Unpacked<Promise<string>>; // string
// Promise<string>의 배열이므로 Promise<string>이 된다.
type T4 = Unpacked<Promise<string>[]>; // Promise<string>
type T5 = Unpacked<Unpacked<Promise<string>[]>>; // string

조건부 타입으로 직접 만들어보는 유틸리티 타입

조건부 타입을 사용해서 몇 가지 유틸리티 타입을 만들어 보자.

// 타입 T에서 값이 문자열인 모든 속성의 이름을 유니온 타입으로 만들어 주는 유틸리티 타입이다. 
type StringProperyNames<T> = {
  [K in keyof T]: T[K] extends String ? K : never
// [keyof T]는 인터페이스에서 모든 속성의 타입을 유니온으로 추출한다. 이때 never 타입은 제거된다.
}[keyof T];
// StringProperties는 인터페이스에서 문자열인 모든 속성을 추출하는 유틸리티 타입이다.
type StringProperties<T> = Pick<T, StringPropertyNames<T>>;
interface Person {
  name: string;
  age: number;
  nation: string;
}
type T1 = StringPropertyNames<Person>; // 'name' | 'nation'
type T2 = StringProperties<Person>; // { name: string, nation: string; }

일부 속성만 제거해 주는 유틸리티 타입

// 인터페이스 T에서 입력된 속성 이름 U를 제거한다.
type Omit<T, U extends keyof T> = Pick<T, Exclude<keyof T, U>>;
interface Person {
  name: string;
  age: number;
  nation: string;
}
type T1 = Omit<Person, 'nation' | 'age'>;
const p: T1 = {
  // Person에서 nation, age 속성을 제거했으므로 타입 T1 에는 name 속성만 남는다.
  name: 'mike',
}

인터페이스를 덮어쓰는 유틸리티 타입

// 인터페이스 T에 인터페이스 U를 덮어쓴다.
type Overwrite<T, U> = { [P in Exclude<keyof T, keyof U>]: T[P] } & U;
interface Person {
  name: string;
  age: number;
}
type T1 = Overwrite<Person, { age: string; nation: string }>;
const p: T1 = {
  name: 'mike',
  // age 속성의 타입은 문자열로 변경됐고, nation 속성은 새로 추가됐다.
  age: '23',
  nation: 'korea',
}

좋은 웹페이지 즐겨찾기