타입스크립트 in 50 레슨

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

Lesson 15

타입스크립트는 값을 생성하는 영역과 타입을 생성하는 영역으로 구분, 함수는 값을 생성

함수는 선언부와 본문으로 구성된다. `declare` 키워드는 함수의 본문을 구현하지 않고 코드를 구성할 수 있게 해준다. 인자부와 반환부에 타입을 선언할 수 있다.

declare function search(query: string, tags?: string[]): Result[]

실제 구현을 하게 되면 이 함수는 비동기로 작동하는데 데이터 fetch 를 위해 `fetch` 함수를 사용하기로 가정하면, 자바스크립트의 fetch 는 `Promise<any>` 를 반환하기 때문에 이 값을 Result 타입으로 캐스팅하여 타입 세이프한 환경을 구성할 수 있다.

function search(query: string, tags?: string[]) {
    //...
    return fetch(`/search${queryString}`)
        .then(response => response.json() as Promise<Result[]>)
}

`as` 키워드는 좌측의 값을 우측의 타입으로 처리한다. 함수 선언부에 반환 정보를 정의하는 것과 같은 역할을 한다.

function search(query: string, tags?: string[]): Promise<Result[]> {
    //...
    return fetch(`/search${queryString}`)
        .then(response => response.json())
}

저자는 함수 선언부에 사용하는 것을 선호한다.

Lesson 16

함수 인자의 콜백 함수에 타입을 정의하여 타입 시스템을 강화

`typeof` 연산을 함수에 적용하면 함수의 타입을 알 수 있다.

type SearchFn = typeof search
type SearchFn = (query: string, tags?: string[] | undefined) => Promise<Result[]>

함수 인자를 `Query` 라는 타입으로 정의해 보자.

type Query = {
    query: string, tags?: string[],
    assemble: (includeTags: boolean) => string
}

'assemble' 인자는 함수 타입을 가지고 이 함수는 includeTags 를 받아서 string 을 반환하는 콜백 함수인 것을 알 수 있다. 물론 이렇게 개선할수도 있다.

type AssembleFn = (includeTags: boolean) => string
type Query = { query: string, tags?: string[], assemble: AssembleFn }

콜백 함수를 사용하는 코드는 특정 기능을 끼워넣을 수 있는 함수로 패턴화 된다. 브라우저의 특정 엘리먼트에서 값을 받아 다른 엘리먼트로 노출하는 함수를 정의한다면 아래와 같이 확장이 가능하다.

declare function displaySearch(
    inputId: string, outputId: string, search: SearchFn
): void

이 함수는 반환하는 값이 없다. (그냥 바깥 세상에 사이드이펙트만 발생시킨다.) `void` 는 lesson 17 에서 조금 더 알아본다.

`구조적 타입 시스템` 에서 함수 인자의 타입 정보는 이름에 제약되지 않고 인자의 전달 순서에 따라 제약된다. 아래 두 함수는 같은 타입 정보를 가진다.

const testSearch: SearchFn = function(query, tags) {
    return Promise.resolve([{
        title: `The ${query} test book`,
        url: `/${query}-design-patterns`,
        abstract: `A practical book on ${query}`
    }])
}

const testSearch: SearchFn = function(term, options) {
    return Promise.resolve([{
        title: `The ${term} test book`,
        url: `/${term}-design-patterns`,
        abstract: `A practical book on ${term}`
    }])
}

위 코드의 경우 'tags' 나 'options' 는 사용되지 않고 있다. 구조적 타입 시스템에서 이 두 번째 인자를 생략해도 타입 점검을 정상적으로 완료한다. 이어지는 레슨에서 이 타입들을 좀 더 다듬어 볼 예정이다.

Lesson 17

대체가능성

이전 코드의 함수 본문에서 첫 번째 인자도 사용하지 않는다면 선언하지 않아도 된다.

const dummyContentSearchFn: SearchFn = function() {
    return Promise.resolve([{
        title: 'Form Design Patterns', url: '/form-design-patterns',
        abstract: 'A practical book on accessible forms'
    }])
}

자바스크립트에서 함수는 인자의 개수에 따라 주의해야할 케이스가 있다. 필요한 인자가 없는 경우와 인자가 너무 많은 경우인데 함수를 실행할 때 필요한 인자가 없는 경우 런타임에 실행 실패한다. 인자가 너무 많은 경우 초과된 인자 그냥 무시된다. 이런 오류들은 타입스크립트를 적용하면 사전 점검된다.

'SearchFn' 을 사용하는 displaySearch 함수의 인자로 콜백 함수를 전달하는데 SearchFn 의 모양을 가지면 사용할 수 있다.

displaySearch('input', 'output', dummyContentSearchFn)

이를 대체 가능성이라 한다.

대체 가능성은 반환하는 값의 타입이 일치하면 이루어진다. 위 코드의 경우 두 콜백 함수 모두 배열 결과를 담은 promise 함수를 반환하고 있다.

타입스크립트의 `void` 는 다른 프로그래밍 언어에서 사용하는 void 와 조금 다르다. 자바스크립트의 모든 함수는 기본적으로 undefined 를 반환한다. 타입스크립트에서도 모든 함수는 반환 타입이 있다. 타입 추론이 어려울 경우 기본적으로 `void` 타입을 반환한다. void 타입의 하나의 값이 있는데 그 값은 'undefined' 이다.

void 타입은 모든 반환 타입에 대해 대체 가능하다. 콜백 함수가 void 반환인 경우 number 를 반환해도 타입 점검은 성공한다. 하지만 실제 반환되는 타입은 undefined 이기 때문에 다른 연산은 할 수 없다.

function search(query: string,
                callback: (results: Result[]) => void,
                tags?: string[]) {
    fetch(`/search${queryString}`)
        .then(res => res.json() as Promise<Result[]>)
        .then(results => {
            const didItWork = callback(results)
            didItWork += 2
        })
}

이 경우 didItWork 는 undefined 를 가지기 때문에 컴파일에 실패한다. 바닐라 자바스크립트에서도 void 를 사용하여 함수의 실행 결과를 undefined 시킬 수 있다.

함수의 반환 타입을 `void` 에서 `undefined` 로 변경하면 대체 가능성을 제거할 수 있다.

callback: (results: Result[]) => undefined,

그리고 옵셔널 파라미터는 가장 마지막에 전달하는게 관행이다.

Lesson 18

콜백 함수의 첫 인자에 명시적으로 this 를 추가하고 타입을 부여할 수 있다.

displaySearch 함수를 작성하는 과정에 this 를 사용하게 되는데 this.value 같은 경우 타입 추론이 실패하여 컴파일이 되지 않는다.

function displaySearch(inputId: string, outputId: string, search: SearchFn): void {
    document.getElementById(inputId)?.addEventListener('change', function () {
        this.parentElement?.classList.add('active')
        const searchTerm = this.value
    })
}

DOM 엘리먼트도 타입스크립트를 위해 타입을 제공하고 있다. 타입을 좁혀 타입스크립트 컴파일러가 추론이 가능하도록 개선해 본다.

        this.parentElement?.classList.add('active')
        if (this instanceof HTMLInputElement) {
            const searchTerm = this.value
        }

이어서 이 addEventListner 의 콜백을 별도의 함수로 분리하면 this 는 콜백 함수의 첫 번째 인자로 this 의 타입을 정의해 줄 수 있다.

function inputChangeHandler(this: HTMLElement) {
    this.parentElement?.classList.add('active')
}

이 인자는 타입스크립트만을 위한 정보로 컴파일된 자바스크립트에는 사라지게된다.

function inputChangeHandler() {
    this.parentElement?.classList.add('active');
}

그리고 this 를 포함한 함수는 호출되는 컨텍스트에 따라 달라지기 때문에 this 를 HTMLElement 타입으로 추론할 수 없는 위치에서 호출하게 되면 컴파일 되지 않는다.

Lesson 19

함수 타입의 추가 기능들

tagged template 함수는 함수 호출에 '(인자)' 대신 '`인자`' 를 사용한다. 태그드 템플릿은 템플릿 문자 배열과 대상이 되는 문자열 표현식으로 구분된다.

taggedTemplate`템플릿A${표현식1}템플릿B`
taggedTemplate`템플릿A${표현식1}템플릿B${표현식2}템플릿C`
taggedTemplate`템플릿A${표현식1}템플릿B${표현식2}템플릿C${표현식3}템플릿D`

템플릿과 표현식은 배열로 처리되는데 표현식은 rest 연산자를 통해 표현된다.

function taggedTemplate(term: string, ...tags: string[]): Promise<Result[]>

Promise 를 반환하는 경우 async 로 함수를 선언하고 await 를 통해 Promise 의 resolved 값을 받을 수 있다.

Lesson 20

함수 오버로딩

타입스크립트는 함수 인자의 개수와 인자/반환 타입이 달라지는 경우를 위해 함수 오버로딩을 지원한다.

function search(term: string, tags?: string[]): Promise<Result[]>
function search(term: string, callback: (results: Result[]) => void, tags?: string[]): void

이 경우 실제 구현 형태는 이렇게 풀이된다.

function search(term: string, p2?: string[] | ((results: Result[]) => void), p3?: string[])

처음으로 `|` 를 통한 유니언 타입이 소개되고 있다. 이 함수를 타입으로 등록하면 좀 더 나은 코드를 볼 수 있다.

type SearchOverload = {
    (term: string, tags?: string[] | undefined): Promise<Result[]>
    (term: string, callback: (results: Result[]) => void, tags?: string[] | undefined): void
}
const search: SearchOverload = (term: string, p2?: string[] | ((results: Result[]) => void), p3?: string[]) => {
    // body
}

Lesson 21

제너레이터

제너레이터는 이터레이션을 구현하기 위해 시간의 흐름에 따라 값을 생성해 제공해 준다. 타입스크립트는 다양한 타입 정보를 제공하여 제너레이터 함수를 손쉽게 사용할 수 있도록 해준다. 제너레이터 함수의 반환 타입은 아래와 유사하다. async 함수가 반환하는 Promise 와 비교해 보면 도움이 될 것이다.

Generator<1 | 2 | 3 | 4, string, unknown>

제너레이터는 풀링이 필요한 시스템에서 유용하다. fetch 를 통해 데이터를 받도록 하자.

type PollingResults = { results: Result[]; done: boolean }

async function polling(term: string): Promise<PollingResults> {
    return fetch('/...').then((res) => res.json())
}

입력이 있을 때마다 백엔드에서 쿼리를 조회하는 제너레이터 함수는 이렇게 구성된다.

async function* getResults(term: string): AsyncGenerator<Result[], void, unknown> {
    let state
    do {
        state = await polling(term)
        yield state.results
    } while (!state.done)
}

AsyncGenerator 타입은 타입스크립트의 시스템 인터페이스로 등록되어 있다.

let resultsGen = getResults(this.value);
for await(results of resultsGen) {
    results.map(someFn)
}

제너레이터는 이터레이터이기 때문에 `for … of` 대상이 된다. yield 의 반환 값을 처리하기 위해 .next() 을 사용할 수 있다. `.next(value)` 를 통해 yield 의 반환 값을 받을 수 있다.

    let state, stop
    do {
        state = await polling(term)
        stop = yield state.results
    } while (!state.done && !stop)

브라우저에서 입력된 값을 사용하는 제너레이터 호출 구문은 아래처럼 구성될 수 있다.

document.getElementById('searchField')?.addEventListener('change', handleChange)

async function handleChange(this: HTMLElement, ev: Event) {
    if (this instanceof HTMLInputElement) {
        let resultsGen = getResults(this.value)
        let next, count = 0
        do {
            next = await resultsGen.next(count >= 5)
            if (typeof next.value !== 'undefined') {
                next.value.map(appendResultToAnswerArea)
                count += next.value.length
            }
        } while (!next.done)
    }
}

await 와 .next(isStop) 을 통해 비동기로 데이터를 요청하고 반환된 결과의 상태에 따라 응답읍 제공한다. 이 경우 AsynGenerator 의 타입은 이렇게 정의된다.

AsyncGenerator<Result[], void, boolean>