본문 바로가기

웹 프론트엔드

TypeScript 레벨업 하기(feat. Utility Type 분석)

유틸리티 타입 명세를 살펴보면, 타입스크립트를 더 잘 이해할 수 있어서 유틸리티 타입을 분석하는 글을 쓰고자 한다.

1. 유틸리티 타입이란?

타입스크립트는 타입 변환을 위한 유틸리티 타입을 제공한다. 이는 .ts 파일 어디서든 (전역으로) 사용 가능하다. 유틸리티 타입은 mapped type, Index type query operator(keyof), Indexed access operator(T[K]), Generic 등의 개념을 통해 정의되어 있다.


먼저 유틸리티 타입의 필요성을 느껴보자.

interface AddressBook {
  name: string;
  phone: number;
  address: string;
  company: string;
}

const phoneBook = {
  name: '재택근무',
  phone: 12342223333,
  company: '내 방'
}


AddressBook은 name, phone, address, company 4개의 프로퍼티를 요구하는 인터페이스이다. 하지만 변수 phoneBook은 address를 제외한 3개의 프로퍼티를 요구한다.

interface AddressBook {
  name: string;
  phone: number;
  address: string;
  company: string;
}

interface PhoneBook {
  name: string;
  phone: number;
  company: string;
}

const phoneBook: PhoneBook = {
  name: '재택근무',
  phone: 12342223333,
  company: '내 방'
}


이와 같이 새로운 프로퍼티를 정하여 사용할 수 있을 것이다. 이때, phone이 number 타입이 아닌, 010-1234-5678 과 같이 string 타입을 받는 것으로 변경되었다고 하자.
그러면 AddressBook과 PhoneBook 2가지 모두 찾아가서 number를 string으로 변경시켜주는 과정이 요구된다.
이를 편하게 해주는 유틸리티 타입 Omit을 사용해보자.

interface AddressBook {
  name: string;
  phone: number;
  address: string;
  company: string;
}

type PhoneBook = Omit<AddressBook, 'address'>

const phoneBook: PhoneBook = {
  name: '재택근무',
  phone: 12342223333,
  company: '내 방'
}


Omit<AddressBook, 'address'>는 AddressBook type에서 address를 제외한 type을 재정의하는 것이다.
또한 Pick을 이용할 수도 있다.

type PhoneBook = Pick<AddressBook, 'name' | 'phone' | 'company'>


이처럼 유틸리티 타입은 유연한 타입 변환을 위해 사용된다.

2. Partial의 내부구현

위 AddressBook 중, 어떠한 정보가 누락되어 기록될 수 있다고 하자.

interface LeakedAddressBook {
  name?: string;
  phone?: number;
  address?: string;
  company?: string;
}


이와 같이 optional을 붙여서 표현할 수 있다.

const address1 = {};
const address2 = { name: '루터회관' };
const address2 = { name: '루터회관', phone: '01012345678' };


등 4개 모두 프로퍼티로 있어도 되고 없어도 된다. 이를 간단하게 표현할 수 있는 유틸리티 타입이 바로 Partial이다.
Partial의 내부 구현을 살펴보자. ts 파일에서 command를 누르고 마우스 클릭을 하면 내부 구현을 볼 수 있다.

type Partial<T> = {
    [P in keyof T]?: T[P];
};


이것이 어떻게 만들어졌는지 풀어서 보자.

type LeakedAddressBook = Paitial<AddressBook>

interface LeakedAddressBook {
  name?: string;
  phone?: number;
  address?: string;
  company?: string;
}


Partial은 모든 프로퍼티가 optional 임을 의미한다.


#1 - Indexing

interface LeakedAddressBook {
    name?: AddressBook['name'];
    phone?: AddressBook['phone'];
    address?: AddressBook['address'];
    company?: AddressBook['company'];
}


AddressBook과 key, value가 동일하므로, Indexing하여 표현할 수 있다. interface에 대해서도 타입을 인덱싱하여 받아올 수 있다. 예를 들어, AddressBook[’name’]은 string 타입을 의미한다.


#2 - Mapped Type

type LeakedAddressBook = {
    [P in 'name' | 'phone' | 'address' | 'company']?: AddressBook[P]
}


동일한 구조가 반복됨을 알 수 있다. key?: AddressBook[key] 형태이다. 타입스크립트에서 타입을 loop 돌면서 선언하는 방식이 바로 Mapped Type이다. [P in keyof T] 형태로 쓰인다. keyof T는 Union Type이고, Union Type 각각에 대해 순회를 돌며 프로퍼티 타입을 선언한다.


#3 - keyof (Index type query operator)

type LeakedAddressBook = {
    [P in keyof AddressBook]?: AddressBook[P]
}


keyof는 매우 편하며 유용한 operator이다. 객체 타입의 key를 union 타입으로 바꿔준다.

 

#4 - Generic

type SubSet<T> = {
    [P in keyof T]?: T[p]
}

type LeakedAddressBook = SubSet<AddressBook>; // === Partial<Product>


이를 AddressBook뿐만 아니라, 모든 케이스에 적용하기 위해 제네릭을 사용한다. 기본적으로 타입은 함수를 선언하는 시점에 정의한다. 제네렉을 활용하면, 함수를 사용하는 곳에서 동적으로 타입을 받아올 수 있다.
Partial을 통해, indexing, mapped type, keyof, generic을 공부해보았다. 이 지식들을 활용하면 훨씬 유연하고 일관성있는 타입 선언이 가능할 것이다.

 

3. Pick의 내부구현

type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

여러 키워드들이 보인다. 어떻게 구현되었는지 하나하나 살펴보자.

 

type PhoneBook = Pick<AddressBook, 'name' | 'phone' | 'company'>

interface PhoneBook {
    name: string;
    phone: number;
    company: string;
}


#1 - indexing

interface LeakedAddressBook {
    name: AddressBook['name'];
    phone: AddressBook['phone'];
    company: AddressBook['company'];
}


Partial 예시와 같이 indexing으로 표현할 수 있다.


#2 - Mapped Type

type LeakedAddressBook {
    [P in 'name' | 'phone' | 'company']: AddressBook[P]
}


Mapped Type으로 순회를 돌며 타입을 표현한다.


#3 - Generic

type PickSomeKey<T, K> = {
    [P in K]: T[P]
}

type PhoneBook = PickSomeKey<AddressBook, 'name' | 'phone' | 'company'> // Ok

type PhoneBook = PickSomeKey<AddressBook, 'bossName'> // Ok - but we don't want


이번에 제네릭에는 2개의 타입변수가 쓰였다. 어떤 인터페이스에서(T) 어떤 타입을(K) Pick 해올지 동적으로 받아와야하기 때문이다.
여기서 디테일을 추가하면, bossName이라는 key를 Pick해오려고 해도 에러가 발생하지 않는다. K는 T의 key들만 받을 수 있어야 한다. 이때 타입의 범위를 강제할 수 있는 것이 extends이다.


#4 - extends

type PickSomeKey<T, K extends keyof T> = {
    [P in K]: T[P]
}

type PhoneBook = PickSomeKey<AddressBook, 'name' | 'phone' | 'company'> // Ok

type PhoneBook = PickSomeKey<AddressBook, 'bossName'> // error


<T, K extends keyof T>와 같은 제네릭 문법은 매우 자주 사용된다. 알아두면 유용하다. 인터페이스(T)를 받아오면서, T의 key를(K) 받아오고 싶을때 사용한다. K는 keyof T를 확장한다. 즉, K는 T를 포함한 타입이어야 한다.


4. 결론

유틸리티 타입 Partial, Pick의 내부구현을 살펴보며 여러 개념들을 공부해보았다. indexing, keyof, mapped type, generic, extends 등. 이 개념들을 내재화시키면, 타입스크립트 실력이 한 단계 성장되었음을 느낄 것이다.

유틸리티 타입은 전역으로 제공되는 타입이다. 이 외에도 개발하다보면 우리만의 유틸리티 타입이 필요해질 때가 올 것이다. 그때 위 개념을 잘 이해하고 있다면, 스스로 유틸리티 타입을 정의하여 사용할 수 있을 것이다.

5. 참고

TypeScript 한글 문서

유틸리티 타입 | 타입스크립트 핸드북

Typescript의 기본 유틸 타입