Search
Duplicate
🌡️

3장. 타입추론

개요

산업계에서 프로그래밍 언어는 정적 타입명시적 타입이 전통적으로 같은 의미로 쓰였었다.
그래서 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
복사
만약 Productid의 타입이 변경된다고 가정해보자.
그럼 logProduct 메소드의 변수들의 타입을 강제했기 때문에 오류가 발생할 것이다.
이는 방지하려면 구조 분해 할당을 사용하는 것이 낫다.
function logProduct(product: Product) { const {id, name, price} = product; console.log(id, nmae, price); }
TypeScript
복사
구조 분해 할당은 모든 지역 변수의 타입이 추론되도록 한다.
정보가 부족해 타입스크립트가 타입을 추론하기 어려운 상황도 일부있다. 이때 명시적 타입 구문이 필요하다.
보통 타입이 명시된 라이브러리에서 콜백 함수의 매개변수 타입은 자동으로 추론된다.
다음 예제에서 express HTTP 서버 라이브러리를 사용하면 requestresponse의 타입 선언은 필요하지 않다.
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
복사
두 번째, 타입 체커에 추가적인 문맥을 제공하는 것이다.
ifinstanceof와 같은 문맥을 의미한다.
세 번째, 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
복사
만약 elemnull이라면 분기문의 첫 번째 블록이 실행되지 않는다. 즉 첫 번째 블록에서 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에서 elemnull일 경우, 연산 결과가 “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
복사
다각형의 기하학적 구조는 exteriorholes 속성으로 정의된다. 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
복사
expandABitpolygon.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은 asyncawait 키워드를 도입하여 콜백 지옥을 더 간단하게 만들었다.
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 또는 더 이전 버전을 대상으로 할 때 타입스크립트 컴파일러는 asyncawait이 동작하도록 정교한 변환을 수행한다.
이를 통해 타입스크립트는 런타임과 상관없이 async, await을 사용한 추론을 제공한다.
콜백보다 Promiseasync/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를 생성하기보다는 asyncawait을 사용하는 것이 좋다. 간결하고 직관적인 코드를 작성할 수 있고 모든 종류의 오류를 처리할 수 있다.
어떤 함수가 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()는 언래핑한다.
내장된 함수형 기법들과 로대시 같은 라이브러리에 타입 정보가 잘 유지되는 것은 우연이 아니다.
함수 호출 시 전달된 매개변수 값을 건드리지 않고 매번 새로운 값을 반환함으로써 새로운 타입으로 안전하게 반환할 수 있다.
넓게 보면 타입스크립트의 많은 부분이 자바스크립트 라이브러리의 동작을 정확히 모델링하기 위해서 개발되었다.
그러므로 라이브러리를 사용할 때 타입 정보가 잘 유지되는 점을 잘 활용해야 타입스크립트의 원래 목적을 달성할 수 있다.
요약
타입 흐름을 개선하고 가독성을 높이고 명시적인 타입 구문을 줄이기 위해 내장된 함수형 기법과 로대시 같은 유틸리티 라이브러리를 사용하는 것이 좋다.