TypeScript 50 Lessons Part 5
타입스크립트 in 50 레슨
스테판 바움가트너가 스매싱 매거진에서 출판한 서적으로 타입스크립트 주요 주제들에 대해 잘 짚어주고 있다.
Lesson 29
내가 원하는 게 뭔지 모르지만 어떻게 얻을지 알고 있다.
비디오 스트리밍 플랫폼 예제를 통해 제너릭 타입을 살펴보자.
type VideoFormatURLs = {
format360p: URL
format480p: URL
format720p: URL
format1080p: URL
}
type SubtitleURLs = {
english: URL
german: URL
french: URL
}
그리고 이를 사용하기 위한 함수들과 유틸리티를 준비한다. 추가로 자막에 대한 타입도 준비했다.
function isFormatVailable(obj: VideoFormatURLs, key: string): key is keyof VideoFormatURLs {
return key in obj
}
function isSubtitleAvailable(obj: SubtitleURLs, key: string): key is keyof SubtitleURLs {
return key in obj
}
// generalized
function isAvailable(obj, key) {
return key in obj
}
작성하고 보니 비디오 포맷에 대한 점검 함수와 자막에 대한 점검 함수가 같은 모양이다. 제너릭 타입이 필요하다.
function isAvailable<Formats>(obj: Formats, key: string | number | symbol): key is keyof Formats {
return key in obj
}
이를 사용하는 과정은 `< >` 안에 명시적으로 타입을 추가하는 것이다.
if (isFormatAvailable<VideoFormatURLs>(videos, format)) {
// ...
}
우리가 자주 사용하는 Promise 객체도 제너릭 타입을 사용한다. 제너릭 타입은 선언 시점이 아닌, 사용 시점에 타입이 추론된다. 이를 제너릭 인퍼런스라고 할 수 있다. 이를 위해 타입을 인수로 받아서 사용하는데 이를 제너릭 어노테이션이라고 한다.
Lesson 30
제너릭 제약
이전 예제는 그럴듯 하지만 전달되는 인자에 대한 타입 제약이 없다. 이는 제너릭 타입이 기본적으로 `any` 를 바탕으로 제공되기 때문이고 따라서 아래와 같은 코드에서도 정상으로 타입 체크가 된다.
if (isAvailable({ name: 'Stefan', age: 38 }, key)) {
// key is now “name” | “age”
}
if (isAvailable('A string', 'length')) {
// Also strings have methods,
// like length, indexOf, ...
}
if (isAvailable(1337, aKey)) {
// Also numbers have methods
// aKey is now everything number has to offer
}
제너릭 어노테이션에 대한 경계를 추가해야 하는데 `extends` 키워드를 사용할 수 있다.
function isAvailable<FormatList extends object>(obj: FormatList, key: string): key is keyof FormatList {
return key in obj
}
이제 적어도 FormatList 는 object 타입으로 제약된다. 비디오를 로딩하는 함수를 하나 만들면 이전에 작성한 제너릭 타입을 사용하는 코드와 유사하게 작성될 것이다.
function loadFile<Formats extends object>(fileFormats: Formats, format: string) {
// You know
}
비디오 파일을 로드하기 위한 주요 타입은 URL 이고 이 정보를 동적으로 만들어내는 인덱스 타입을 사용해 제너릭 어노테이션을 확장할 수 있다.
type URLList = { [k: string]: URL }
function loadFile<Formats extends URLList>(fileFormats: Formats, format: string) {
// The real work ahead
}
Lesson 31
키 활용하기
문자열을 키로 사용하는 경우 오탈자나 정의되지 않은 키를 사용해 발생하는 런타임 에러를 막아보자.
function loadVideoFormat(fileFormats: VideoFormatURLs, format: keyof VideoFormatURLs) {
// You know
}
`keyof` 를 통해 'format' 의 타입을 제약하는 방법이 있다. 아래는 제너릭 타입과 타입 제약을 적용한 것이다.
type URLObject = { [k: string]: URL }
function loadFile<Formats extends URLObject>(fileFormats: Formats, format: keyof Formats) {
// The real work ahead
}
loadFile(video, 'format1080p') // ok
loadFile(video, 'format4k') // 'format4k' is not available
제너릭 어노테이션은 하나 이상 작성할 수 있다.
async function loadFile<Formats extends URLObject, Key extends keyof Formats>(fileFormats: Formats, format: Key) {
const data = await fetch(fileFormats[format].href)
return { format, loaded: data.response === 200 }
}
Promise 타입에 대한 부분도 제너릭으로 표현할 수 있다. loadFile 의 실제 구현에 await 가 필요하다면 이 함수의 반환 타입을 `Promise<{ format: keyof Formats, loaded: boolean }>` 처럼 작성할 수 있다.
type URLObject = { [k: string]: URL }
type Loaded<Key> = { format: Key, loaded: boolean }
async function loadFile<Formats extends URLObject, Key extends keyof Formats>(fileFormats: Formats, format: Key): Promise<Loaded<Key>> {
const data = await fetch(fileFormats[format].href)
return { format, loaded: data.response === 200 }
}
Lesson 32
제너릭 맵 타입
타입스크립트는 기본 타입을 확장해 사용할 수 있는 헬퍼 타입(유틸리티 타입)을 제공한다. 제너릭 맵 타입을 알아보기 위해 Record 와 Pick 을 살펴 본다. Pick 은 첫 번째 인자의 타입에서 두 번째 인자를 선택한 타입을 만든다.
type HD = Pick<VideoFormatURLs, 'format1080p' | 'format720p'>
Record 는 키와 타입으로 이루어진 맵이다. Record 는 쉽게 동적으로 object 타입에 대한 타입을 만들어 낸다.
type URLObject = Record<string, URL>
맵 타입과 인덱스 타입을 활용하면 복잡한 타입을 단순하게 풀어낼 수 있다. 이어지는 예제를 보자.
type Split = keyof VideoFormatURLs
// Equivalent to
type Split = 'format360p' | 'format480p' | 'format720p' | 'format1080p'
type Split = { [P in keyof VideoFormatURLs]: P }
// Equivalent to
type Split = {
format360p: 'format360p', format480p: 'format480p', format720p: 'format720p', format1080p: 'format1080p'
}
type Split = { [P in keyof VideoFormatURLs]: P }[keyof VideoFormatURLs]
// Equivalent to
type Split = 'format360p' | 'format480p' | 'format720p' | 'format1080p'
type Split = { [P in keyof VideoFormatURLs]: Record<P, VideoFormatURLs[P]> }[keyof VideoFormatURLs]
// Equivalent to
type Split = Record<'format360p', URL> | Record<'format480p', URL> | Record<'format720p', URL> | Record<'format1080p', URL>
// Equivalent to
type Split = { format360p: URL } | { format480p: URL } | { format720p: URL } | { format1080p: URL }
마지막으로 잘 인덱스와 맵으로 정의된 제너릭 타입을 보자.
type Split<Obj> = { [Prop in keyof Obj]: Record<Prop, Obj[P]> }[keyof Obj]
type AvailableFormats = Split<VideoFormatURLs>
Lesson 33
맵 타입 모디파이어
타입스크립트는 Pick 처럼 맵 타입을 수정해 사용할 수 있는 수정자를 제공한다. 이전 예제에서 사용할 사용자 속성을 아래 코드처럼 정의해 볼 수 있다.
type UserPreferences = {
format: keyof VideoFormatURLs
subtitles: {
active: boolean,
language: keyof SubtitleURLs
},
theme: 'dark' | 'light'
}
새로 추가된 UserPreferences 을 사용하는 코드에서 기본 세팅과 사용자 정의 세팅을 제공해야 한다면 사용자 세팅을 override 해야 한다.
function combinePreferences(defaultPerf: UserPreferences, userPerf: unknown) {
return { ...defaultPerf, ...userPerf }
}
처음 정의한 UserPreferences 타입을 수정하지 않고 옵셔널 하게 정의할 수 있는 Optional<T> 수정자를 만들어 보자.
type Optional<Obj> = {
[Key in keyof Obj]?: Obj[Key]
}
function combinePreferences(defaultPerf: UserPreferences, userPerf: Optional<UserPreferences>) {
return { ...defaultPerf, ...userPerf }
}
이 기능을 위한 Partial<T> 수정자가 내장되어 있다. 상반된 목적의 Required<T> 수정자도 있다.
type Required<Obj> = {
[Key in keyof Obj]-?: Obj[Key]
}
읽기 전용으로 변경하는 Readonly<T> 수정자는 아래 코드처럼 구현된다. 타입스크립트는 컴파일 타임에 이를 검사하기 때문에 런타임에 변경되는 것을 막기 위해서는 Object.freeze 를 사용해야 한다.
type Const<Obj> = {
readonly [Key in keyof Obj]: Obj[Key]
}
추가로 Readonly 나 Partial 수정자는 첫 단계의 프로퍼티에 대해 동작하는 것을 기억해야 한다. deep 하게 제약을 추가하기 위해 DeepReadyonly 같은게 필요할 수 있다.
type DeepReadonly<T> = {
readonly [key in keyof T]: DeepReadonly<T[key]>
}
type DeepPartial<T> = {
[key in keyof T]?: DeepPartial<T[key]>
}
Lesson 34
고정 타입을 제너릭 타입으로 확장하기
이전에 combinePreferences 함수의 반환 타입을 만들어가며 제너릭을 확장하고 있다.
combinePreferences(defaultUserPref, { format: 'format720p', theme: 'dark' })
const userSettings = { format: 'format720p', theme: 'dark' }
combinePreferences(defaultUserPref, userSettings) // type errors
이렇게 리터럴을 사용할 때랑 참조를 전달할 때 동작이 다르다. userSettings 가 만들어지는 시점을 보면 타입스크립트는 이 타입 UserPreferences 타입인지 확인하기 어렵다. format 이나 theme 가 변경 가능하기 때문에 가능하면 더 넓은 범위의 타입을 추론한다. `as const` 타입을 제약해 보자.
const userSettings = { format: 'format720p', theme: 'dark' } as const
이제 Partial<UserPreferences> 타입을 만족한다. 다른 방법으로 userSettings 를 생성할 때 타입을 정의하여 타입 체크를 만족시킬 수 있다.
const userSettings: Partial<UserPreferences> = { format: 'format720p', theme: 'dark' }
모디파이어를 사용한 타입이라도 제너릭 기준에서 보면 고정 타입이다. 예제 함수의 매개변수 타입을 제너릭으로 개선해 보자.
function combinePreferences<UserSettings extends Partial<UserPreferences>> (
defaultPref: UserPreferences,
userPref: UserSettings
) {
//
}
물론 defaultPerf 의 UserPreferences 도 제너릭하게 변경할 수 있다.
Lesson 35
제너릭 타입의 기본 타입을 정의하기
협업 상황에서 주의할 것은 사이드 이펙트를 유발하는 undefined, null 데이터들이다. Nullable 한 타입을 하나 가정하자.
type Nullable<T> = T | undefined
HTML 을 처리하기 위한 HTMLElement 타입의 Container 클래스를 만들 때에도 제너릭을 활용할 수 있다.
class Container<CustomElement extends HTMLElement> {
private element: Nullable<CustomElement>;
// ...
set element(value: Nullable<CustomElement>) {
this.#element = value
}
get element(): Nullable<CustomElement> {
return this.#element
}
// ...
}
const container = new Container(userPrefs)
위 코드처럼 container 에 제너릭 타입 어노테이션을 작성하지 않고 생성할 때에도 VideoElement 를 위한 기본 타입을 가지도록 정의하기 위해 제너릭은 기본 값을 가질 수 있다.
class Container<CustomElement extends HTMLElement = HTMLVideoElement>
기본 타입이 정의된다면 조금 더 편리하게 코드를 작성할 수 있다.
제너릭스의 개념은 1970년대 Ada 프로그래밍 언어에서 처음 소개되었다. 타입스크립트의 제너릭 문법은 C++ 의 템플릿에서 물려 받았다. 그래서 기본 타입을 표기할 때 `T` 를 사용한다. 이어지는 타입으로 U,V,W 을 사용하고 프로퍼티로 P, 키로 K 등을 쓴다.