본문으로 건너뛰기

03 타입 추론

타입스크립트는 타입 추론을 적극적으로 수행합니다. 타입 추론은 수동으로 명시해야 하는 타입 구문의 수를 엄청나게 줄여 주기 때문에, 코드의 전체적인 안정성이 향상됩니다.

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

코드의 모든 변수에 타입을 선언하는 것은 비생산적이며 형편없는 스타일로 여겨집니다.

let x: number = 12;

타입 추론이 된다면 명시적 타입 구문은 필요하지 않습니다. 오히려 방해가 될 뿐입니다. 만약 타입을 확신하지 못한다면 편집기를 통해 체크하면 됩니다.

const axis1: string = "x"; // 타입은 string
const axis2 = "y"; // 타입은 "y"

axis2 변수를 string으로 예상하기 쉽지만 타입스크립트가 추론한 "y"가 더 정확한 타입입니다. 이러한 추론은 타입 오류를 방지합니다.

타입이 추론되면 리팩터링 역시 용이해집니다.

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

function logProduct(product: Product) {
const id: number = product.id;
// ~ 'string' 형식은 'number' 형식에 할당할 수 없습니다.
const name: string = product.name;
const price: number = product.price;
console.log(id, name, price);
}

logProduct 함수 내의 명시적 타입 구분이 없었다면 타입 체커를 통과했을 겁니다.

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

function logProduct(product: Product) {
const { id, name, price }: { id: string; name: string; price: number } =
product;
console.log(id, name, price);
}

정보가 부족해서 타입스크립트가 스스로 타입을 판단하기 어려운 상황도 일부 있습니다. 그럴 때는 명시적 타입 구문이 필요합니다. logProduct 함수에서 매개변수 타입을 product로 명시한 경우가 그 예입니다.

이상적인 타입스크립트 코드는 함수/매서드 시그니처에 타입 구문을 포함하지만, 함수 내에서 생성된 지역 변수에는 타입 구문을 넣지 않습니다. 타입 구문을 생략하여 방해되는 것들을 최소화하고 코드를 읽는 사람이 구현 로직에 집중할 수 있게 하는 것이 좋습니다.

함수 매개변수에 타입 구문을 생각하는 경우도 있습니다. 기본값이 있는 경우입니다.

function parseNumber(str: string, base = 10) {
// ...
}

보통 타입 정보가 있는 라이브러리에서, 콜백 함수의 매개변수 타입은 자동으로 추론됩니다.

// 이렇게 하지 맙시다.
app.get("/health", (request: express.Request, response: express.Response) => {
response.send("OK");
});

// 이렇게 합시다.
app.get("/health", (request, response) => {
response.send("OK");
});

타입이 추론될 수 있음에도 여전히 타입을 명시하고 싶은 몇 가지 상황이 있습니다. 그중 하나는 객체 리터럴을 정의할 때입니다.

const furby = {
name: "Furby",
id: 630509430963,
price: 35,
};

logProduct(furby);
// ~ ... 형식의 인수는 'Product' 형식의 매개변수에 할당할 수 없습니다.
// 'id' 속성의 형식이 호환되지 않습니다.
// 'number' 형식은 'string' 형식에 할당할 수 없습니다.

그러나 타입 구문을 제대로 명시한다면, 실제로 실수가 발생한 부분에 오류를 표시해 줍니다.

const furby: Product = {
name: "Furby",
id: 630623494,
// ~ 'number' 형식은 'string' 형식에 할당할 수 없습니다.
price: 35,
};

logProduct(furby);

마찬가지로 함수의 반환에도 타입을 명시하여 오류를 방지할 수 있습니다. 타입 추론이 가능할지라도 구현상의 오류가 함수를 호출한 곳까지 영향을 미치지 않도록 하기 위해 타입 구문을 명시하는 게 좋습니다.

주식 시세를 조회하는 함수를 작성했다고 가정해 보겠습니다.

function getQuote(ticker: string) {
return fetch(`https://quotes.example.com/?q=${ticker}`).then((response) =>
response.json()
);
}

이미 조회한 종목을 다시 요청하지 않도록 캐시를 추가합니다.

const cache: { [ticker: string]: number } = {};

function getQuote(ticker: string) {
if (ticker in cache) {
return cache[ticker];
}

return fetch(`https://quotes.example.com/?q=${ticker}`)
.then((response) => response.json())
.then((quote) => {
cache[ticker] = quote;
return quote;
});
}

getQuote는 항상 Promise를 반환하므로 if 구문에는 cache[ticker]가 아니라 Promise.resolve(cache[ticker])가 반환되도록 해야 합니다. 실행해 보면 오류는 getQuote 내부가 아닌 getQuote를 호출한 코드에서 발생합니다.

getQuote("MSFT").then(considerBuying);
// ~ 'number | Promise<any>' 형식에 'then' 속성이 없습니다.
// 'number' 형식에 'then' 속성이 없ㅅ브니다.

이때 의도된 반환 타입 Promise<number>를 명시한다면, 정확한 위치에 오류가 표시됩니다.

const cache: { [ticker: string]: number } = {};

function getQuote(ticker: string): Promise<number> {
if (ticker in cache) {
return cache[ticker];
// ~ 'number' 형식은 'Promise<number>' 형식에 할당할 수 없습니다.
}
}

반환 타입을 명시하면, 함수에 대해 더욱 명확하게 알 수 있습니다. 미리 타입을 명시하는 방법은, 함수를 구현하기 전에 테스트를 먼저 작성하는 테스트 주도 개발과 비슷합니다. 먼저 타입 시그니처를 작성하면 구현에 맞추어 주먹구국식으로 시그니처가 작성되는 것을 방지하고 제대로 원하는 모양을 얻게 됩니다.

반환 타입을 명시해야 하는 두 번째 이유는 명명된 타입을 사용하기 위해서입니다.

interface Vector2D {
x: number;
y: number;
}

function add(a: Vector2D, b: Vector2D) {
return { x: a.x + b.x, y: a.y + b.y };
}

타입스크립트는 반환 타입을 { x: number; y: number; } 로 추론했습니다. 이런 경우 Vector2D와 호환되지만, 입력이 Vector2D인데 반해 출력은 Vector2D가 아니기 때문에 사용자 입장에서 당황스러울 수 있습니다.

반환 타입을 명시하면 더욱 직관적인 표현이 됩니다. 추론된 반환 타입이 복잡해질수록 명명된 타입을 제공하는 이점은 커집니다.

요약

  • 타입스크립트가 타입을 추론할 수 있다면 타입 구문을 작성하지 않는게 좋습니다.
  • 이상적인 경우 함수/메서드의 시그니처에는 타입 구문이 있지만, 함수 내의 지역 변수에는 타입 구문이 없습니다.
  • 추론될 수 있는 경우라도 객체 리터럴과 함수 반환에는 타입 명시를 고려해야 합니다. 이는 내부 구현의 오류가 사용자 코드 위치에 나타나는 것을 방지해 줍니다.

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

타입스크립트는 한 변수를 다른 목적을 가지는 다른 타입으로 재사용할 수 없습니다.

let id = "12-34-56";
fetchProduct(id);

id = 123456;
// ~ '123456' 형식은 'string' 형식에 할당할 수 없습니다.
fetchProductBySerialNumber(id);
// ~ 'string' 형식의 인수는 'number' 형식의 매개변수에 할당될 수 없습니다.

id의 타입을 바꾸지 않으려면, string과 number를 모두 포함할 수 있도록 타입을 확장하면 됩니다. string | number로 표현되며, 유니온(union) 타입이라고 합니다.

let id: string | number = "12-34-56";
fetchProduct(id);
id = 123456;
fetchProductBySerialNumber(id);

유니온 타입으로 코드가 동작하기는 하겠지만 더 많은 문제가 생길 수 있습니다. id를 사용할 때마다 값이 어떤 타입인지 확인해야 하기 때문에 유니온 타입은 string이나 number 같은 간단한 타입에 비해 다루기 더 어렵습니다. 차라리 별도의 변수를 도입하는 것이 낫습니다.

변수를 무분별하게 재사용하면 타입 체커와 사람 모두에게 혼란을 줄 뿐입니다. 다른 타입에는 별도의 변수를 사용하는게 바람직한 이유는 다음과 같습니다.

  • 서로 관련이 없는 두 개의 값을 분리합니다.
  • 변수명을 더 구체적으로 지을 수 있습니다.
  • 타입 추론을 향상시키며, 타입 구문이 불필요해집니다.
  • 타입이 좀 더 간결해집니다.
  • let 대신 const로 변수를 선언하게 됩니다. const로 변수를 선언하면 코드가 간결해지고, 타입 체커가 타입을 추론하기에도 좋습니다.
const id = "12-34-56";
fetchProduct(id);

{
const id = 123456;
fetchProductBySerialNumber(id);
}

위와 같이 '가려지는(shadowed)' 변수는 아무 관련이 없습니다. 그러나 사람에게 혼란을 줄 수 있으므로 사용하면 안됩니다.

요약

  • 변수의 값은 바뀔 수 있지만 타입은 일반적으로 바뀌지 않습니다.
  • 혼란을 막기 위해 타입이 다른 값을 다룰 떄에는 변수를 재사용하지 않도록 합니다.

아이템 21 타입 넓히기

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

타입 넓히기가 진행될 떄, 주어진 값으로 추론 가능한 타입이 여러 개이기 때문에 과정이 상당히 모호합니다.

타입스크립트는 넓히기의 과정을 제어할 수 있도록 몇 가지 방법을 제공합니다. 넓히기 과정을 제어할 수 있는 첫 번째 방법은 const입니다. 만약 let 대신 const로 변수를 선언하면 더 좁은 타입이 됩니다.

그러나 const는 만능이 아닙니다. 객체와 배역의 경우에는 여전히 문제가 있습니다. 다음 코드는 자바스크립트에서 정상입니다.

const v = {
x: 1,
};
v.x = 3;
v.x = "3";
v.y = 4;
v.name = "Pythagoras";

v의 타입은 구체적인 정도에 따라 다양한 모습으로 추론될 수 있습니다. 가장 구체적인 경우라면 { readonly x: 1 } 입니다. 가장 추상적이라면 {{[ key: string]: numbmer }} 또는 object가 될 것입니다.

타입스크립트는 명확성과 유연성 사이의 균형을 유지하려고 합니다.

타입추론의 강도를 직접 제어하려면 타입스크립트의 기본 동작을 재정의해야 합니다.

첫 번째, 명시적 타입 구문을 제공하는 것입니다.

const v: { x: 1 | 3 | 5 } = {
x: 1,
}; // 타입이 { x: 1|3|5; }

두 번쨰, 타입 체커에 추가적인 문맥을 제공하는 것입니다.

세 번째, const 단언문을 사용하는 것입니다.

const v1 = {
x: 1,
y: 2,
}; // 타입은 { x: number; y: number }

const v2 = {
x: 1 as const,
y: 2,
}; // 타입은 { x: 1; y: number; }

const v3 = {
x: 1,
y: 2,
} as const; // 타입은 { readonly x: 1; readonly y: 2; }

값 뒤에 as const를 작성하면, 타입스크립트는 최대한 좁은 타입으로 추론합니다.

넓히기로 인해 오류가 발생한다고 생각되면, 명시적 타입 구문 또는 const 단언문을 추가하느 ㄴ것을 고려해야 합니다.

요약

  • 타입스크립트가 넓히기를 통해 상수의 타입을 추론하는 법을 이해해야 합니다.
  • 동작에 영향을 줄 수 있는 방법인 const, 타입 구문, 문맥, as const에 익숙해져야 합니다.

아이템 22 타입 좁히기

타입 좁히기는 타입스크립트가 넓은 타입으로부터 좁은 타입으로 진행하는 과정을 말합니다. 아마도 가장 일반적인 예시는 null 체크일 겁니다.

const el = document.getElementById("foo"); // 타입이 HTMLElement | null
if (el) {
el.innerHTML = "Party Time".blink();
} else {
alrt("No element #foo");
}

분기문에서 예외를 던지거나 함수를 반환하여 블록의 나머지 부분에서 변수의 타입을 좁힐 수도 있습니다.

const el = document.getElementById("foo");
if (!el) throw new Error("Unable to find #foo");

el.innerHTML = "Party Time".blink();

instanceof를 사용해서 타입을 좁힐 수도 있습니다.

function contains(text: string, search: string | RegExp) {
if (search instanceof RegExp) {
return !!search.exec(text);
}

return text.includes(search);
}

속성 체크로도 타입을 좁힐 수 있습니다.

interface A {
a: number;
}
interface B {
b: number;
}

function pickAB(ab: A | B) {
if ("a" in ab) {
// 타입이 A
} else {
// 타입이 B
}
// 타입이 A | B
}

Array.isArray 같은 내장 함수로도 타입을 좁힐 수 있습니다.

function contains(text: string, terms: string string | string[]) {
const termList = Array.isArray(terms) ? terms : [terms];
}

타입을 좁히는 또 다른 일반적인 방법은 명시적 '태그'를 붙이는 것입니다.

interface UploadEvent {
type: "upload";
filename: string;
contents: string;
}
interface DownloadEvnet {
type: "download";
filename: string;
}
type AppEvent = UploadEvent | DownloadEvent;

function handleEvent(e: AppEvent) {
switch (e.type) {
case "download":
break;
case "upload":
break;
}
}

이 패턴은 '태그된 유니온(tagged union)' 또는 '구별된 유니온(discriminated union)'이라고 불리며, 타입스크립트 어디에서나 찾아볼 수 있습니다.

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

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

이러한 기법을 '사용자 정의 타입 가드'라고 합니다.

예를 들어, 배열에서 어떤 탐색을 수행할 때 undefined가 될 수 있는 타입을 사용할 수 있습니다.

const jackon5 = ["Jackie", "Tito", "Jermaine", "Marlon", "Michael"];
const members = ["Janet", "Michael"]
.map((who) => jackson5.find((n) => n === who))
.filter((who) => who !== undefined);

이럴 때 타입 가드를 사용하면 타입을 좁힐 수 있습니다.

function isDefined<T>(x: T | undefined): x is T {
return x !== undefined;
}
const members = ["Janet", "Michael"]
.map((who) => jackson5.find((n) => n === who))
.filter(isDefined);

요약

  • 분기문 외에도 여러 종류의 제어 흐름을 살펴보며 타입스크립트가 타입을 좁히는 과정을 이해해야 합니다.
  • 태그된/구별된 유니온과 사용자 정의 타입 가드를 사용하여 타입 좁히기 과정을 원활하게 만들 수 있습니다.

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

변수의 값은 변경될 수 있지만, 타입스크립트의 타입은 일반적으로 변경되지 않습니다. 객체를 생성할 때는 속성을 하나씩 추가하기보다는 여러 속성을 포함해서 한꺼번에 생성해야 타입추론에 유리합니다.

interface Point {
x: number;
y: nubmer;
}
const pt: Point = {};
pt.x = 3;
pt.y = 4;

이렇게 하면 오류가 발생하므로 다음과 같이 작성해야 합니다.

const pt = {
x: 3,
y: 4,
};

작은 객체들을 조합해서 큰 객체를 만들어야 하는 경우에는 '객체 전개 연산자'를 사용해야 합니다.

const pt = { x: 3, y: 4 };
const id = { name: "Pythagoras" };
const namedPoint = {};
Object.assign(namedPoint, pt, id);
namedPoint.name;
// ~ {} 형식에 name 속성이 없습니다.
const namedPoint = { ...pt, ...id };
namedPoint.name;

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

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

다음과 같이 추론됩니다.

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 } : {}),
};

다음과 같이 추론됩니다.

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

이 경우는 start와 end가 항상 함께 정의됩니다.

요약

  • 속성을 제각각 추가하지 말고 한꺼번에 객체로 만들어야 합니다. 안전한 타입으로 속성을 추가하려면 객체 전개를 사용하면 됩니다.
  • 객체에 조건부로 속성을 추가하는 방법을 익히도록 합니다.

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

요약

  • 별칭은 타입스크립트가 타입을 좁히는 것을 방해합니다. 따라서 변수에 별칭을 사용할 때는 일관되게 사용해야 합니다.
  • 비구조화 문법을 사용해서 일관된 이름을 사용하는 것이 좋습니다.
  • 함수 호출이 객체 속성의 타입 정제를 무효화할 수 있다는 점을 주의해야 합니다. 속성보다 지역 변수를 사용하면 타입 정제를 믿을 수 있습니다.

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

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

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

가끔 프로미스를 직접 생성해야 할 때, 특히 setTimeout과 같은 콜백 API를 랩핑할 경우가 있습니다. 이 경우 프로미스보다 async/await를 사용해야하는 이유는 다음과 같습니다.

  • 일반적으로 더 간결하고 직관적인 코드가 됩니다.
  • async 함수는 항상 프로미스를 반환하도록 강제됩니다.
// function getNumber(): Promise<number>
async function getNumber() {
return 42;
}

요약

  • 콜백보다는 프로미스를 사용하는 게 코드 작성과 타입 추론 면에서 유리합니다.
  • 가능하면 프로미스를 생성하기보다는 async와 await를 사용하는 것이 좋습니다. 간결하고 직관적인 코드를 작성할 수 있고 모든 종류의 오류를 제거할 수 있습니다.
  • 어떤 함수가 프로미스를 반환한다면 async로 선언하는 것이 좋습니다.

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

요약

  • 타입 추론에서 문맥이 어떻게 쓰이는지 주의해서 살펴봐야 합니다.
  • 변수를 뽑아서 별도로 선언했을 때 오류가 발생한다면 타입 선언을 추가해야 합니다.
  • 변수가 정말로 상수라면 상수 단언을 사용해야 합니다. 그러나 상수 단언을 사용하면 정의한 곳이 아니라 사용한 곳에서 오류가 발생하므로 주의해야 합니다.

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

요약

  • 타입 흐름을 개선하고, 가독성을 높이고, 명시적인 타입 구문의 필요성을 줄이기 위해 직접 구현하기보다는 내장된 함수형 기법과 로대시 같은 유틸리티 라이브러리를 사용하는 것이 좋습니다.