타입스크립트 in 50 레슨

스테판 바움가트너가 스매싱 매거진에서 출판한 서적으로 타입스크립트 주요 주제들에 대해 잘 짚어주고 있다.

Lesson 22

데이터 모델링

레슨 22 는 4장의 시작인데 4장은 타입스크립트를 지탱하고 있는 집합 이론에 대해 소개한다. 특히 유니언 타입과 인터섹션 타입에 대한 논의를 하고 있다.

데이터 모델링 예제로 테크 컨퍼런스, 밋업, 웨비나를 들어 소개하고 있다.

  • 테크 컨퍼런스: 사람들이 특정 장소에 만나서 여러 이얘기를 듣는다. 참가 비용이 있다.
  • 밋업: 컨퍼런스보다 조금 작은 규모지만 이 데이터 모델에서는 무료로 진행된다.
  • 웨비나: 위 두 형태와 다르게 온라인에서 진행되고 시청을 위한 URL 이 제공된다. 유료인 경우도 있고 무료인 경우도 있다. 그리고 한 이벤트 당 하나의 주제를 제공한다.

이 이벤트에 대해 데이터 모델링을 시작해 보자. 각 이벤트는 Talk 라는 최소 엔티티를 구성할 수 있다. 컨퍼런스는 참가자 수용 정원, 참여 비용, 예약 인원, 참여 비용과 Talk 들로 구성된다. 밋업은 비용이 없기 때문에 참여 비용이 없다. (여기에서는 'free' 라는 문자열로 처리) 웨비나는 URL 이 추가로 제공되고 Location 정보가 없다.

데이터를 모델링 하는 것은 추상화 하는 것인데, (객체지향) 프로그래밍에서 가장 중요한 부분이기도 하다. 객체지향 프로그래밍은 역할 모델을 기반으로 엔티티를 구분하고 객체를 설계하는 것이다. 이건 추상화를 잘 해야 하는데… 연습도 많이 해야 하지만 세상이, 특히 비즈니스가 어떻게 움직이고 어떤 걸 필요로 하고 이걸 일반화하여 구분하고 공통점과 차이점을 찾아내어 코드로 옮기는 능력이 있어야 한다. 이런 건 어떻게 배우나?

교차 타입은 Intersection 이라고 하는데 `&` 로 타입을 묶는다. 'and' 로 부르면 된다. 클래스를 확장하는 것과 유사하다. 반복되는 타입을 묶어 TechEventBase 타입으로 선언했다. 나는 TechEvent 나 BaseTechEvent 로 쓰는 편이다. 각 타입으로 이벤트를 모델링 했지만 어떤 이벤트는 웨비나가 될지 밋업 또는 컨퍼런스가 될지 모르는 경우는 유니언 타입을 사용한다. `|` 로 묶고 'or' 로 부른다. 유니언으로 묶는 경우 Type Narrowing (Type Guard) 을 해야 할 경우도 있다.

Lesson 23

타입 스페이스 이동

왜 인터섹션, 유니언 타입이라고 불렸는가? 집합 이론에 근거한 용어. 'Programming with Types' - Vlad Riscutia 의 정의에 따르면,

타입은 데이터에 대해 수행할 수 있는 동작, 데이터 자체의 의미, 허용되는 값 집합;

이라고 한다. 허용되는 값 집합을 정의하는 부분이 우리가 중요하게 보는 부분인데 이미 우리가 타입을 사용하면서 경험한 부분이다. 유니언 타입 string | number 의 경우 숫자와 문자열이 값 집합의 제약으로 동작한다. 반면 인터섹션 타입 string & number 의 경우 공유하는 집합이 없다.

벨류 타입으로도 정의할 수 있다.

let withTypeAny: any = 'conference' // OK!
let withTypeString: string = 'conference' // OK!
let withValueType: 'conference' = 'conference' // OK!

`conference` 타입의 경우 문자열 'conference' 가 가능한 값이다.

type EventKind = 'webinar' | 'conference' | 'meetup'
let tomorrowsEvent: EventKind = 'concert'

이 경우 tomorrowsEvent 는 타입 체크 단계에 실패한다.

Lesson 24

값 타입

값 타입과 유니언 타입으로 테크 이벤트 데이터 구성을 개선한다. kind 필드에 string 으로 되어 있는 것을 narrowing 할 수 있다.

type TechEventBase = {
    title: string
    // ...
    kind: 'conference' | 'meetup' | 'webinar'
}

이 제약은 switch case 에도 적용되어 된다. 발생하지 않을 조건에 대해 검사하지 않을 수 있게 가이드 한다. TechEventBase 에 kind 도 세 개의 개별 타입으로 내릴 수 있다. 그 전에는 string 타입이었지만 이제는 discrimicated 되었다. 이걸 discriminated union type 이라고 부른다.

쉽게 타입을 고정하는 기법을 배워보자.

const script19 = {
    title: 'ScriptConf',
    //...
    kind: 'conference',
    price: 129,
    talks: [ ... ],
}

서버에서 새 이벤트 타입을 받아 보면 kind: 'conference' 를 받는다 하더라도 타입 체크에 실패한다. 타입스크립트는 서버에서 받은 kind 정보를 string 으로 추론하기 때문이다. 이를 해결하기 위해 left hand typing 을 할 수 있다.

const script19: TechEvent = {
    // All the properties from before ...
}

또 다른 방법으로는 kind 를 conference 값 타입으로 정의하는 것이다.

const script19 = {
    // ...
    kind: 'conference' as 'conference',
    // ...
}

이 경우도 괜찮지만 meetup 같은 경우 또다른 수정이 필요하다.

const script19 = {
    // ...
    kind: 'conference' as const,
    // ...
}

kind 를 const 로 정의하는 경우 타입 고정이 되고 타입스크립트의 구조적 타이핑 시스템에 타입 힌트가 된다. 사이드 이펙트로 이 script19 객체는 read-only 가 된다는 단점이 있다.

Lesson 25

동적 유니언 타입 만들기

테크 이벤트들을 필터링 하는 함수를 만들면서 타입을 보강한다.

type TechEvent = Webinar | Conference | Meetup;
type EventKind = 'conference' | 'webinar' | 'meetup'

function filterByKind(list: TechEvent[], kind: EventKind): TechEvent[] {
  return list.filter(el => el.kind === kind)
}

이 코드는 안정된 타입 정보를 바탕으로 테크 이벤트를 필터링 할 수 있지만 Hackathon 같은 새로운 이벤트가 추가되면 수정할 부분이 생긴다. EventKind 와 TechEvent 간에 연결이 끊어지는 것이다.

type Hackathon = TechEventBase & {
    location: string
    price?: number
    kind: 'hackathon'
}
type TechEvent = Conference | Webinar | Meetup | Hackathon

filterByKind(eventList, 'hackathon') // Error

EventKind 와 TechEvent 간에 관계를 만들자.

type TechEvent = Conference | Webinar | Meetup | Hackathon
type EventKind = TechEvent['kind']

이런 타입을 '인덱스 접근 타입' 또는 '룩업 타입'이라고 부른다. 하지만 타입 추론이 실패하게 된다. 동적으로 타입을 생성하기에는 이걸로 부족하다. 타입 맵을 통해 동적으로 타입을 구성할 수 있다.

type GroupedEvents = {
    conference: TechEvent[]
    meetup: TechEvent[]
    webinar: TechEvent[]
    hackathon: TechEvent[]
}

위 코드는 아래와 같이 개선될 수 있다.

type GroupedEvents = {
    [Kind in EventKind]: TechEvent[]
}

이 종류의 타입을 Mapped type 이라고 부른다. 프로퍼티 이름을 직접 사용하는 대신 브라켓으로 프로퍼티를 담는 식별자를 사용한다. 이게 동작하는 과정은 아래와 같다.

// Resolving the type alias.
type GroupedEvents = {
    [Kind in TechEvent['kind']]: TechEvent[]
}
// Resolving the union
type GroupedEvents = {
    [Kind in 'webinar' | 'conference' | 'meetup' | 'hackathon']: TechEvent[]
}
// Extrapolating keys
type GroupedEvents = {
    webinar: TechEvent[], conference: TechEvent[], meetup: TechEvent[], hackathon: TechEvent[]
}

맵 타입은 편리하기도 하지만 코드량을 많이 줄여주기도 한다.

Lesson 26

객체 키와 타입 단정

새로운 데이터 모델로 참가자에 대한 내용을 추가하자.

type UserEvents = {
    watching: TechEvent[]
    rvsp: TechEvent[]
    attended: TechEvent[]
    signedout: TechEvent[]
}

UserEventCategory 의 타입을 구하는 코드로 아래와 같이 선언하고 참가자의 이벤트를 필터링 할 수 있다.

type UserEventCategory = 'watching' | 'rsvp' | 'attended' | 'signedoff'

function filterUserEvent(userEventList: UserEvents, category: UserEventCategory, filterKind?: EventKind) {
    const filteredList = userEventList[category]
    if (filterKind) {
        return filteredList.filter(event => event.kind === filterKind)
    }
    return filteredList
}

지난 Lesson 에서 겪은 타입 문제를 다시 해결하기 위해 동적으로 타입을 생성할 수 있는 `keyof` 연산자를 사용햐 수 있다.

// 'speaker' | 'title' | 'abstract'
type TalkProperties = keyof Talk
// number | 'toString' | 'charAt' | ...
type StringKeys = keyof 'speaker'
// number | 'length' | 'pop' | 'push' | ...
type ArrayKeys = keyof []

위 예제에서 보듯 연산 대상 타입의 속성을 값 타입으로 얻을 수 있다.

// no needed type UserEventCategory = 'watching' | 'rsvp' | 'attended' | 'signedoff'
function filterUserEvent(userEventList: UserEvents, category: keyof UserEvents, filterKind?: EventKind) {
    // ...
}

UserEvent 에 새로운 타입이 추가되어도 필터링 함수에 필터 category 가 동적으로 반영되어 코드를 수정할 필요가 없게 된다. 만약 이 함수를 타입스크립트를 사용하지 않는 다른 곳에서 사용된다면, category 가 없는 리스트에 접근하게 되는 버그를 안고 있다. 추가로 제공되는 카테고리가 사용 가능한 것인지 점검하는 함수가 필요할 것이다. 그리고 이를 적용한 필터 함수는 아래처럼 개선해야 한다.

function isUserEventListCategory(list: UserEvents, category: string) {
    return Object.keys(list).includes(category)
}

function filterUserEvent(list: UserEvents, category: string, filterKind?: EventKind) {
    // check it
    if (isUserEventListCategory(list, category)) {
        const filteredList = list[category]
        if (filterKind) {
            return filteredList.filter(event => event.kind === filterKind)
        }
        return filteredList
    }
    return list
}

이렇게 되면 잘 동작하긴 하지만 타입스크립트 입장에서 category 를 단지 string 타입으로 선언하여 사용하는 것은 좋지 않다. 이 경우 type predicate 가 필요하다. 타입 narrowing 의 한 방법이다.

function isUserEventListCategory(list: UserEvents, category: string): category is keyof UserEvents {
    return Object.keys(list).includes(category)
}

함수 본문의 결과는 boolean 이어야 하고 결과가 참이면 category 는 지정된 타입을 만족하는 것으로 처리된다. 단순히 string 으로 타입을 반환하던 것보다 명확한 UserEvent 를 반환하니 타입 시스템이 훼손되지 않는다.

Lesson 27

가장 바닥에 있는 Never 타입

집합론에 따라 타입을 넓히고 좁히는 과정에 가장 아래에 있는 값은 never 타입이다. 아무 것도 없는 집합이다. any 의 반대편에 있다. 서버에서 도착하는 데이터가 그 어떤 TechEvent 타입도 아닌 경우 throw 되어 never 타입이 된다. 다루고 있는 예제에서 실수로 hackathon 에 대한 코드를 작성하지 않았다면 getEventTeaser 함수는 타입 체크에 실패할 것이고 우리는 코드를 완결 할 수 있다.

function neverError(message: string, token: never) {
    return new Error(`${message}. ${token} should not exist`)
}

function getEventTeaser(event: TechEvent) {
    switch (event.kind) {
        case 'conference':
            return `${event.title} (Conference), ` + `priced at ${event.price} USD`
        case 'meetup':
            return `${event.title} (Meetup), ` + `hosted at ${event.location}`
        case 'webinar':
            return `${event.title} (Webinar), ` + `available online at ${event.url}`
        case 'hackathon':
            return `even that: ${event.title}`
        default:
            throw neverError('Not sure what to do with that', event)
    }
}

Lesson 28

undefined 와 null

null 과 undefined 는 값이 없다는 표현을 담당한다. 타입의 관점에서 undefined 는 아직 값이 할당되기 전에 값이 없는 상태이고 null 은 변수나 속성에 값을 지우기 위해 빈 값이 할당하는 것이다. never 타입처럼 가장 아래 쪽에 있는 타입이다. 아쉽게도 이 바텀 벨류에 대해 논의는 많이 있었지만 이 값이 두 종류여야 한다는 의견은 없었다. 타입 공간에서 undefined 와 null 은 각각의 타입 공간에 존재한다.

let age: number // Let's define a number variable
age = age + 1 // I'm getting one year older!

위 코드는 이상이 없는 타입스크립트 코드지만 결과는 NaN 이다. tsconfig.json 에서 strictNullChecks 가 활성화 되어 있다면 null, undefined 는 각 타입에서 배제된다. Number 타입의 예를 들면 undefined 와 null 은 number 타입에 속하지 않게 된다. 위 예제에서 age = age + 1 은 타입 체크에서 실패한다. html querySelector 를 통해 반환되는 결과는 null 일 수 있는데 예제에서 사용한 코드에는 null 에 대한 가정이 없다. 그래서 list.append 코드는 nullish 에 대한 처리가 필요하다. 많은 경우 nullish 값을 사용할 경우 null 이나 undefined 중 하나로 강제하여 사용할 수 있도록 하자.

튜플 타입에 대하여

타입스크립트에서 튜플 타입은 길이를 가진 배열 형태이다. 각 엘리먼트의 타입이 선언되어 있다. 각 타입은 다를 수 있다.

let tuple = ['Stefan', 38] // tuple is `(string | number)[]`

`as` 키워드를 통해 immutable 하게 선언할 수 있다.

let tuple = ['Stefan', 38] as const // tuple is read-only [string, number]

함수 반환 타입에도 사용할 수 있다.

function useToggleState(id: number): [boolean, () => void] {
  let state = false
  // ... Some magic

  // Type checks!
  return [false, () => { state = !state}]
}