[이펙티브 타입스크립트] 3장 타입 추론
Prog. Langs & Tools/TypeScript

[이펙티브 타입스크립트] 3장 타입 추론

타입스크립트는 타입 추론을 적극적으로 수행한다. 타입 추론은 수동으로 명시해야 하는 타입 구문의 수를 엄청나게 줄여 주기 때문에, 코드의 전체적인 안정성이 향상된다. 숙련된 타입스크립트 개발자는 비교적 적은 수의 구문을 사용한다. 반면, 초보자의 코드는 불필요한 타입 구문으로 도배되어 있다.

이번 장을 읽고 나면 타입스크립트가 어떻게 타입을 추론하는지, 언제 타입 선언을 해야 하는지, 타입 추론이 가능하더라도 명시적으로 타입 선언을 작성하는 것이 필요한 상황은 언제인지 잘 이해할 수 있다.

아이템 19. 추론 가능한 타입을 사용해 장황한 코드 방지하기

타입 추론이 된다면 명시적 타입 구문은 필요하지 않다. 오히려 방해가 될 뿐이다.

let x: number = 12; // 굳이?

let x = 12; // OK

때로는 우리가 예상한 것보다 더 정확할 때도 있다.

const axis1: string = 'x'; // 타입은 string

const axis2 = 'y'; // 타입은 'y'

비구조화 할당문은 모든 지역 변수의 타입이 추론되도록 한다. 여기서 추가적으로 명시적 타입 구문을 넣는다면 불필요한 타입 선언으로 인해 코드가 번잡해진다.

interface Product {
    id: string;
    name: string;
    price: number;
}

function logProduct(product: Product) {
    const {id, name, price} = product; // Good
    const {id, name, price}: {id: string; name: string; price: number} = product; // 굳이?
}

보통 타입 정보가 있는 라이브러리에서, 콜백 함수의 매개변수 타입은 자동으로 추론된다. 다음 예제에서 express HTTP 서버 라이브러리를 사용하는 request와 response의 타입 선언은 필요하지 않다.

// Don't
app.get('/health', (req: express.Request, res: express.Response) => {
    res.send('OK');
});

// Do
app.get('/health', (req, res) => {
    res.send('OK');
});

타입이 추론될 수 있음에도 여전히 타입을 명시하고 싶은 상황도 있다. 예를 들면 객체 리터럴을 정의할 때이다. 타입을 명시하면 잉여 속성 체크가 동작하며, 변수가 사용되는 순간이 아닌 할당하는 시점에 오류가 표시 되도록 해준다.

또한 함수의 반환에도 타입을 명시하여 오류를 방지할 수 있다. 타입 추론이 가능할 지라도 구현상의 오류가 함수를 호출한 곳까지 영향을 미치지 않도록 하기 위해 타입 구문을 명시하는 것이 좋다. 이 외에도 반환 타입을 명시해야 하는 이유는 다음과 같은 것들이 있다.

  1. 반환 타입을 명시하면 함수에 대해 더욱 명확하게 알 수 있기 때문이다. 추후에 코드가 조금 변경되어도 그 함수의 시그니처는 쉽게 바뀌지 않는다. 미리 타입을 명시하는 방법은, 함수를 구현하기 전에 테스트를 먼저 작성하는 TDD와 비슷하다. 전체 타입 시그니처를 먼저 작성하면 구현에 맞추어 주먹구구식으로 시그니처가 작성되는 것을 방지하고 제대로 원하는 모양을 얻게 된다.
  2. 명명된 타입을 사용하기 위해서이다. 반환 타입을 명시하면 더욱 직관적인 표현이 된다. 추론된 반환 타입이 복잡해질수록 명명된 타입을 제공하는 이점은 커진다.

아이템 20. 다른 타입에는 다른 변수 사용하기

변수의 값은 바뀔 수 있지만, 그 타입은 보통 바뀌지 않는다. 타입을 바꿀 수 있는 한 가지 방법은 범위를 좁히는 것인데, 새로운 변수값을 포함하도록 확장하는 것이 아니라 타입을 더 작게 제한하는 것이다.

일반적으로 유니온 타입을 사용하면 코드가 동작하기는 하겠으나, 이 경우 해당 변수를 사용할 때 마다 어떤 타입인지 확인을 해 주어야 하기 때문에 일반 타입에 비해서 다루기가 더 어렵다. 따라서 차라리 다른 타입을 사용해야 한다면 별도의 변수를 사용하는 것이 낫다.

아이템 21. 타입 넓히기

런타임에서 모든 변수는 유일한 값을 가진다. 그러나 타입스크립트가 작성된 코드를 체크하는 정적 분석 시점에, 변수는 '가능한' 값들의 집합인 타입을 가진다. 상수를 사용해서 변수를 초기화할 때 타입을 명시하지 않으면 타입 체커는 타입을 결정해야 한다. 이 말은 지정된 단일 값을 가지고 할당 가능한 값들의 집합을 유추해야 한다는 뜻이다. 타입스크립트에서는 이 과정을 넓히기(widening)라고 부른다.

벡터를 다루는 라이브러리를 가정해보자. Vector3 함수를 사용한 다음 코드는 런타임에서는 오류 없이 실행이 되지만, 편집기에서는 오류가 표시된다.

interface Vector3 { x: number, y: number; z: number }
function getComponent(vector: Vector3, axis: 'x' | 'y' | 'z') {
    return vector[axis];
}

let x = 'x';
let vec = {x: 10, y: 20, z: 30};
getComponent(vec, x); // ~ 'string' 형식의 인수는 '"x" | "y" | "z"' 형식의 매개변수에 할당될 수 없다.

getComponent 함수는 두 번째 매개변수에 "x" | "y" | "z" 타입을 기대했지만, x의 타입은 할당 시점에 넓히기가 동작해서 string으로 추론이 되었다. string 타입은 "x" | "y" | "z" 타입에 할당이 불가능하므로 오류가 된 것이다.

타입 넓히기를 하게 되면 주어진 값으로 추론 가능한 타입이 여러개이기 때문에 모호해질 수 있다. 예를 들어

const mixed = ['x', 1];

여기서 mixed의 타입은 ['x'. 1] 이 될 수도 있고, [string, number]가 될 수도 있고, (string|number)[]가 될 수도 있으며, any[] 가 될 수도 있다.

따라서 타입스크립트는 넓히기의 과정을 제어할 수 있도록 몇 가지 방법을 제공하는데 그 첫 번째 방법은 const를 사용하는 것이다. 두 번째는 타입 체커에 추가적인 문맥을 제공하는 것이다. 마지막 세 번째는 값 뒤에 as const 로 단언문을 추가하는 것이다.

아이템 22. 타입 좁히기

타입 좁히기는 타입스크립트가 넓은 타입으로부터 좁은 타입으로 진행하는 과정을 말한다. null 체크를 하거나 instanceof 로 타입을 체크하는 방법이 있을 수 있다. 또는 Array.isArray() 같은 일부 내장 함수로도 타입을 좁힐 수 있다.

만약 타입스크립트가 타입을 식별하지 못한다면, 식별을 돕기 위해 커스텀 함수를 도입할 수 있다.

function isInputElement(el: HTMLElement): el is HTMLInputElement {
    return 'value' in el;
}

function getElementContent(el: HTMLElement) {
    if(isInputElement(el)) {
        el; // 타입이 HTMLInputElement
        return el.value;
    }
    el;
    return el.textContent;
}

이러한 기법을 '사용자 정의 타입 가드'라고 한다. 반환 타입의 el is HTMLInputElement는 함수의 반환이 true인 경우, 타입 체커에게 타입을 좁힐 수 있따고 알려 준다.

어떤 함수들은 타입 가드를 사용하여 배열과 객체의 타입 좁히기를 할 수 있다.

const jackson5 = ['Jackie', 'Tito', 'Jermaine', 'Marlon', 'Michael'];

const members = ['Janet', 'Micheal'].map(
    who => jackson5.find(n => n === who)
).filter(who => who !== undefined); // 타입이 (string | undefined)[]

function isDefined<T>(x: T | undefined): x is T {
    return x !== undefined;
}

const members2 = ['Janet', 'Micheal'].map(
    who => jackson5.find(n => n === who)
).filter(isDefined); // 타입이 string[]

아이템 23. 한꺼번에 객체 생성하기

타입에 안전한 방식으로 조건부 속성을 추가하려면, 속성을 추가하지 않는 null 또는 {} 으로 객체 전개를 사용하면 된다.

declare let hasMiddle: boolean;
const firstLast = {first: 'Harry', last: 'Truman'};
const president = {...firstLast, ...(hasMiddle ? { middle: '5' } : {})};

편집기에서 president를 보면 타입이 선택적 속성을 가진 것으로 추론된다는 것을 알 수 있다.

const president: {
    middle?: string;
    first: string;
    last: string;
}

전개 연산자로 한꺼번에 여러 속성을 추가할 수도 있다.

declare let hasDates: boolean;
const nameTitle = { name: 'Khufu', title: 'Pharaoh' };
const pharaoh = {
    ...nameTitle,
    ...(hasDates ? {start: -2589, end: -2566} : {})
};

이제는 pharaoh의 타입이 유니온으로 추론됨을 알 수 있다.

const pharaoh: {
    start: number;
    end: number;
    name: string;
    title: string;
} | {
    name: string;
    title: string;
}

pharaoh.start // '{ name: string; title: string; }' 형식에 'start' 속성이 없습니다.

start와 end가 선택적 필드이기를 원했다면 당황할 수 있다. 이 타입에서는 start를 읽을 수 없다.

만약 이를 선택적 필드 방식으로 표현하려면 다음처럼 헬퍼 함수를 사용하면 된다.

function addOptional<T extends object, U extends object>(
    a: T, b: U | null
): T & Partial<U> {
    return { ...a, ...b };
}

const pharaoh = addOptional(
    nameTitle,
    hasDates ? { start: -2589, end: -2566 } : null
);
pharaoh.start // 정상, 타입이 number | undefined

가끔 객체나 배열을 변환해서 새로운 객체나 배열을 생성하고 싶을 수 있다. 이런 경우 루프 대신 내장된 함수형 기법 또는 로대시(Lodash)같은 유틸리티 라이브러리를 사용하는 것이 '한꺼번에 객체 생성하기' 관점에서 보면 옳다.

아이템 24. 일관성 있는 별칭 사용하기

별칭은 타입스크립트가 타입을 좁히는 것을 방해한다. 따라서 변수에 별칭을 사용할 때는 일관되게 사용해야 한다. 이 때 비구조화 문법을 사용해서 일관된 이름을 사용하는 것이 좋다.

함수 호출이 객체 속성의 타입 정제를 무효화할 수 있다는 점을 주의해야 한다. 속성보다 지역 변수를 사용하면 타입 정제를 믿을 수 있다.

아이템 25. 비동기 코드에는 콜백 대신 async 함수 사용하기

ES5 또는 더 이전 버전을 대상으로 할 때, 타입스크립트 컴파일러는 async와 await가 동작하도록 정교한 변환을 수행한다. 다시 말해, 타입스크립트는 런타임에 관계없이 async/await를 사용할 수 있다.

콜백보다 프로미스나 async/await를 사용해야 하는 이유는 다음과 같다.

  • 콜백보다는 프로미스가 코드를 작성하기 쉽다.
  • 콜백보다는 프로미스가 타입을 추론하기 쉽다.

예를 들어 병렬로 페이지를 로드하고 싶다면 Promise.all을 사용하여 프로미스를 조합하면 된다.

async function fetchPage() {
    const [response1, response2, response3] = await Promise.all([
        fetch(url1), fetch(url2), fetch(url3)
    ]);
    // ...
}

타입스크립트는 세 가지 response 변수 각각의 타입을 Response로 추론한다.

콜백 스타일로 동일한 코드를 작성하려면 더 많은 코드와 타입 구문이 필요하다.

function fetchPageCB() {
    let numDone = 0;
    const responses: string[] = [];
    const done = () => {
        const [response1, response2, response3] = responses;
        // ...
    };
    const urls = [url1, url2, url3];
    urls.forEach((url, i) => {
        fetchURL(url, r => {
            response[i] = url;
            numDone++;
            if(numDone === urls.length) done();
        });
    });
}

한편 입력된 프로미스들 중 첫 번째가 처리될 때 완료되는 Promise.race도 타입 추론과 잘 맞다. Promise.race를 사용하여 프로미스에 타임아웃을 추가하는 방법은 흔하게 사용되는 패턴이다.

function timeout(millis: number): Promise<never> {
    return new Promise((resolve, reject) => {
        setTimeout(() => reject('timeout'), millis);
    });
}

async function fetchWithTimeout(url: string, ms: number) {
    return Promise.race([fetch(url), timeout(ms)]);
}

타입 구문이 없어도 fetchWithTimeout의 반환 타입은 Promise로 추론된다. 프로미스를 사용하면 타입스크립트의 모든 타입 추론이 제대로 동작한다.

가끔 프로미스를 직접 생성해야 할 때, 특히 setTimeout과 같은 콜백 API를 래핑할 경우가 있다. 그러나 선택의 여지가 있다면 일반적으로는 프로미스를 생성하기 보다는 async/await를 사용해야 한다. 그 이유는 다음 두 가지이다.

  • 일반적으로 더 간결하고 직관적인 코드가 된다.
  • async 함수는 항상 프로미스를 반환하도록 강제된다.

함수는 항상 동기 또는 항상 비동기로 실행되어야 하며 절대 혼용해서는 안 된다.

async 함수에서 프로미스를 반환하면 또 다른 프로미스로 래핑되지 않는다. 반환 타입은 Promise가 아닌 Promise가 된다. 타입스크립트를 사용하면 타입 정보가 명확히 드러나기 때문에 비동기 코드의 개념을 잡는데 도움이 된다.

// function getJSON(url: string): Promise<any>
async function getJSON(url: string) {
    const response = await fetch(url);
    const jsonPromise = response.json(); // 타입이 Promise<any>
    return jsonPromise;
}

아이템 26. 타입 추론에 문맥이 어떻게 사용되는지 이해하기

타입스크립트는 타입을 추론할 때 단순히 값만 고려하지는 않는다. 값이 존재하는 곳의 문맥까지 살핀다. 문맥을 고려하다 보면 가끔 이상한 결과가 나온다.

type Language = 'JavaScript' | 'TypeScript' | 'Python';
function setLanguage(language: Language) { /* ... */ }

setLanguage('JavaScript'); // 정상

let language = 'JavaScript';
setLanguage(language); // 'string' 타입의 인수는 'language' 형식의 매개변수에 할당할 수 없습니다.

값을 변수로 분리했을 때 타입스크립트는 할당 시점에 타입을 추론한다. 위의 경우는 string으로 추론했고, Language 타입으로 할당이 불가능하므로 오류가 발생하였다.

이러한 문제를 해결하는 데에는 두 가지 방법이 있다. 첫 번째는 타입 선언에서 language의 가능한 값을 제한하는 것이다. 두 번째는 language를 상수로 만들어 주는 것이다.

// 1.
let language: Language = 'JavaScript';
setLanguage(language); // 정상
// 2.
const language = 'JavaScript';
setLanguage(language); // 정상

이 과정에서 사용되는 문맥으로부터 값을 분리하였다. 문맥과 값을 분리하면 추후에 근본적인 문제를 발생시킬 수 있다. 이러한 문맥의 소실로 인해 오류가 발생하는 케이스들을 살펴보자.

튜플 사용 시 주의점

이동이 가능한 지도를 보여주는 프로그램을 작성한다고 가정한다.

function panTo(where: [number, number]) { /* ... */ }

panTo([10, 20]); // 정상

const loc = [10, 20];
panTo(loc); // 'number[]' 형식의 인수는 '[number, number]' 형식의 매개변수에 할당할 수 없다.

여기서도 문맥과 값을 분리하였다. 첫 번째는 [10, 20]이 튜플 타입 [number, number]에 할당 가능하다. 두 번째 경우는 타입스크립트가 loc의 타입을 number[]로 추론한다. 따라서 해당 튜플 타입은 여기에 할당할 수 없다.

any를 쓰지 않고 오류를 고치는 방법은 loc의 타입을 [number, number]로 선언하거나 '상수 문맥'을 사용하는 것이다. const는 단지 값이 가리키는 참조가 변하지 않는 얕은(shallow) 상수인 반면, as const는 그 값이 내부(deeply)까지 상수라는 사실을 타입스크립트에게 알려준다.

const loc: [number, number] = [10, 20];
panTo(loc); // 정상
function panTo(where: readonly [number, number]) { /* ... */ }

const loc: [10, 20] as const;
panTo(loc); // 정상

/* 
만약 panTo의 인자 타입을 수정하지 않았다면 
'readonly [10, 20]' 형식은 'readonly'이며 변경 가능한 형식 '[number, number]'에 할당할 수 없다.
에러가 발생했을 것이다. 
*/

객체 사용 시 주의점

문맥에서 값을 분리하는 문제는 문자열 리터럴이나 튜플을 포함하는 큰 객체에서 상수를 뽑아낼 때도 발생한다.

이 문제는 타입 선언을 추가하거나 상수 단언(as const)을 사용해 해결한다.

콜백 사용 시 주의점

콜백을 다른 함수로 전달할 때, 타입스크립트는 콜백의 매개변수 타입을 추론하기 위해 문맥을 사용한다. 콜백은 상수로 뽑아내면 문맥이 소실되고 noImplicitAny 오류가 발생하게 되는데, 이런 경우는 매개변수에 타입 구문을 추가해서 해결할 수 있다.

아이템 27. 함수형 기법과 라이브러리로 타입 흐름 유지하기

jQuery, UnderScore, Lodash, Ramda 같은 라이브러리들의 일부 기능(map, flatMap, filter, reduce)은 순수 자바스크립트로 구현되어 있다. 이들은 타입스크립트와 조합하면 더 빛을 발한다. 그 이유는 타입 정보가 그대로 유지되면서 타입 흐름(flow)이 계속 전달되도록 하기 때문이다.

자바스크립트에서는 프로젝트에 서드파티 라이브러리(ex. Lodash) 종속성을 추가할 때 신중해야 한다. 하지만 같은 코드를 타입스크립트로 작성하면 서드파티 라이브러리를 사용하는 것이 무조건 유리하다. 타입 정보를 참고하며 작업할 수 있기 때문에 시간을 단축할 수 있다.

데이터의 가공(mungling)이 정교해질수록 이러한 장점은 더욱 분명해진다. 예를 들어 모든 NBA 팀의 선수 명단을 가지고 있다고 가정한다.

interface BasketballPlayer {
    name: string;
    team: string;
    salary: string;
}
declare const rosters: {[team: string]: BasketballPlayer[]};

직접 루프를 사용해 단순(flat) 목록을 만들려면 concat을 사용하고 allPlayers에 타입 구문을 추가해 주어야 한다.

let allPlayers: BasketballPlayer[] = [];
for (const players of Object.values(rosters)) {
    allPlayers = allPlayers.concat(players); // 정상
}

그러나 더 나은 해법은 Array.prototype.flat을 사용하는 것이다.

const allPlayers = Object.values(rosters).flat(); // 정상 타입이 BasketballPlayer[]

flat 메서드는 다차원 배열을 평탄화해(flatten) 준다. 타입 시그니처는 T[][] ⇒ T[] 같은 형태이다.

여러가지 연산을 처리할 때 로대시나 언더스코어 같은 라이브러리를 사용하면 체인의 개념을 사용하기 때문에, 더 자연스러운 순서로 연산을 작성할 수 있다. 체인을 사용하면 연산의 등장과 순서가 동일하게 된다.

// 일반 연산
_.c(_.b(_.a(v)));

// 체인 연산
_(v).a().b().c().value();

여기서 _(v)는 값을 래핑(wrap)하고, .value()는 언래핑(unwrap)한다.

내장된 함수형 기법들과 로대시 같은 라이브러리에 타입 정보가 잘 유지되는 것은 우연이 아니다. 함수 호출 시 전달된 매개변수 값을 건드리지 않고 매번 새로운 값을 반환함으로써, 새로운 타입으로 안전하게 반환할 수 있다.