아이템 19 - 추론 가능한 타입을 사용해 장황한 코드 방지하기
타입 추론이란?
타입스크립트에서 타입 구문을 명시하지 않는다면, 일반적으로 변수의 타입 혹은 함수가 반환하는 타입은 처음 등장할 때 결정되는데, 이것을 타입 추론이라고 합니다.
-
변수의 타입을 명시하지 않고 값을 할당할 경우
let x = 12; // number 타입으로 추론 const person = { name: 'Sojourner Truth', born: { where: 'Swartekill, NY', when: 'c.1797', } }; // { name: string; born: { where: string; when: string; }; } 타입으로 추론
-
함수의 반환 타입을 명시하지 않았을 경우
function square(nums: number[]) { return nums.map(x => x * x); } const squares = square([1, 2, 3, 4]); // number[] 타입으로 추론
이처럼 타입 추론은 타입 구문을 생략하여 장황한 코드를 작성하지 않게 함으로써 코드를 읽는 사람이 구현 로직에 집중할 수 있게 해줍니다.
그러면 언제 타입 추론을 활용하는 것이 좋고, 언제 타입을 명시하는 것이 좋은지 알아봅시다.
타입 추론을 활용하는 경우
-
원시값을 할당
let str = 'string'; const bool = true; const num = 12;
-
함수 내에서 생성된 지역 변수
interface Product { id: string; name: string; price: number; } function logProduct(product: Product) { const { id, name, price } = product; // 비구조화 할당문을 사용 /* ... */ }
-
함수에서 기본값이 있는 매개변수
function parseNumber(str: string, base = 10) { /* ... */ }
-
타입 정보가 있는 라이브러리에서 콜백 함수의 매개변수
namespace express { export interface Request {} export interface Response { send(text: string): void; } } interface App { get(path: string, cb: (request: express.Request, response: express.Response) => void): void; } const app: App = null!; // Don't do this: app.get('/health', (request: express.Request, response: express.Response) => { response.send('OK'); }); // Do this: app.get('/health', (request, response) => { response.send('OK'); });
타입을 명시하는 경우
-
객체 리터럴 정의
객체 리터럴을 정의할 때, 타입을 명시하면 타입스크립트는 잉여 속성 체크를 하기 때문에 객체가 사용되는 곳에서 오류가 발생하는 것이 아니라 객체를 선언한 곳에 오류가 발생합니다.
interface Product { id: string; name: string; price: number; } // 타입을 명시하지 않은 경우 const furby = { id: 630509430963, name: 'Furby', price: 35, }; logProduct(furby); // ~~~~~ Argument .. is not assignable to parameter of type 'Product' // Types of property 'id' are incompatible // Type 'number' is not assignable to type 'string' // 타입을 명시한 경우 const typedFurby: Product = { id: 630509430963, // ~~ Type 'number' is not assignable to type 'string' name: 'Furby', price: 35, }; logProduct(typedFurby);
-
함수의 반환 타입
함수의 반환 타입을 명시하면 함수에 대해 더욱 명확하게 알 수 있고, 명명된 타입을 사용할 수 있기 때문에 타입을 명시하는 것이 좋습니다.
interface Vector2D { x: number; y: number; } function add(a: Vector2D, b: Vector2D): Vector2D { return { x: a.x + b.x, y: a.y + b.y }; }
아이템 20- 다른 타입에는 다른 변수 사용하기
변수에 기본형 타입을 할당하는 방법
타입스크립트에서는 변수에 값을 재할당할 때, 변수를 초기화할 때 지정한 타입과 다른 타입의 값을 할당하면 오류가 발생합니다.
function fetchProduct(id: string) { /* ... */ }
function fetchProductBySerialNumber(id: number) { /* ... */ }
let id = "12-34-56";
fetchProduct(id);
id = 123456; // '123456' is not assignable to type 'string'.
fetchProductBySerialNumber(id);
// ~~ Argument of type 'string' is not assignable to
// parameter of type 'number'
위의 오류를 해결하기 위해 id
의 타입을 string | number
와 같은 유니온 타입으로 변경할 수 있지만, 이러한 타입은 간단한 string
이나 number
타입에 비해 다루기가 더 어렵습니다.
이럴 때는 별도의 변수를 도입하는 것이 더 바람직합니다.
function fetchProduct(id: string) { /* ... */ }
function fetchProductBySerialNumber(id: number) { /* ... */ }
const id = "12-34-56";
fetchProduct(id);
const serial = 123456; // OK
fetchProductBySerialNumber(serial); // OK
이렇게 별도의 변수를 도입하면 다음과 같은 장점을 가질 수 있습니다.
- 서로 관려이 없는 두 개의 값을 분리합니다. (
id
와serial
) - 변수명을 더 구체적으로 지을 수 있습니다.
- 타입 추론을 향상시키며, 타입 구문이 불필요해집니다.
- 타입이 좀 더 간결해집니다. (
string | number
대신string
과number
사용 ) let
대신const
로 변수를 선언하게 되어 코드가 간결해지고, 타입 체커가 타입을 추론하기에도 좋습니다.
따라서 타입이 다른 값을 다룰 때는 변수를 재사용하지 않고 별도의 변수명을 사용하는 것이 좋습니다.
아이템 21 - 타입 넓히기
변수가 초기화 될 때, 타입이 추론되는 과정
타입스크립트에서는 타입을 추론할 때, 지정된 단일 값을 가지고 할당 가능한 값들의 집합을 유추합니다. 이러한 과정을 **넓히기(widening)**라고 부릅니다.
넓히기 과정에서는 일반적으로 변수가 선언된 후로는 타입이 바뀌지 않는 것을 가정하고 명확성과 유연성 사이의 균형을 유지해서 타입을 추론하려고 합니다.
기본형 값의 넓히기 과정 예시
let x = 'x';
변수 x
에는 타입을 명시하지 않았으므로 타입스크립트는 타입 추론을 하게 되는데, 할당 가능한 값들의 집합에는 다음과 같은 후보군이 존재합니다.
any
string
'x'
일반적으로 타입을 추론할 때, 변수가 선언된 후로는 타입이 바뀌지 않는 것을 가정하기 때문에 위 후보군 중에서 any
타입은 다른 타입도 할당할 수 있어 후보군에서 제외됩니다. 또한 변수 x
는 let
으로 선언되었기 때문에, 'x'
라는 string literal이 아닌 다른 string
으로 재할당될 수 있을 것이라 생각하여 타입스크립트는 최종적으로 변수 x
의 타입을 string
으로 추론하게 됩니다.
const x = 'x';
위의 예시와 달리 변수 x
는 const
로 선언되었기 때문에, 다른 string
으로 재할당이 불가능하여 타입스크립트는 최종적으로 변수 x
의 타입을 string literal인 'x'
로 추론하게 됩니다.
객체의 넓히기 과정 예시
const v = { x: 1 };
변수 v
에 할당 가능한 값들의 집합에는 다음과 같은 후보군이 존재합니다.
{ readonly x: 1 }
{ x: number }
{ [key: string]: number }
- …
객체의 경우 넓히기 알고리즘은 각 요소를 let
으로 할당된 것처럼 다룹니다. 따라서 변수 v
의 타입은 { x: number }
가 됩니다.
타입 추론 강도 제어하기
지금까지 타입스크립트에게 지정된 값만을 가지고 타입을 추론하게 했는데, 다음과 같은 방법으로 타입 추론의 강도를 제어할 수 있습니다.
-
명시적 타입 구문 제공
const v: {x: 1|3|5} = { x: 1, }; // Type is { x: 1 | 3 | 5; }
-
추가적인 문맥을 제공 (아이템 26에서 다룸)
-
const
단언문 사용// 값 뒤에 as const를 작성하면, 타입스크립트는 최대한 좁은 타입으로 추론합니다. const v1 = { x: 1, y: 2, }; // Type is { x: number; y: number; } const v2 = { x: 1 as const, y: 2, }; // Type is { x: 1; y: number; } const v3 = { x: 1, y: 2, } as const; // Type is { readonly x: 1; readonly y: 2; }
아이템 22 - 타입 좁히기
여러가지 타입이 가능할 때, 문맥에 따라 특정 타입으로 추론하게 하는 방법
타입 좁히기는 타입스크립트가 넓은 타입으로부터 좁은 타입으로 추론하는 과정을 말하는데, 다음과 같은 방법으로 타입을 좁힐 수 있습니다.
-
조건문
const el = document.getElementById('foo'); // Type is HTMLElement | null if (el) { el // Type is HTMLElement } else { el // Type is null }
-
예외를 던지거나 함수를 반환(early return)
// 예외를 던지는 방법 const el = document.getElementById('foo'); // Type is HTMLElement | null if (!el) throw new Error('Unable to find #foo'); el; // Now type is HTMLElement
-
instanceof
function contains(text: string, search: string|RegExp) { if (search instanceof RegExp) { search // Type is RegExp return !!search.exec(text); } search // Type is string return text.includes(search); }
-
속성 체크
interface A { a: number } interface B { b: number } function pickAB(ab: A | B) { if ('a' in ab) { ab // Type is A } else { ab // Type is B } ab // Type is A | B }
-
Array.isArray
function contains(text: string, terms: string|string[]) { const termList = Array.isArray(terms) ? terms : [terms]; termList // Type is string[] // ... }
-
태그된 유니온(tagged union) 또는 구별된 유니온(discriminated union)
각 객체의 동일한 프로퍼티에 객체마다 다른 값을 지정하여 서로 다른 객체를 구분하는 방법
interface UploadEvent { type: 'upload'; filename: string; contents: string } interface DownloadEvent { type: 'download'; filename: string; } type AppEvent = UploadEvent | DownloadEvent; function handleEvent(e: AppEvent) { switch (e.type) { case 'download': e // Type is DownloadEvent break; case 'upload': e; // Type is UploadEvent break; } }
-
타입 식별을 돕기 위한 커스텀 함수
function isInputElement(el: HTMLElement): el is HTMLInputElement { return 'value' in el; } function getElementContent(el: HTMLElement) { if (isInputElement(el)) { el; // Type is HTMLInputElement return el.value; } el; // Type is HTMLElement return el.textContent; }
함수의 반환 타입에
el is HTMLInputElement
라고 지정하면, 함수의 반환되는 값이true
인 경우 타입 체커에게 매개변수의 타입을 좁힐 수 있다고 알려줄 수 있습니다. 이러한 기법을 사용자 정의 타입 가드라고 합니다.
아이템 23 - 한꺼번에 객체 생성하기
변수에 객체를 할당하는 방법
자바스크립트에서는 객체를 생성할 때 다음과 같이 생성할 수 있습니다.
const pt = {};
pt.x = 3;
pt.y = 4;
하지만 위의 예제를 타입스크립트에서 사용하면 다음과 같은 오류가 발생합니다.
const pt = {};
pt.x = 3;
// ~ Property 'x' does not exist on type '{}'
pt.y = 4;
// ~ Property 'y' does not exist on type '{}'
따라서 타입스크립트에서는 객체를 생성할 때, 속성을 하나씩 추가하기보다는 여러 속성을 포함해서 한꺼번에 생성하는 것이 좋습니다.
타입스트립트에서 객체 생성 방법
-
타입 선언 활용 (
type
키워드 or 인터페이스)interface Point { x: number; y: number; } const pt: Point = { x: 3, y: 4, }; // OK
-
타입 추론 활용
const pt = { x: 3, y: 4, };
-
타입 단언문 활용
interface Point { x: number; y: number; } const pt = {} as Point; pt.x = 3; pt.y = 4; // OK
타입스크립트에서 여러 객체를 합치는 방법
작은 객체들을 조합해서 큰 객체를 만들어야 하는 경우 **객체 전개 연산자(...
)**을 사용하면 됩니다.
interface Point { x: number; y: number; }
const pt = { x: 3, y: 4 };
const id = { name: 'Pythagoras' };
const namedPoint = { ...pt, ...id };
namedPoint.name; // OK, type is string
// 객체 전개 연산자를 사용하면 필드 단위로 객체를 생성할 수도 있습니다.
const pt0 = {};
const pt1 = { ...pt0, x: 3 };
const pt: Point = { ...pt1, y: 4 }; // OK
객체에 조건부 속성을 추가하려면, 속성을 추가하지 않는 null
또는 {}
으로 객체 전개를 사용하면 됩니다.
// 조건부 속성 추가하는 방법
declare let hasMiddle: boolean;
const firstLast = { first: 'Harry', last: 'Truman' };
const president = { ...firstLast, ...(hasMiddle ? { middle: 'S' } : {}) };
// 한꺼번에 여러 속성을 추가하는 경우
declare let hasDates: boolean;
const nameTitle = { name: 'Khufu', title: 'Pharaoh' };
const pharaoh = {
...nameTitle,
...(hasDates ? { start: -2589, end: -2566 } : {})
};
// pharaoh의 타입은 { start: number; end: number; name: string; title: string; } | { name: string; title: string; } 가 된다.
// 만약 { start?: number; end?: number; name: string; title: string; } 타입으로 만들고 싶다면 헬퍼 함수를 사용하면 된다.
function addOptional<T extends object, U extends object>(
a: T, b: U | null
): T & Partial<U> {
return { ...a, ...b };
}
const optionalPharaoh = addOptional(
namedTitle,
hasDates ? { start: -2589, end: -2566 } : null
);
optionalPharaoh.start // OK, type is number | undefined
아이템 24 - 일관성 있는 별칭 사용하기
interface Coordinate {
x: number;
y: number;
}
interface BoundingBox {
x: [number, number];
y: [number, number];
}
interface Polygon {
exterior: Coordinate[];
holes: Coordinate[][];
bbox?: BoundingBox;
}
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
if (polygon.bbox) {
if (pt.x < polygon.bbox.x[0] || pt.x > polygon.bbox.x[1] ||
pt.y < polygon.bbox.y[1] || pt.y > polygon.bbox.y[1]) {
return false;
}
}
// ... more complex check
}
위 예제에서 isPointInPolygon
함수 내부에서 polygon.bbox
라는 것이 중복되어 사용되고 있어 중복을 제거하는 리팩토링을 해보겠습니다.
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
const { bbox } = polygon; // 비구조화 할당을 이용
if (bbox) {
const {x, y} = bbox;
if (pt.x < x[0] || pt.x > x[1] ||
pt.y < x[0] || pt.y > y[1]) {
return false;
}
}
// ...
}
위와 같이 비구조화 할당을 이용하면 보다 간결한 문법으로 일관된 이름을 사용할 수 있습니다.
아이템 26 - 타입 추론에 문맥이 어떻게 사용되는지 이해하기
타입스크립트는 타입을 추론할 때 단순히 값만 고려하지는 않습니다. 값이 존재하는 곳의 문맥까지도 살피는데, 이렇게 문맥을 고려해 타입을 추론하면 가끔 이상한 결과가 나옵니다. 이때 타입 추론에 문맥이 어떻게 사용되는지 이해하고 있다면 제대로 대처할 수 있습니다.
문자열 사용 시 주의점
type Language = 'JavaScript' | 'TypeScript' | 'Python';
function setLanguage(language: Language) { /* ... */ }
setLanguage('JavaScript'); // 이 문맥에서는 string literal로 타입을 추론하기 때문에 OK
// 오류가 발생하는 경우
let language = 'JavaScript'; // 이 문맥에서는 language를 string 타입으로 추론
setLanguage(language);
// ~~~~~~~~ Argument of type 'string' is not assignable
// to parameter of type 'Language'
// 오류 해결책
// 1. let을 그대로 사용하고 타입 명시한다.
let language: Language = 'JavaScript'; // 이 문맥에서는 language를 string literal로 타입을 추론
setLanguage(language); // OK
// 2. const로 변수를 선언한다.
const language = 'JavaScript'; // 이 문맥에서는 language를 string literal로 타입을 추론
setLanguage(language); // OK
튜플 사용 시 주의점
function panTo(where: [number, number]) { /* ... */ }
panTo([10, 20]); // 이 문맥에서는 타입을 [number, number]로 추론하기 때문에 OK
// 오류가 발생하는 경우
const loc = [10, 20]; // 이 문맥에서는 loc의 타입을 number[] 타입으로 추론
panTo(loc);
// ~~~ Argument of type 'number[]' is not assignable to
// parameter of type '[number, number]'
// 오류 해결책
// 1. 변수에 타입을 명시한다.
const loc: [number, number] = [10, 20];
panTo(loc); // OK
// 2. const 단언문을 사용하고 함수 매개변수의 타입을 readonly로 변경한다.
function panTo(where: readonly [number, number]) { /* ... */ }
const loc = [10, 20] as const; // 이 문맥에서는 loc의 타입을 readonly [number, number] 타입으로 추론
panTo(loc); // OK
객체 사용 시 주의점
type Language = 'JavaScript' | 'TypeScript' | 'Python';
interface GovernedLanguage {
language: Language;
organization: string;
}
function complain(language: GovernedLanguage) { /* ... */ }
complain({ language: 'TypeScript', organization: 'Microsoft' }); // 이 문맥에서는 language와 organization을 string literal로 타입을 추론하기 때문에 OK
// 오류가 발생하는 경우
const ts = {
language: 'TypeScript',
organization: 'Microsoft',
};
complain(ts); // 이 문맥에서는 language와 organization을 string 타입으로 추론
// ~~ Argument of type '{ language: string; organization: string; }'
// is not assignable to parameter of type 'GovernedLanguage'
// Types of property 'language' are incompatible
// Type 'string' is not assignable to type 'Language'
// 오류 해결책
// 1. 변수에 타입을 명시한다.
const ts: GovernedLanguage = {
language: 'TypeScript',
organization: 'Microsoft',
};
complain(ts); // OK
// 2. const 단언문을 사용한다.
const ts = {
language: 'TypeScript',
organization: 'Microsoft',
} as const;
complain(ts); // OK
콜백 사용 시 주의점
function callWithRandomNumbers(fn: (n1: number, n2: number) => void) {
fn(Math.random(), Math.random());
}
callWithRandomNumbers((a, b) => { // 이 문맥에서 a와 b를 number 타입으로 추론
a; // Type is number
b; // Type is number
console.log(a + b);
});
콜백을 상수로 뽑아내면 문맥이 소실되고 noImplicitAny
오류가 발생하게 됩니다.
function callWithRandomNumbers(fn: (n1: number, n2: number) => void) {
fn(Math.random(), Math.random());
}
const fn = (a, b) => {
// ~ Parameter 'a' implicitly has an 'any' type
// ~ Parameter 'b' implicitly has an 'any' type
console.log(a + b);
}
callWithRandomNumbers(fn);
// 해결책
// 1. 매개변수에 타입 구문을 추가한다.
const fn = (a: number, b: number) => {
console.log(a + b);
}
callWithRandomNumbers(fn);
// 2. 함수 시그니처를 사용한다.
type CallWithRandomNumbers = (n1: number, n2: number) => void;
const fn: CallWithRandomNumbers = (a, b) => {
console.log(a + b);
}
callWithRandomNumbers(fn);