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) {
const box = polygon.bbox;
if (polygon.bbox) {
polygon.bbox.x; // 정상
box.x; // Object is possibly 'undefined'.
}
}
polygon.bbox를 별도의 box라는 별칭을 만들면 제어 흐름 분석이 방해돼 오류가 발생한다.
function isPointInpolygon(polygon: Polygon) {
polygon.bbox; // BoundingBox | undefined
const box = polygon.bbox;
box; // BoundingBox | undefined
if (polygon.bbox) {
polygon.bbox.x; // BoundingBox
box.x; // BoundingBox | undefined
}
}
속성 체크는 poly.bbox의 타입을 정제했지만 box는 하지 못했다.
별칭을 일관성 있게 사용하면 해결할 수 있다.
function isPointInpolygon(polygon: Polygon) {
const box = polygon.bbox;
if (box) {
box.x; // 정상
}
}
타입 체커의 문제는 해결되었지만 box와 bbox는 같은 값인데 아른 이름을 사용한 게 된다.
객체 비구조화를 이용하면 간결한 문법으로 일관된 이름을 사용할 수 있다.
function isPointInpolygon(polygon: Polygon) {
const { bbox } = polygon;
if (bbox) {
const { x } = bbox;
x; // 정상
}
}
bbox의 경우 선택적 속성이 적합하지만 holes는 그렇지 않다.
holes가 선택적이라면 값이 없거나 빈 배열에 차이가 없는데도 구별하는 게 된다.
holes가 없는 경우를 빈 배열로 나타내는 게 좋다.
별칭은 런타임에도 혼동을 야기할 수 있다.
function isPointInpolygon(polygon: Polygon) {
const { bbox } = polygon;
if (!bbox) {
polygon.bbox = {
x: [0, 0],
y: [0, 0],
};
// polygon.bbox와 bbox는 다른 값을 참조하게 된다.
console.log(polygon.bbox); // { x: [ 0, 0 ], y: [ 0, 0 ] }
console.log(bbox); // undefined
}
}
타입스크립트의 제어 흐름 분석은 지역 변수에는 꽤 잘 작동한다. 그러나 객체 속성에서는 주의해야 한다.
function fn(p: Polygon) {
// 만약 polygon.bbox를 제거한다면?
}
function isPointInpolygon(polygon: Polygon) {
polygon.bbox; // BoundingBox | undefined
if (polygon.bbox) {
polygon.bbox; // BoundingBox
fn(polygon);
polygon.bbox; // BoundingBox
}
}
만약 polygon.bbox를 제거한다면 타입을 BoundingBox | undefined 으로 되돌리는 게 안전할 것이다.
그러나 함수를 호출할 때마다 속성 체크를 반복해야 하기 때문에 좋지 않다.
타입스크립트는 함수가 타입 정제를 무효화하지 않는다고 가정하지만 실제로는 무효화될 수 있다.
25. 비동기 코드에는 콜백 대신 async 함수 사용하기
과거의 자바스크립트에서는 비동기 동작을 모델링하기 위해 콜백을 사용했다.
콜백 지옥을 극복하기 위해 프로미스 개념이 나왔다.
es6에서는 async/await 키워드로 콜백 지옥을 더 간단하게 처리할 수 있게 되었다.
// callback
fetchURL(url1, function (res1) {
fetchURL(url2, function (res2) {
fetchURL(url3, function (res3) {
//
});
});
});
// promise
const fetchPromise = fetch(url1);
fetchPromise
.then((res1) => {
return fetch(url2);
})
.then((res2) => {
return fetch(url3);
})
.then((res3) => {
//
})
.catch((error) => {});
// async/await
async function fetchPages() {
try {
const res1 = await fetch(url1);
const res2 = await fetch(url2);
const res3 = await fetch(url3);
} catch (e) {}
}
es5 이전 버전을 대상으로 할 때, 타입스크립트 컴파일러는 async/await가 동작하도록 정교한 변환을 수행한다.
즉, 타입스크립트는 런타임에 관계없이 async/await를 사용할 수 있다.
콜백보다는 프로미스를 사용하는 게 코드 작성과 타입 추론 면에서 유리하다.
에를 들어, Promise.all을 사용할 때도 await와 구조 분해 할당을 쓰면 좋다.
async function fetchPages() {
const [res1, res2, res3] = await Promise.all([
fetch(url1),
fetch(url2),
fetch(url3),
]);
}
타입스크립트는 세 response 각각의 타입을 Response로 추론한다.
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<Response>로 추론된다.
가능하면 프로미스를 생성하기 보다는 async/await를 사용하는 게 좋다.
간결하고 직관적이며 모든 종류의 오류를 제거할 수 있다. 또한 async 함수는 항상 프로미스를 반환하도록 강제된다.
const getNumber = async () => 42; // () => Promise<number>
const getNumber = () => Promise.resolve(42); // () => Promise<number>
const _cache: { [url: string]: string } = {};
let requestStatus: "loading" | "success" | "error";
// 이렇게 하지 말기
function fetchWithCache(url: string, callback: (text: string) => void) {
if (url in _cache) {
callback(_cache[url]);
} else {
fetchURL(url, (text) => {
_cache[url] = text;
callback(text);
});
}
}
function getUser(userId: string) {
fetchWithCache("", (profile) => {
requestStatus = "success";
});
requestStatus = "loading";
}
// 이렇게 하기
async function fetchWithCache(url: string) {
if (url in _cache) {
return _cache[url];
}
const response = await fetch(url);
const text = await response.text();
_cache[url] = text;
return text;
}
async function getUser(userId: string) {
requestStatus = "loading";
const profile = await fetchWithCache("");
requestStatus = "success";
}
async를 적용하면 일관된 동작을 강제하게 된다.
콜백이나 프로미스를 사용하면 실수로 반동기 코드가 될 수도 있지만, async를 사용하면 항상 비동기 코드가 된다.
async 함수에서 프로미스를 반환한다고 또 다른 프로미스로 래핑되지도 않는다.
반환 타입은 Promise<Promise<T>>가 아닌 Promise<T>가 된다.
26. 타입 추론에 문맥이 어떻게 사용되는지 이해하기
타입스크립트는 타입을 추론할 때 값이 존재하는 곳의 컨텍스트까지 살핀다.
이때 값이 사용되는 문맥으로부터 값을 분리해내면 문맥의 소실로 인해 오류가 발생할 수 있다.
1. 문자열 리터럴 타입 예시
type Language = "javascript" | "typescript";
function setLanguage(language: Language) {}
// 인라인 형태
setLanguage("javascript"); // 정상
// 참조 형태
let language = "javascript";
setLanguage(language); // 'string' 형식의 인수는 'Language' 형식의 매개변수에 할당될 수 없습니다.
참조 형태에서, 타입스크립트는 할당 시점에 string으로 타입을 추론했다.
let language: Language = "javascript";
const language = "javascript";
명시적 타입 선언을 해주거나 language를 불변의 상수로 만들어서 해결할 수 있다.
2. 튜플 타입 예시
function panTo(where: [number, number]) {}
panTo([10, 20]); // 정상
const loc = [10, 20];
panTo(loc); // 'number[]' 형식의 인수는 '[number, number]' 형식의 매개변수에 할당될 수 없습니다.
const loc: [number, number] = [10, 20];
const loc = [10, 20] as const;
타입 선언을 제공해주거나 as const로 상수 문맥을 제공해서 해결할 수 있다.
const는 단지 값이 가리키는 참조가 변하지 않는 얕은 상수인 반면,
as const는 그 값이 내부까지 상수라는 사실을 타입스크립트에게 알려 준다.
그러나 이번에는 readonly[10, 20]으로 추론된다. 반면 panTo의 타입 시크니처는 where의 내용이 불변이라고 보장하지 않는다.
따라서 최선의 해결책은 다음과 같다.
function panTo(where: readonly [number, number]) {}
const loc = [10, 20] as const;
panTo(loc);
as const는 문맥 손실과 관련한 문제를 깔끔하게 해결할 수 있지만,
만약 타입 정의에 실수가 있다면 오류는 타입 정의가 아닌 호출되는 곳에서 발생한다. (근본적 원인을 파악하기 어려워진다.)
3. 객체 사용 예시
문맥에서 값을 분리하는 문제는 큰 객체에서 상수를 뽑아낼 때도 발생한다.
type Language = "javascript" | "typescript";
interface GovernedLanguage {
language: Language;
organization: string;
}
function complain(language: GovernedLanguage) {}
complain({ language: "typescript", organization: "Microsoft" }); // 정상
const ts = { language: "typescript", organization: "Microsoft" };
complain(ts);
// 'string' 형식은 'Language' 형식에 할당할 수 없습니다.
ts 객체에서 language의 타입이 string으로 추론된다.
타입 선언을 추가하거나(const ts: GovernedLanguage = ...) 상수 단언(as const)을 사용해서 해결할 수 있다.
4. 콜백 사용 예시
콜백을 다른 함수로 전달할 때, 타입스크립트는 콜백의 매개변수 타입을 추론하기 위해 컨텍스트를 사용한다.
function callWithRandomNumbers(fn: (n1: number, n2: number) => void) {
fn(Math.random(), Math.random());
}
callWithRandomNumbers((a, b) => {
a; // number
b; // number
});
const fn = (a, b) => {
// 'a', 'b' 매개변수에는 암시적으로 'any' 형식이 포함됩니다.
};
callWithRandomNumbers(fn);
콜백을 상수로 뽑아내면 문맥이 소실되고 noImplicitAny 오류가 발생한다.
이때는 매개변수에 타입 구문을 추가하거나 전체 함수 표현식에 대한 타입 선언을 제공해서 해결할 수 있다.
const fn = (a: number, b: number) => {};
callWithRandomNumbers(fn);
27. 함수형 기법과 라이브러리로 타입 흐름 유지하기
함수현 프로그래밍의 일부 기능(map, flatMap, filter, reduce 등)은 순수 자바스크립트로 구현되어 있다.
타입 정보가 그대로 유지되면서 타입 흐름이 계속 전달되도록 할 수 있다.
루프를 직접 구현하면 타입 체크 관리도 직접 해야 한다.
타입스크립트를 쓰는 경우, 타입 정보를 참고하며 작업할 수 있기 때문에 서드파티 라이브러리를 사용하는 것이 무조건 유리하다.
const csvData = "...";
const rawRows = csvData.split("\n");
const headers = rawRows[0].split(",");
// 절차형
const rows1 = rawRows.slice(1).map((rowStr) => {
const row = {};
rowStr.split(",").forEach((val, j) => {
row[headers[j]] = val;
// '{}' 형식에서 'string' 형식의 매개변수가 포함된 인덱스 시그니처를 찾을 수 없습니다.
});
return row;
});
// 함수형
const rows2 = rawRows.slice(1).map(
(rowStr) =>
rowStr
.split(",")
.reduce((row, val, i) => ((row[headers[i]] = val), row), {})
// '{}' 형식에서 'string' 형식의 매개변수가 포함된 인덱스 시그니처를 찾을 수 없습니다.
);
// 서드파티 라이브러리
import _ from "lodash";
const rows3 = rawRows
.slice(1)
.map((rowStr) => _.zipObject(headers, rowStr.split(",")));
// 타입이 _.Dictionary<string>[]
로대시 버전은 타입 체커를 통과하는 반면,
다른 두 방법은 {}의 타입으로 {[column: string]: string} 또는 Record<string, string>을 제공해야 오류가 해결된다.
Dictionary는 로대시의 타입 별칭이다. 중요한 점은 타입 구문 없어도 rows의 타입이 정확하다는 것이다.
데이터의 가공이 정교해질수록 이러한 장점은 더욱 분명해진다.
interface BasketballPlayer {
name: string;
team: string;
salary: number;
}
declare const rosters: { [team: string]: BasketballPlayer[] };
루프를 통해 단순(flat) 목록을 만들려면 배열에 concat을 사용해야 한다.
let allPlayers = [];
// 'allPlayers' 변수는 형식을 확인할 수 없는 경우
// 일부 위치에서 암시적으로 'any[]' 형식입니다.
for (const players of Object.values(rosters)) {
allPlayers = allPlayers.concat(players);
// 'allPlayers' 변수에는 암시적으로 'any[]' 형식이 포함됩니다.
}
오류를 고치려면 allPlayers에 타입 구문을 추가해야 한다.
let allPlayers: BasketballPlayer[] = [];
더 나은 해법은 Array.prototype.flat을 사용하는 것이다.
// let 대신 const를 사용하자
const allPlayers = Object.values(rosters).flat(); // 타입이 BasketballPlayer[]
flat 메서드는 다차원 배열을 평탄화해준다. 타입 시그니처는 T[][] => T[] 같은 형태이다.
allPlayers로 팀별로 연봉 순으로 정렬해서 최고 연봉 선수의 명단을 만들어보자.
// 로대시 없는 방법
const teamToPlayers: { [team: string]: BasketballPlayer[] } = {};
for (const player of allPlayers) {
const { team } = player;
teamToPlayers[team] = teamToPlayers[team] || [];
teamToPlayers[team].push(player);
}
for (const players of Object.values(teamToPlayers)) {
players.sort((a, b) => b.salary - a.salary);
}
const bestPaid1 = Object.values(teamToPlayers).map((players) => players[0]);
bestPaid1.sort((playerA, playerB) => playerB.salary - playerA.salary);
// 로대시 사용
const bestPaid2 = _(allPlayers)
.groupBy((player) => player.team)
.mapValues((players) => _.maxBy(players, (p) => p.salary)!)
.values()
.sortBy((p) => -p.salary)
.value(); // 타입이 BasketballPlayer[]
길이도 절반이고, 깔끔하고, null 아님 단언문도 딱 한 번 사용했다.
(타입 체커는 _.maxBy로 전달된 players 배열이 비어있지 않은지 알 수 없다)
로대시와 언어스코어의 개념인 체인을 사용했기 때문에 더 자연스러운 순서로 일련의 연산을 작성할 수 있었다.
// 체인 사용 X
_.c(_.b(_.a(v)));
// 체인 사용
_(v).a().b().c().value();
체인을 사용하면 연산자의 등장 순서와 실행 순서가 동일하다.
_(v)는 값을 래핑하고, .value()는 언래핑한다.
로대시의 단축 기법은 타입스크립트로 정확하게 모델링된다.
Array.prototype.map 대신 _.map을 사용하는 이유는, 콜백을 전달하는 대신 속성의 이름을 전달할 수 있기 때문이다.
// 모두 같은 결과(타입은 string[])
const namesA = allPlayers.map((player) => player.name);
const namesB = _.map(allPlayers, (player) => player.name);
const namesC = _.map(allPlayers, "name");
타입 흐름을 개선하고, 가독성을 높이고, 명시적인 타입 구문의 필요성을 줄이기 위해
데이터의 가공을 직접 구현하기보다는 내장된 함수형 기법과 로대시 같은 유틸리티 라이브러리를 사용하는 것이 좋다.