개요
•
산업계에서 프로그래밍 언어는 정적 타입과 명시적 타입이 전통적으로 같은 의미로 쓰였었다.
◦
그래서 C, C++, Java에서는 타입을 직접 명시하게 되어있다.
•
학술계에선 이 두 단어를 결코 혼동해서 사용하지 않는다.
◦
학술계로 분류되는 하스켈같은 언어는 오래전부터 정교한 타입 추론 시스템을 가지고 있었다.
•
학술계 언어의 발전에 대응하여 10년 전부터는 기존 산업계의 언어에도 타입 추론 기능이 추가되기 시작했다.
◦
C++은 auto를 추가했고 Java는 var를 추가했다.
•
타입스크립트는 타입 추론을 적극적으로 수행한다. 타입 추론은 수동으로 명시해야 하는 타입 구문의 수를 엄청나게 줄여주기 때문에 코드의 전체적인 안정성이 향상된다.
•
이 장에서는 타입 추론에서 발생할 수 있는 몇 가지 문제와 그 해법을 안내한다.
•
타입스크립트가 어떻게 타입을 추론하는지, 언제 타입 선언을 작성해야 하는지, 타입 추론이 가능하더라도 명시적으로 타입 선언을 작성하는 것이 필요한 상황은 언제인지 잘 이해할 수 있게 될 것이다.
item 19. 추론 가능한 타입을 사용해 장황한 코드 방지하기
•
타입스크립트를 처음 접한 자바스크립트 개발자는 여기 저기에 타입 구문을 넣곤 하는데, 사실 타입 추론이 되므로 명시적 타입 구문은 불필요하다. 오히려 방해가 될 뿐이다.
◦
타입을 확신하지 못하겠다면 단순히 IDE를 통해 체크하면 될 뿐이다.
•
타입 추론을 사용하면 리팩토링 역시 용이해진다.
interface Product{
id: number;
name: string;
price: number;
}
function logProduct(product: Product) {
const id: number = product.id;
const name: string = product.name;
const price: number = product.price;
console.log(id, nmae, price);
}
TypeScript
복사
◦
만약 Product의 id의 타입이 변경된다고 가정해보자.
◦
그럼 logProduct 메소드의 변수들의 타입을 강제했기 때문에 오류가 발생할 것이다.
◦
이는 방지하려면 구조 분해 할당을 사용하는 것이 낫다.
function logProduct(product: Product) {
const {id, name, price} = product;
console.log(id, nmae, price);
}
TypeScript
복사
◦
구조 분해 할당은 모든 지역 변수의 타입이 추론되도록 한다.
•
정보가 부족해 타입스크립트가 타입을 추론하기 어려운 상황도 일부있다. 이때 명시적 타입 구문이 필요하다.
◦
보통 타입이 명시된 라이브러리에서 콜백 함수의 매개변수 타입은 자동으로 추론된다.
◦
다음 예제에서 express HTTP 서버 라이브러리를 사용하면 request와 response의 타입 선언은 필요하지 않다.
app.get('/health', (reuqest, response) => {
response.send("Ok");
});
TypeScript
복사
•
이상적인 타입스크립트 코드는 함수, 메소드 시그니처에 타입이 명시되고 본문의 지역 변수에는 명시하지 않는 것이다.
◦
불필요한 요소를 최소화하고 읽는 사람으로 하여금 구현 로직에 초점을 맞추게 한다.
•
타입이 추론될 수 있음에도 여전히 타입을 명시하고 싶은 상황이 몇 있을 것이다. 그 중 하나는 객체 리터럴을 정의할 때다.
const elmo: Product = {
name: 'Tickle Me Elmo',
id: '048188 627152',
price: 28.99,
};
TypeScript
복사
◦
이런 정의에 타입을 명시한다면 잉여 속성 체크가 동작한다.
◦
잉여 속성 체크는 특히 선택적 속성이 있는 타입의 오타 같은 오류를 잡는데 효과적이다. 그리고 변수 사용되는 순간이 아닌 할당되는 시점에 오류를 표시하도록 해준다.
•
만약 타입 구문을 제거한다면 잉여 속성 체크가 동작하지 않고 객체를 선언한 곳이 아닌 사용하는 곳에서 오류가 발생한다.
◦
타입 구문을 제대로 명시했다면 실제로 오류가 발생한 부분에 오류를 표시해준다.
•
함수의 반환값도 타입을 명시하여 오류를 방지할 수 있다. 타입 추론이 가능할지라도 구현상의 오류가 함수를 호출한 곳까지 영향을 미치지 않도록 하기 위해 타입 구문을 명시하는 게 좋다.
function getQuote(ticker: string) {
return fetch(`https://quotes.example.com/?q=${ticker}`)
.then(response => response.json());
}
TypeScript
복사
◦
캐시를 적용시키기 위해 다음과 같은 구문을 추가했다.
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 as number;
});
}
TypeScript
복사
◦
getQutoe는 항상 Promise를 반환하므로 if 구문에는 cache[ticker]가 아닌 Promise.resolve(cache[ticker])가 반환되도록 해야 한다.
◦
따라서 오류가 발생하는데 이때 getQuote 내부가 아닌 getQuote의 클라이언트 코드에서 발생한다.
getQuote('MSFT').then(considerBuying);
// ~~~~
// Property 'then' does not exist on type 'number | Promise<number>'
TypeScript
복사
◦
이때 의도된 반환 타입 Promise<number>을 메소드 시그니처에 명시한다면 정확한 위치에 오류가 표시된다.
◦
반환 타입을 명시하면 구현상의 오류가 클라이언트 코드의 오류로 표시되지 않는다.
•
오류의 위치를 제대로 표시해주는 이점 외에도 반환 타입을 명시해야 하는 이유가 두 가지 더 있다.
◦
첫 번째는 반환 타입을 명시하면 함수에 대해 더욱 명확하게 이해할 수 있게 된다.
◦
두 번째는 명명된 타입을 사용하기 위해서다.
•
일반적으로 반환 타입을 명시하면 더욱 직관적인 표현이 된다. 그리고 반환 값을 별도의 타입으로 정의하면 타입에 대한 주석을 작성할 수 있어 더ㅜㄱ 자세한 설명이 가능하다.
•
요약
◦
타입스크립트가 타입을 추론할 수 있다면 타입 구문을 작성하지 않는 것이 좋다.
◦
이상적인 경우 함수나 메소드의 시그니처엔 타입 구문이 있지만 함수 내의 지역 변수에는 타입 구문이 없다.
◦
추론될 수 있는 경우라도 객체 리터럴과 함수 반환에는 타입 명시를 고려해야 한다.
▪
이는 구현의 오류가 클라이언트 코드에서 발생하는 것을 방지해준다.
item 20. 다른 타입에는 다른 변수 사용하기
•
자바스크립트에서는 한 변수를 다른 목적을 가지는 다른 타입으로 재사용해도 되나 타입스크립트에선 두 오류가 발생한다.
let productId = "12-34-56";
fetchProduct(productId);
productId = 123456;
// ~~~~~~ '123456' is not assignable to type 'string'.
fetchProductBySerialNumber(productId);
// ~~~~~~~~~
// Argument of type 'string' is not assignable to parameter of type 'number'
TypeScript
복사
◦
첫 번째 오류는 타입스크립트는 첫 줄의 초기화 구문으로 id의 타입을 string으로 추론해서 발생했다.
◦
두 번째 오류는 string 형식의 변수에 number 형식의 인수를 할당하려 했기 때문에 발생했다.
•
여기서 변수의 값은 바뀔 수 있지만 그 타입은 보통 바뀌지 않는다는 중요한 관점을 확인할 수 있다.
◦
타입이 변경되는 한 가지 방법은 범위를 좁히는 것이다.
•
다른 타입에는 별도의 변수를 사용하는 것이 바람직하다.
◦
서로 관련이 없는 두 개의 값을 분리해준다.
◦
맥락과 상황에 따라 변수명을 구체적으로 지을 수 있게 된다.
◦
타입 추론을 향상시키며 타입 구문이 불필요해진다.
◦
타입이 더 간결해진다.
◦
let 대신 const로 변수를 선언해 값이 바뀌지 않아 안전하게 사용할 수 있다.
•
타입이 바뀌는 변수는 가급적 피하는 것이 좋으며 목적이 다른 곳에는 별도의 변수를 사용해야 함을 명심하자.
•
요약
◦
변수의 값은 바뀔 수 있지만 타입은 일반적으로 바뀌지 않는다.
◦
혼란을 막기 위해서 타입이 다른 값을 다루는 경우, 변수를 재사용하지 않도록 한다.
item 21. 타입 넓히기
•
모든 변수는 런타임에 유일한 값을 가진다. 그러나 타입스크립트가 작성된 코드를 분석하는 정적 분석 시점에는 가능한 값들의 집합인 타입을 가진다.
•
상수를 사용해서 변수를 초기화하는 경우, 타입을 명시하지 않으면 타입 체커는 할당된 단일 값을 토대로 타입을 유추한다.
•
타입스크립트에서는 이러한 과정을 타입 넓히기라고 부른다.
◦
넓히기 과정을 이해한다면 오류의 원인을 파악하고 타입 구문을 더 효과적으로 사용할 수 있을 것이다.
•
다음 예시를 살펴보자.
interface Vector3 { x : number; y: number; z: number; }
function getComponent(vector: Vector3, axis: 'x' | 'y' | 'z') {
return vector[axis];
}
TypeScript
복사
◦
해당 함수는 런타임에 오류 없이 실행되지만 IDE에서는 오류를 발생시킨다.
let x = 'x';
let vec = {x: 10, y: 20, z: 30};
getComponent(vec, x);
// ~ Argument of type 'string' is not assignable
// to parameter of type '"x" | "y" | "z"'
TypeScript
복사
◦
getComponent 함수는 두 번째 매개변수에 'x' | 'y' | 'z' 타입을 기대했으나 x의 타입이 할당 시점에 넓히기로 인해 string으로 추론되었다.
◦
string 타입은 'x' | 'y' | 'z' 타입에 할당이 불가능하므로 오류가 발생한 것이다.
•
타입스크립트는 추론의 불확실함을 막고 더 정확한 추론을 가능하게 하기 위해 넓히기의 과정을 제어할 수 있도록 몇몇 방법들을 제공한다.
◦
const
▪
let 대신 const로 변수를 선언하면 더 좁은 타입이 된다. 앞선 예제의 경우 const로 선언하면 오류가 발생하지 않는다.
▪
const는 변수를 재할당하지 못하게 막으므로 확장 가능성을 염두에 두지 않아도 된다. 따라서 더 좁은 타입으로 추론할 수 있다.
▪
그러나 객체와 배열의 경우 여전히 문제가 발생한다.
const obj = {
x: 1,
};
obj.x = 3;
obj.x = '3';
obj.y = 4;
obj.z = 5;
obj.name = 'Pythagoras';
TypeScript
복사
•
위 코드는 자바스크립트에선 정상이다.
•
obj의 타입은 구체적인 정도에 따라 다양한 모습으로 추론될 수 있다.
◦
가장 구체적인 경우 {readonly x: 1}일 것이다.
◦
조금 추상적인 경우 {x: number}일 것이다.
◦
가장 추상적이라면 {[key: string]: number}또는 object일 것이다.
•
객체의 경우, 타입스크립트의 넓히기 알고리즘이 각 요소를 let으로 할당한 것처럼 다룬다. 따라서 obj의 타입은 {x: number}가 된다.
•
덕분에 obj.x를 다른 숫자로 재할당은 가능하지만 string으로 재할당은 불가능하다. 그리고 다른 속성을 추가하지도 못한다.
•
따라서 위 코드에서 타입스크립트의 타입 체커가 다음과 같은 오류들을 찾아낸다.
const obj = {
x: 1,
};
obj.x = 3; // OK
obj.x = '3';
// ~ Type '"3"' is not assignable to type 'number'
obj.y = 4;
// ~ Property 'y' does not exist on type '{ x: number; }'
obj.z = 5;
// ~ Property 'z' does not exist on type '{ x: number; }'
obj.name = 'Pythagoras';
// ~~~~ Property 'name' does not exist on type '{ x: number; }'
TypeScript
복사
•
앞서 말했듯 타입스크립트는 명확성과 유연성 그 어딘가의 균형을 유지하려 한다.
◦
오류를 정확히 잡아 내려면 충분히 구체적으로 타입을 추론해야 하지만 잘못된 추론을 할 정도로 구체적으로 수행하지는 않는다.
◦
예를 들어, 1이 할당된 변수는 적당히 number로 타입을 추론한다.
•
타입 추론의 강도를 제어하려면 타입스크립트의 기본 동작을 재정의해야 하는데, 타입스크립트의 기본 동작을 재정의하는 방법에는 세 가지가 있다.
◦
첫 번째, 명시적 타입 구문을 제공하는 것이다.
const obj: { x: string | number } = { x: 1 };
// ^? const obj: { x: string | number; }
TypeScript
복사
◦
두 번째, 타입 체커에 추가적인 문맥을 제공하는 것이다.
▪
if나 instanceof와 같은 문맥을 의미한다.
◦
세 번째, const 단언문을 사용하는 것이다.
▪
const 단언문과 변수 선언에 사용하는 let이나 const를 혼동하면 안 된다. const 단언문은 온전히 타입 차원의 기법이다.
const obj1 = { x: 1, y: 2 };
// ^? const obj1: { x: number; y: number; }
const obj2 = { x: 1 as const, y: 2 };
// ^? const obj2: { x: 1; y: number; }
const obj3 = { x: 1, y: 2 } as const;
// ^? const obj3: { readonly x: 1; readonly y: 2; }
Java
복사
▪
객체의 속성 값 뒤에 as const로 단언문을 작성하면 타입스크립트는 최대한 좁은 타입으로 추론한다. 실제로 obj3은 타입 넓히기가 동작하지 않았다.
•
넓히기로 인해 오류가 발생하는 것 같으면 명시적 타입 구문 또는 as const 단언문을 사용하는 것을 고려해야 한다.
•
요약
◦
타입스크립트가 넓히기를 통해 상수의 타입을 추론하는 법을 이해해야 한다.
◦
타입 체커의 동작에 영향을 줄 수 있는 방법인 const, 타입 구문, 문맥, as const에 익숙해져야 한다.
item 22. 타입 좁히기
•
타입 좁히기는 타입스크립트가 넓은 타입으로부터 좁은 타입으로 진행하는 과정을 의미한다.
•
일종의 객체의 타입을 검사하는 것이라고 생각한다.
◦
가장 일반적인 예시는 null 체크다.
const elem = document.getElementById('what-time-is-it');
// ^? const elem: HTMLElement | null
if (elem) {
elem.innerHTML = 'Party Time'.blink();
// ^? const elem: HTMLElement
} else {
elem
// ^? const elem: null
alert('No element #what-time-is-it');
}
TypeScript
복사
▪
만약 elem이 null이라면 분기문의 첫 번째 블록이 실행되지 않는다. 즉 첫 번째 블록에서 HTMLElement | null 타입의 null을 제외하므로 더 좁은 타입이 되어 작업이 훨씬 쉬워진다.
▪
타입 체커는 일반적으로 이러한 조건문에서 타입 좁히기를 잘 해내지만 타입 별칭이 존재한다면 그러지 못할 수 있다.
◦
다음은 instacneof를 사용해서 타입을 좁히는 방법이다.
function contains(text: string, search: string | RegExp) {
if (search instanceof RegExp) {
return !!search.exec(text);
// ^? (parameter) search: RegExp
}
return text.includes(search);
// ^? (parameter) search: string
}
TypeScript
복사
◦
속성 체크로도 타입을 좁힐 수 있다.
interface Apple { isGoodForBaking: boolean; }
interface Orange { numSlices: number; }
function pickFruit(fruit: Apple | Orange) {
if ('isGoodForBaking' in fruit) {
fruit
// ^? (parameter) fruit: Apple
} else {
fruit
// ^? (parameter) fruit: Orange
}
fruit
// ^? (parameter) fruit: Apple | Orange
}
TypeScript
복사
•
타입스크립트는 일반적으로 조건문에서 타입을 좁히는데 매우 능숙하다. 그러나 타입을 섣불리 판단하는 실수를 저지르기 쉬으므로 다시 한번 꼼꼼히 살펴봐야 한다.
const elem = document.getElementById('what-time-is-it');
// ^? const elem: HTMLElement | null
if (typeof elem === 'object') {
elem;
// ^? const elem: HTMLElement | null
}
TypeScript
복사
◦
위 코드에서 typeof elem에서 elem이 null일 경우, 연산 결과가 “object” 이므로 if 문에서 null 타입이 제외되지 않는다.
•
타입을 좁히는 또 다른 일바적인 방법은 명시적 태그를 붙이는 것이다.
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':
console.log('Download', e.filename);
// ^? (parameter) e: DownloadEvent
break;
case 'upload':
console.log('Upload', e.filename, e.contents.length, 'bytes');
// ^? (parameter) e: UploadEvent
break;
}
}
TypeScript
복사
◦
이 패턴은 태그된 유니온 또는 구별된 유니온이라고 부르며 자주 찾아볼 수 있다.
•
만약 타입스크립트가 타입을 식별하지 못한다면 식별을 돕기 위해 커스텀 함수를 도입할 수 있다.
function isInputElement(el: HTMLElement): el is HTMLInputElement {
return 'value' in el;
}
function getElementContent(el: HTMLElement) {
if (isInputElement(el)) {
return el.value;
// ^? (parameter) el: HTMLInputElement
}
return el.textContent;
// ^? (parameter) el: HTMLElement
}
TypeScript
복사
◦
이러한 기법을 사용자 정의 타입 가드라고 한다.
•
요약
◦
분기문 외에도 여러 종류의 제어 흐름을 살펴보며 타입스크립트가 타입을 좁히는 과정을 이해해야 한다.
◦
태그된/구별된 유니온과 사용자 정의 타입 가드를 사용하여 타입 좁히기 과정을 원활하게 만들 수 있다.
item 23. 한꺼번에 객체 생성하기
•
값은 변경될 수 있지만 타입스크립트의 타입은 일반적으로 변경되지 않는다.
•
이런 특성 덕분에 일부 자바스크립트 패턴을 타입스크립트로 모델링하는게 쉬워진다.
•
즉 객체를 생성할 때는 속성을 하나씩 추가하기보다는 여러 속성을 포함해서 한꺼번에 생성해야 타입 추론에 유리하다.
•
다음은 정상적으로 동작하는 자바스크립트 코드다.
const pt = {};
pt.x = 3;
pt.y = 4;
TypeScript
복사
•
타입스크립트에선 다음과 같은 오류가 발생할 것이다.
const pt = {};
// ^? const pt: {}
pt.x = 3;
// ~ Property 'x' does not exist on type '{}'
pt.y = 4;
// ~ Property 'y' does not exist on type '{}'
TypeScript
복사
◦
pt의 타입이 {}로 추론되기 때문이다. 존재하지 않는 속성을 추가할 수 있는 방법은 존재하지 않는다.
•
Point 인터페이스를 정의해두고 사용한다면 오류가 객체 생성 시로 옮겨갈 것이다.
interface Point { x: number; y: number; }
const pt: Point = {};
// ~~ Type '{}' is missing the following properties from type 'Point': x, y
pt.x = 3;
pt.y = 4;
TypeScript
복사
⇒ 책에는 객체를 나눠서 만드는 방식에 대한 설명도 있으나 객체는 생성 시점에 온전해야 한다고 생각하기 때문에 정리해두지는 않았습니다.
•
객체에 조건부로 속성을 추가하려면 속성을 추가하지 않는 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;
// }
// or: const president = {...firstLast, ...(hasMiddle && {middle: 'S'})};
TypeScript
복사
•
요약
◦
속성을 제각각 추가하지 말고 한꺼번에 객체로 만들어야 한다. 안전한 타입으로 속성을 추가하려면 객체 전개 연산자 ...를 사용하면 된다.
◦
객체에 조건부로 속성을 추가하는 방법을 익히도록 하자.
item 24. 일관성 있는 별칭 사용하기
const place = {name: 'New York', latLng: [41.6868, -74.2692]};
const loc = place.latLng;
TypeScript
복사
•
place.latLng 배열에 loc이라는 별칭을 만들었다. 이때 별칭의 값을 변경하면 원래 속성값에서도 변경된다.
•
별칭을 남발해서 사용하면 제어 흐름을 분석하기가 어려우므로 타입 정제 흐름을 방해한다. 별칭을 신중하게 사용해야 코드를 잘 이해할 수 있고 오류도 쉽게 찾을 수 있다.
•
다음은 다각형을 의미하는 자료구조다.
interface Coordinate {
x: number;
y: number;
}
interface BoundingBox {
x: [number, number];
y: [number, number];
}
interface Polygon {
exterior: Coordinate[];
holes: Coordinate[][];
bbox?: BoundingBox;
}
TypeScript
복사
◦
다각형의 기하학적 구조는 exterior와 holes 속성으로 정의된다. bbox는 필수가 아닌 최적화 속성이다. bbox 속성을 사용하면 어떤 점이나 다각형에 포함되는지 빠르게 확인할 수 있다.
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[0] || pt.y > polygon.bbox.y[1]) {
return false;
}
}
// ... more complex check
}
TypeScript
복사
▪
이 코드는 잘 동작하지만 반복되는 부분이 존재한다. polygon.bbox가 5번이나 등장한다.
◦
다음 코드는 중복을 줄이기 위해 임시 변수를 뽑아낸 모습이다.
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
const box = polygon.bbox;
if (polygon.bbox) {
if (pt.x < box.x[0] || pt.x > box.x[1] ||
// ~~~ ~~~ Object is possibly 'undefined'
pt.y < box.y[0] || pt.y > box.y[1]) {
// ~~~ ~~~ Object is possibly 'undefined'
return false;
}
}
// ...
}
TypeScript
복사
▪
이 코드는 동작하지만 IDE에서 오류로 표시된다. 그 이유는 polygon.bbox를 별도의 box라는 별칭을 만들었고 첫 번째 예제에서 잘 동작했던 제어 흐름 분석을 방해했기 때문이다.
▪
polygon.bbox에 대한 undefined 검사는 수행해 타입을 좁혔으나 box에 대한 undefined 타입 정제를 진행하지 않아 타입이 좁혀지지 않았기 때문이다. 속성 체크에 box를 사용하면 간단히 해결된다.
◦
가독성을 조금 더 개선시키려면 객체 구조 분해할당을 사용하면 된다.
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 < y[0] || pt.y > y[1]) {
return false;
}
}
// ...
}
TypeScript
복사
▪
이때 두 가지를 주의해야 한다.
•
전체 bbox 속성이 아니라 bbox.x, bbox.y를 사용하는 경우, 이들이 선택적 속성인 경우에는 속성 체크가 더 필요하다. 따라서 타입의 경계에 null 값을 추가하는 것이 좋다.
•
bbox에는 선택적 속성이 적합했지만 holes는 그렇지 않다. 만약 이 변수가 선택적이라면 빈 배열이었을 것이다.
◦
타입스크립트의 제어 흐름 분석은 지역 변수에서는 잘 동작하나 객체 속성에서는 주의가 필요하다.
function expandABit(p: Polygon) { /* ... */ }
polygon.bbox
// ^? (property) Polygon.bbox?: BoundingBox | undefined
if (polygon.bbox) {
polygon.bbox
// ^? (property) Polygon.bbox?: BoundingBox
expandABit(polygon);
polygon.bbox
// ^? (property) Polygon.bbox?: BoundingBox
}
TypeScript
복사
▪
expandABit가 polygon.bbox에 무슨 일을 저지를지 모른다. 그러므로 타입을 BoundingBox | undefined로 되돌리는 것이 안전하지만 이는 비용이 많이 드는 작업이다.
▪
그래서 타입스크립트는 함수가 타입 정제를 무효화하는 행위를 저지르지 않을 것임을 가정한다.
⇒ 그러나 실제로는 무효화될 가능성이 존재한다! 보이는 것만 보지 말자.
▪
polygon.bbox로 사용하는 대신 box로 별칭을 뽑아내서 사용하면 box의 타입은 유지되지만 함수의 실행 결과 때문에 polygon.bbox의 값과 달라질 수 있다.
•
요약
◦
별칭은 타입스크립트가 타입을 좁히는 것 즉 타입 정제 행위를 방해한다. 따라서 변수에 별칭을 사용할 때는 일관성 있게 사용해야 한다.
◦
객체 구조 분해 할당 문법을 사용해서 일관된 이름을 사용하는 것이 좋다.
◦
함수 호출이 객체 속성의 타입 정제를 무효화할 수 있다는 점을 주의해야 한다. 속성보다 지역 변수를 사용하면 타입스크립트의 타입 정제를 믿을 수 있다.
item 25. 비동기 코드에는 콜백 대신 async 함수 사용하기
•
과거의 자바스크립트에서는 비동기 동작을 모델링하기 위해 콜백을 사용했다. 이는 곧 수많은 콜백 지옥을 불러오곤 했다.
declare function fetchURL(
url: string, callback: (response: string) => void
): void;
fetchURL(url1, function(response1) {
fetchURL(url2, function(response2) {
fetchURL(url3, function(response3) {
// ...
console.log(1);
});
console.log(2);
});
console.log(3);
});
console.log(4);
// Logs:
// 4
// 3
// 2
// 1
TypeScript
복사
•
ES2015는 콜백 지옥을 극복하기 위해서 Promise라는 개념을 도입했다. Promise는 미래에 가능해질 어떤 것을 나타낸다.
const page1Promise = fetch(url1);
page1Promise.then(response1 => {
return fetch(url2);
}).then(response2 => {
return fetch(url3);
}).then(response3 => {
// ...
}).catch(error => {
// ...
});
TypeScript
복사
◦
코드의 중첩도 적어졌고 실행 순서도 코드의 순서와 같아졌다. 또한 오류를 처리(catch)하기도 Promise.all과 같은 고급 기법을 사용하기도 더 쉬워졌다.
•
ES2017은 async와 await 키워드를 도입하여 콜백 지옥을 더 간단하게 만들었다.
async function fetchPages() {
const response1 = await fetch(url1);
const response2 = await fetch(url2);
const response3 = await fetch(url3);
// ...
}
TypeScript
복사
◦
await 키워드는 각각의 Promise 작업들이 처리(resolve)될 때까지 fetchPages 함수의 실행을 멈춘다.
◦
async 함수 내에서 await 중인 Promise가 거절(reject)되면 예외를 던진다.
◦
이를 통해 일반적인 try/catch 구문을 사용할 수 있다.
async function fetchPages() {
try {
const response1 = await fetch(url1);
const response2 = await fetch(url2);
const response3 = await fetch(url3);
// ...
} catch (e) {
// ...
}
}
TypeScript
복사
•
ES5 또는 더 이전 버전을 대상으로 할 때 타입스크립트 컴파일러는 async와 await이 동작하도록 정교한 변환을 수행한다.
◦
이를 통해 타입스크립트는 런타임과 상관없이 async, await을 사용한 추론을 제공한다.
•
콜백보다 Promise나 async/await을 사용해야 하는 이유는 다음과 같다.
◦
콜백보다는 Promise가 코드를 작성하기 쉽다.
◦
콜백보다는 Promise가 타입을 추론하기 쉽다.
•
병렬로 뭔가 데이터를 받아오고 싶다면 Promise.all을 사용해서 Promise를 조합하면 된다.
async function fetchPages() {
const [response1, response2, response3] = await Promise.all([
fetch(url1), fetch(url2), fetch(url3)
]);
// ...
}
TypeScript
복사
◦
이런 경우 await과 구조 분해 할당이 잘 맞는다.
•
한편 입력된 Promise들 중 첫 번째가 처리될 때 완료되는 Promise.race도 타입 추론과 잘 동작한다. 이를 사용해서 Promise에 타임아웃을 추가하는 방법은 흔하게 사용되는 패턴이다.
function timeout(timeoutMs: number): Promise<never> {
return new Promise((resolve, reject) => {
setTimeout(() => reject('timeout'), timeoutMs);
});
}
async function fetchWithTimeout(url: string, timeoutMs: number) {
return Promise.race([fetch(url), timeout(timeoutMs)]);
}
TypeScript
복사
•
Promise를 생성하기 보다는 async/await를 사용해야 한다.
◦
일반적으로 더 간결하고 직관적인 코드가 된다.
◦
async 함수는 항상 프로미스를 반환하도록 강제한다.
•
요약
◦
콜백보다는 Promise를 사용하는 게 코드 작성과 타입 추론 면에서 유리하다.
◦
가능하면 Promise를 생성하기보다는 async와 await을 사용하는 것이 좋다. 간결하고 직관적인 코드를 작성할 수 있고 모든 종류의 오류를 처리할 수 있다.
◦
어떤 함수가 Promise를 반환한다면 async로 선언하여 함수의 비동기, 동기 실행 정체성을 명확히하는 것이 좋다.
item 26. 타입 추론에 문맥이 어떻게 사용되는지 이해하기
•
타입스크립트는 타입을 추론할 때, 단순히 값만을 고려하지는 않는다. 값이 존재하는 곳의 문맥까지도 살핀다.
◦
이때문에 가끔 문맥을 고려한 타입 추론의 결과가 이상한 결과가 나올 때가 있다.
◦
타입 추론에 문맥이 어떻게 사용되는지 이해하고 있다면 제대로 대처할 수 있다.
•
자바스크립트는 코드의 동작과 실행 순서를 바꾸지 않으면서 표현식을 상수로 분리해낼 수 있다.
// Inline form
setLanguage('JavaScript');
// Reference form
let language = 'JavaScript';
setLanguage(language);
JavaScript
복사
•
타입스크립트에서는 다음 리팩토링이 여전히 동작한다.
function setLanguage(language: string) { /* ... */ }
setLanguage('JavaScript'); // OK
let language = 'JavaScript';
setLanguage(language); // OK
JavaScript
복사
◦
여기서 문자열 타입을 더 특정해서 문자열 리터럴 타입의 유니온으로 바꾼다고 가정해보자.
type Language = 'JavaScript' | 'TypeScript' | 'Python';
function setLanguage(language: Language) { /* ... */ }
setLanguage('JavaScript'); // OK
let language = 'JavaScript';
setLanguage(language);
// ~~~~~~~~ Argument of type 'string' is not assignable
// to parameter of type 'Language'
JavaScript
복사
◦
인라인 형태에서 타입스크립트는 함수 선언을 통해 매개변수가 Language 타입이어야 한다는 것을 알고 있다.
▪
해당 타입에 문자열 리터럴 ‘JavaScript’는 할당 가능하므로 정상이다.
▪
그러나 이 값을 변수로 분리해내면 타입스크립트는 할당 시점에 타입을 추론한다. 따라서 string으로 추론하게되고 오류가 발생한다.
◦
이런 문제를 해결하는 방법은 두 가지가 있다.
▪
첫 번째는 타입 선언에서 language의 가능한 값을 제한하는 것이다.
let language: Language = 'JavaScript';
setLanguage(language); // OK
JavaScript
복사
▪
두 번째 해법은 language를 상수로 만드는 것이다.
const language = 'JavaScript';
setLanguage(language); // OK
JavaScript
복사
•
const를 사용하여 타입 체커에게 language는 변경할 수 없다고 알려주므로서 더 정확한 문자열 타입인 ‘JavaScript’로 추론하게끔 한다.
◦
이 과정에서 사용되는 문맥으로부터 값을 분리했다. 문맥과 값을 분리하면 추후 근본적인 문제를 발생시킬 수 있다.
▪
튜플 사용 시 주의점
•
문자열 리터럴 타입과 마찬가지로 튜플 타입에서도 문제가 발생한다.
// Parameter is a (latitude, longitude) pair.
function panTo(where: [number, number]) { /* ... */ }
panTo([10, 20]); // OK
const loc = [10, 20];
panTo(loc);
// ~~~ Argument of type 'number[]' is not assignable to
// parameter of type '[number, number]'
JavaScript
복사
◦
이전 예제처럼 여기서도 문맥과 값을 분리한다. 첫 번째 경우는 [10, 20]이 튜플 타입 [number, number]에 할당 가능하다.
◦
두 번째 경우는 타입스크립트가 loc의 타입을 number[]로 추론하므로 튜플 타입에 할당할 수 없다.
•
타입스크립트가 의도를 정확히 파악할 수 있도록 타입 선언을 제공하는 방법은 다음과 같다.
const loc: [number, number] = [10, 20];
panTo(loc); // OK
JavaScript
복사
•
또 다른 방법은 상수 문맥을 제공하는 것이다. const는 단지 값이 가리키는 참조가 변하지 않는 얕은 상수인 반면 as const는 그 값이 내부까지 상수라는 사실을 타입스크립트에게 알려준다.
const loc = [10, 20] as const;
panTo(loc);
// ~~~ Type 'readonly [10, 20]' is 'readonly'
// and cannot be assigned to the mutable type '[number, number]'
JavaScript
복사
◦
그러나 이런 추론은 과하게 정확하다. panTo의 타입 시그니처는 where의 내용이 불변이라고 보장하지 않는다.
◦
따라서 any를 사용하지 않고 오류를 고칠 수 있는 최선의 해결책은 panTo 함수에 readonly 구문을 추가하는 것이다.
function panTo(where: readonly [number, number]) { /* ... */ }
const loc = [10, 20] as const;
panTo(loc); // OK
JavaScript
복사
•
타입 시그니처를 수정할 수 없는 경우라면 타입 구문을 사용해야 한다.
◦
as const는 문맥 손실과 관련한 문제를 깔끔하게 해결할 수 있지만 만약 타입 정의에 실수가 있다면 오류는 타입 정의가 아니라 호출되는 곳에서 발생한다.
const loc = [10, 20, 30] as const; // error is really here.
panTo(loc);
// ~~~ Argument of type 'readonly [10, 20, 30]' is not assignable to
// parameter of type 'readonly [number, number]'
// Types of property 'length' are incompatible
// Type '3' is not assignable to type '2'
JavaScript
복사
▪
객체 사용 시 주의 점
•
문맥에서 값을 분리하는 문제는 객체에서 상수를 뽑아낼 때도 발생한다.
type Language = 'JavaScript' | 'TypeScript' | 'Python';
interface GovernedLanguage {
language: Language;
organization: string;
}
function complain(language: GovernedLanguage) { /* ... */ }
complain({ language: 'TypeScript', organization: 'Microsoft' }); // OK
const ts = {
language: 'TypeScript',
organization: 'Microsoft',
};
complain(ts);
// ~~ 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'
JavaScript
복사
◦
ts 객체의 language 속성은 string으로 추론된다. 이 문제는 타입 선언을 추가하거나 상수 단언으로 해결된다.
•
요약
◦
타입 추론에서 문맥이 어떻게 쓰이는지 주의해서 살펴봐야 한다.
◦
변수를 추출해서 별도로 선언했을 때, 오류가 발생한다면 타입 선언을 사용해야 한다.
◦
변수가 정말로 상수라면 상수 단언을 사용한다. 그러나 상수 단언을 사용하면 정의한 곳이 아닌 호출한 곳에서 오류가 발생한다.
item 27. 함수형 기법과 라이브러리로 타입 흐름 유지하기
•
람다같은 최근의 라이브러리는 함수형 프로그래밍의 개념을 자바스크립트 세계에 도입하고 있다.
•
이러한 라이브러리들의 일부 기능(map, flatMap, filter, reduce 등)은 순수 자바스크립트로 구현되어 있다. 이러한 기법은 루프를 대체할 수 있기 때문에 유용하게 사용된다.
•
이는 타입스크립트와 조합하여 사용하면 더 빛을 발하는데, 그 이유는 타입 정보가 그대로 유지되면서 타입 흐름이 계속 전달되도록 하기 때문이다.
◦
반면에 직접 루프를 구현한다면 타입 체크에 대한 관리도 직접 해야 한다.
•
예를 들어 어떤 CSV 파일을 파싱한다고 해보자.
◦
순수 자바스크립트에서는 절차형 프로그래밍 형태로 구현할 수 있다.
const csvData = "...";
const rawRows = csvData.split('\n');
const headers = rawRows[0].split(',');
const rows = rawRows.slice(1).map(rowStr => {
const row = {};
rowStr.split(',').forEach((val, j) => {
row[headers[j]] = val;
});
return row;
});
JavaScript
복사
◦
함수형 마인드를 조금이라도 가진 개발자는 reduce를 사용해 행 객체를 만드는 방법을 선호할 수 있다.
const rows = rawRows.slice(1)
.map(rowStr => rowStr.split(',').reduce(
(row, val, i) => (row[headers[i]] = val, row),
{}));
TypeScript
복사
▪
이 코드는 절차형 코드에 비해 세 줄을 절약했지만 보는 사람에 따라 더 복잡하게 느껴질 수도 있다.
◦
키와 값 배열로 취합해서 객체로 만들어 주는 로대시의 zipObject 함수를 이용하면 코드를 더 짧게 만들 수 있다.
import _ from 'lodash';
const rows = rawRows.slice(1)
.map(rowStr => _.zipObject(headers, rowStr.split(',')));
TypeScript
복사
▪
코드가 매우 짧아졌다. 그러나 자바스크립트에서는 프로젝트에 서드파티 라이브러리 종속성을 추가할 땐 신중해야 한다.
▪
만약 서드파티 라이브러리 기반으로 코드를 짧게 줄이는 데 시간이 많이 든다면 서드파티 라이브러리를 사용하지 않는 게 낫기 때문이다.
▪
그러나 같은 코드를 타입스크립트로 작성하면 서드파티 라이브러리를 사용하는 것이 무조건 유리한다.
•
타입 정보를 참고하며 작업할 수 있기 때문에 서드파티 라이브러리 기반으로 바꾸는데 시간이 훨씬 단축된다.
•
한편 CSV 파서의 절차형 버전과 함수형 버전 모두 같은 오류를 발생시킨다.
const rowsA = rawRows.slice(1).map(rowStr => {
const row = {};
rowStr.split(',').forEach((val, j) => {
row[headers[j]] = val;
// ~~~~~~~~~~~~~~~ No index signature with a parameter of
// type 'string' was found on type '{}'
});
return row;
});
const rowsB = rawRows.slice(1)
.map(rowStr => rowStr.split(',').reduce(
(row, val, i) => (row[headers[i]] = val, row),
// ~~~~~~~~~~~~~~~ No index signature with a parameter of
// type 'string' was found on type '{}'
{}));
TypeScript
복사
◦
두 버전 모두 {}의 타입으로 {[column: string]: string} 또는 Record<string, string>을 제공하면 오류가 해결된다.
•
반면 로대시 버전은 별도의 수정 없이도 타입 체커를 통과한다.
const rows = rawRows.slice(1)
.map(rowStr => _.zipObject(headers, rowStr.split(',')));
// Type is _.Dictionary<string>[]
TypeScript
복사
◦
Dictionary는 로대시의 타입 별칭으로 Dictionary<string>은 {[key: string]: string} 또는 Record<string, string>과 동일하다.
◦
여기서 중요한 점은 타입 구문이 없어도 rows의 타입이 정확하다는 것이다.
•
루프를 사용해 단순(flat) 목록을 만들려면 배열에 concat을 사용해야 한다.
◦
다음 코드는 동작이 되지만 타입 체크는 되지 않는다.
let allPlayers = [];
// ~~~~~~~~~~ Variable 'allPlayers' implicitly has type 'any[]'
// in some locations where its type cannot be determined
for (const players of Object.values(rosters)) {
allPlayers = allPlayers.concat(players);
// ~~~~~~~~~~ Variable 'allPlayers' implicitly has an 'any[]' type
}
TypeScript
복사
◦
이 오류를 고치려면 allPlayers에 타입 구문을 추가해야 한다.
let allPlayers: BasketballPlayer[] = [];
for (const players of Object.values(rosters)) {
allPlayers = allPlayers.concat(players); // OK
}
TypeScript
복사
•
그러나 더 나은 방법은 Array.prototype.flat을 사용하는 것이다.
const allPlayers = Object.values(rosters).flat();
// OK, type is BasketballPlayer[]
TypeScript
복사
◦
flat 메소드는 다차원 배열을 평탄화(flatten)해준다. 타입 시그니처는 T[][] => T[] 같은 형태다. 이 버전이 가장 간결하고 타입 구문도 필요 없다.
•
로대시를 사용해서 동일한 작업을 하는 코드를 구현하면 다음과 같다.
const bestPaid = _(allPlayers)
.groupBy(player => player.team)
.mapValues(players => _.maxBy(players, p => p.salary)!)
.values()
.sortBy(p => -p.salary)
.value() // Type is BasketballPlayer[]
TypeScript
복사
◦
로대시와 언더스코어의 개념인 체인을 사용했기 때문에 더 자연스로운 순서로 일련의 연산을 작성할 수 있었다.
◦
만약 체인을 사용하지 않는다면 다음과 같이 작성해야하며 가독성이 저하될 것이다. 연산자의 등장 순서와 실행 순서가 반대다.
▪
_.c(_.b(_.a(v)))
◦
체인을 사용하면 연산자의 등장 순서와 실행 순서가 동일하게 된다.
▪
_(v).a().b().c().value()
•
_(v)는 값을 래핑하고 .value()는 언래핑한다.
•
내장된 함수형 기법들과 로대시 같은 라이브러리에 타입 정보가 잘 유지되는 것은 우연이 아니다.
◦
함수 호출 시 전달된 매개변수 값을 건드리지 않고 매번 새로운 값을 반환함으로써 새로운 타입으로 안전하게 반환할 수 있다.
◦
넓게 보면 타입스크립트의 많은 부분이 자바스크립트 라이브러리의 동작을 정확히 모델링하기 위해서 개발되었다.
◦
그러므로 라이브러리를 사용할 때 타입 정보가 잘 유지되는 점을 잘 활용해야 타입스크립트의 원래 목적을 달성할 수 있다.
•
요약
◦
타입 흐름을 개선하고 가독성을 높이고 명시적인 타입 구문을 줄이기 위해 내장된 함수형 기법과 로대시 같은 유틸리티 라이브러리를 사용하는 것이 좋다.