아이템 22 ~ 27
아이템 22. 타입 좁히기
타입을 좁히는 예시 코드
if else 사용해서 타입을 좁히는 경우
const el = document.getElementById("foo"); // 타입이 HTMLElement | null
if (el) {
el; // 타입이 HTMLElement
el.innerHTML = "Hello";
} else {
el; // 타입이 null
alert("No element");
}
분기문에서 예외 처리하거나 함수 반환해서 타입을 좁히는 경우
const el = document.createElement("foo");
if (!el) throw new Error("No element");
el.innerHTML = "Hello";
instanceof를 사용해서 타입을 좁히는 경우
const contains = (text: string, search: string | RegExp) => {
if (search instanceof RegExp) {
search; // 타입이 RegExp
return !!search.exec(text);
}
search; // 타입이 string
return text.includes(search);
};
속성 체크로 타입을 좁히는 경우
interface A {
a: number;
}
interface B {
b: number;
}
const pickAB = (ab: A | B) => {
// 속성 존재하는지 체크하는 조건 추가
if ("a" in ab) {
ab; // 타입이 A
} else {
ab; // 타입이 B
}
ab; // 타입이 A | B
};
Array.isArray 내장함수로 타입을 좁히는 경우
const contains = (text: string, terms: string | string[]) => {
const termsList = Array.isArray(terms) ? terms : [terms];
termsList; // 타입이 string[]
};
명시적 '태그'를 붙여서 타입을 좁히는 경우
- 태그된 유니온 (구별된 유니온) 타입이라고 불림
interface UploadEvent {
type: "upload";
fileName: string;
contents: string;
}
interface DownloadEvent {
type: "download";
fileName: string;
}
type AppEvent = UploadEvent | DownloadEvent;
const handleEvent = (event: AppEvent) => {
switch (event.type) {
case "download":
event; // 타입이 DownloadEvent
break;
case "upload":
event; // 타입이 UploadEvent
break;
}
};
타입스크립트가 타입을 식별하지 못하는 경우 커스텀 함수 도입하기
- 식별을 돕기 위한 사용자 정의 타입 가드 사용하기
// 반환 타입(el is HTMLInputElement)은 함수의 반환 값이 true인 경우, 매개변수의 타입을 좁힐 수 있다고 알려주는 것
const isInputElement = (el: HTMLElement): el is HTMLInputElement => {
return "value" in el;
};
const getElementContents = (el: HTMLElement) => {
if (isInputElement(el)) {
el; // 타입이 HTMLInputElement
return el.value;
}
el; // 타입이 HTMLElement
return el.textContent;
};
// 타입 가드 사용해서 객체의 타입 좁히기
// 배열에서 요소 탐색할 때 undefined가 되는 경우 사용
const jackson5 = ["Jackie", "Tito", "Jermain", "Marion", "Michael"];
const members1 = ["Janet", "Michael"].map((name) =>
jackson5.find((n) => n === name)
); // 타입이 (string | undefined)[]
// string[]로 좁히기 위해 타입 가드 사용하기
const isDefined = <T>(x: T | undefined): x is T => {
return x !== undefined;
};
const members2 = ["Janet", "Michael"]
.map((name) => jackson5.find((n) => n === name))
.filter(isDefined); // 타입이 string[]
타입 좁힐 때 자주하는 실수
null 타입이 "object"인 걸 놓친 케이스
const el = document.getElementById("foo");
// bad case
if (typeof el === "object") {
// null 타입이 "object"라서 타입 좁히기 실패!
el; // 타입이 여전히 HTMLElement | null
}
// good case
if (el) {
el; // 타입이 HTMLElement
}
if (el instanceof HTMLElement) {
el; // 타입이 HTMLElement
}
기본형이 잘못된 케이스
- falsy값 체크 조건을 추가했지만 빈 문자열, 0, null, undefined 모두 falsy값이라 타입 좁히기 실패했다.
const foo = (x?: number | string | null) => {
// bad case
if (!x) {
x; // 타입이 string | number | null | undefined
}
// good case
if (x === null || x === undefined) {
x; // 타입이 null | undefined
} else if (typeof x === "number") {
x; // 타입이 number
} else {
x; // 타입이 string
}
};
요약
타입스크립트가 타입을 좁히는 과정 이해하기.
타입 좁힐 때 태그된/구별된 유니온과 사용자 저의 타입 가드 사용하기.
아이템 23. 한꺼번에 객체 생성하기
객체 생성할 때는 속성 하나씩 추가하기 보다 여러 속성을 포함해서 한꺼번에 생성해야 타입 추론에 유리하다
변수의 값은 변경될 수 있지만, 타입스크립트의 타입은 변경되지 않는 룰을 따른다.
const pt = {
x: 3,
y: 4,
};
개별 속성 추가 관련 에러 발생 예시 코드
자바스크립트 코드를 타입스크립트 코드를 마이그레이션할 때 흔하게 발생하는 문제!
자바스크립트에서는 빈 객체를 선언하고 속성을 하나씩 추가하는게 가능하지만, 타입스크립트는 할당 시점을 기준으로 타입이 추론되기 때문에 타입 관련 에러 발생한다.
// 존재하지 않는 속성 추가할 때 에러 발생 예시 1
const pt = {};
pt.x = 3;
// 인터페이스 정의하면 아래와 같이 오류 바뀜
interface Point {
x: number;
y: number;
}
// 정의된 Point 타입과 다른 타입이 할당되어서 에러 발생 예시 2
const pt: Point = {};
객체 속성을 나눠서 추가해야 하는 경우
as const(타입 단언) 사용해서 타입 체커 통과하기
- 이 방법보다 가능하다면 선언할 때 객체를 한꺼번에 만드는 게 더 좋은 방법이다.
interface Point {
x: number;
y: number;
}
const pt = {} as Point;
pt.x = 3;
객체 조합해서 또 다른 객체 만드는 경우 객체 전개 연산자 사용하기
- 타입 걱정 없이 필드 단위로 객체를 생성할 수 있다.
const pt = { x: 3, y: 5 };
const id = { name: "mallang" };
// bad case : Object.assign 사용
const namedPoint1 = {};
Object.assign(namedPoint, pt, id);
namedPoint1.name; // namedPoint 타입이 {} 라서 에러 발생
// good case : 전개 연산자 사용
const namedPoint2 = { ...pt, ...id };
namedPoint2.name; // 타입이 string
- 모든 업데이트마다 새 변수를 사용하여 새로운 타입을 얻는게 매우 중요하다.
const pt0 = {};
const pt1 = { ...pt0, x: 3 };
const pt2 = { ...pt1, y: 5 };
타입 안전하게 조건부 속성 추가하는 방법
- 속성 추가하지 않는 null이나 {}으로 객체 전개 사용하기
declare let hasMiddle: boolean;
const firstLast = { first: "Harry", last: "Alice" };
const president = {
...firstLast,
...(hasMiddle ? { middle: "M", middleNameLength: 10 } : {}),
};
요약
속성 제각각 추가하지 말고 한꺼번에 객체로 만들기
객체 전개 연산자를 사용해서 안전한 타입으로 속성 추가하기
객체에 조건부로 속성 추가하는 방법 익히기
아이템 24. 일관성 있는 별칭 사용하기
객채를 별칭 만들어서 사용할 때 주의할 점
- 별칭 값 변경시 원래 속성값도 변경된다.
const borough = { name: "Brooklyn", location: [40.688, -73.979] };
// loc 별칭 생성
const loc = borough.location;
loc[0] = 1000;
console.log(borough.location); // [ 1000, -73.979 ]
- 별칭 남발시 제어 흐름 분석하기가 어렵다.
코드 예시
interface Coordinate {
x: number;
y: number;
}
interface BoundingBox {
x: [number, number];
y: [number, number];
}
interface Polygon {
exterior: Coordinate[];
holes: Coordinate[][];
bbox?: BoundingBox;
}
const isPointInPolygon1 = (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;
}
}
};
// polygon.bbox 중복 개선하기 위해서 변수로 분리하기
// 구조 분해 할당 사용해서 일관된 네이밍 사용하기
const isPointInPolygon2 = (polygon: Polygon, pt: Coordinate) => {
const { bbox } = polygon; // 타입이 BoundingBox | undefined
if (bbox) {
if (
pt.x < bbox.x[0] ||
pt.x > bbox.x[1] ||
pt.y < bbox.y[0] ||
pt.y > bbox.y[1]
) {
return false;
}
}
};
객체 구조 분해 할당시 주의할 점
옵셔널 속성을 구조분해할 때는 속성 체크가 필요하다. 타입 경게에 null값 추가하는 게 좋다 (아이템 31에서 다룰 내용)
지역 변수로 분리시 타입은 정확하게 유지되지만 할당 이후에 원본값이 변경될 수 있으므로 원본값이 구조분해 할당한 값과 다를 수 있다. (할당 이후에 해당 속성이 삭제된 경우)
// fn이 구조 분해 할당 이후에 속성을 삭제하는 경우
const fn = (p: Polygon) => {};
const isPointInPolygon3 = (polygon: Polygon, pt: Coordinate) => {
const { bbox } = polygon; // 타입이 BoundingBox | undefined
if (bbox) {
bbox; // 타입이 BoundingBox
fn(polygon);
bbox; // 타입이 BoundingBox
}
};
요약
별칭은 타입스크립트가 타입을 좁히는 것을 방해하기 때문에 사용할 때는 비구조화 문법 사용해서 일관되게 사용하기
함수 호출이 객체 속성의 타입 정제를 무효화할 수 있다는 점에 주의하기.
아이템 25. 비동기 코드에는 콜백대신 async 함수 사용하기
promise 등장 전 콜백 지옥
fetchUrl(url1, function (response1) {
fetchUrl(url2, function (response2) {
fetchUrl(url3, function (response3) {
console.log(1);
});
console.log(2);
});
console.log(3);
});
console.log(4);
// 4
// 3
// 2
// 1
코드 순서와 실행 순서가 반대
중첩되어서 코드 직관적이지 않아 이해하기 어렵다
요청들이 병렬로 실행하거나 오류 상황 빠져나오기 어렵다.
promise 등장
미래에 가능해질 어떤 것을 나타낸다.
코드 순서와 실행 순서가 동일하다.
const page1Promise = fetch(url1);
page1Promise
.then((reponse1) => {
return fetch(url2);
})
.then((reponse2) => {
return fetch(url3);
})
.then((reponse3) => {})
.catch((err) => {});
async와 await의 등장
- await 키워드는 각각의 프로미스가 처리(resolve)될 때까지 fetchPages 함수의 실행을 멈춘다. async 함수 내에서 await 중인 프로미스가 거절(reject)되면 예외를 던진다. (try/catch 구문 사용)
const fetchPages = async () => {
try {
const response1 = await fetch(url1);
const response2 = await fetch(url2);
const response3 = await fetch(url3);
} catch (err) {}
};
프로미스나 asyn/await를 사용해야 하는 이유
콜백보다 프로미스가 코드를 작성하기 쉽다.
콜백보다 프로미스가 타입을 추론하기 쉽다.
promiseAll 코드 예시
// async await 버전
const fetchPages = async () => {
const [response1, response2, response3] = await Promise.all([
fetch(url1),
fetch(url2),
fetch(url3),
]);
};
// 콜백 버전
const fetchPagesCallBack = () => {
let numDone = 0;
const responses: string[] = [];
const done = () => {
const [response1, response2, response3] = responses;
};
const urls = [url1, url2, url3];
urls.forEach((url, i) => {
fetchURL(url, (r) => {
responses[i] = url;
numDone++;
// 모든 response가 모여지면 done 함수 실행
if (numDone === urls.length) done();
});
});
};
Promise.race를 사용하여 프로미스에 타임아웃을 추가하는 패턴 예시
- Promize.race : Promise 중에서 가장 먼저 완료된 것의 결과값으로 이행함.
const timeout = (millis: number): Promise<never> => {
return new Promise((resolve, reject) => {
setTimeout(() => reject("timeout"), millis);
});
};
const fetchWithTimeout = (url: string, millis: number) => {
return Promise.race([fetch(url), timeout(millis)]);
};
- fetchWithTimeout은 반환 타입이 Promise 로 추론된다.
추론된 이유
- Promise.race의 반환 타입은 입력 타입들의 유니온이라서 Promize<Response | never>가 되는데, 공집합인 never와의 유니온은 아무런 효과가 없어서 간단하게 Promise 로 추론됨
프로미스 생성하기 보다 async/await 사용해야 하는 이유
코드가 더 간결하다.
async함수는 항상 프로미스를 반환하도록 강제된다.
// 프로미스 생성시
const getNumber = () => Promise.resolve(42);
// async 사용시
const getNumber = async () => 42;
// 함수 시그니처 타입이 const getNumber: () => Promise<number>
- async/await는 비동기함수로 통일하도록 강제하기 때문에 항상 동기 / 비동기로 분리되어서 실행된다. 혼용해서 호출되지 않는다.
// fetchURL 함수에 캐시를 추가하는 로직 (동기, 비동기 로직이 섞이는 경우)
const _cache: { [url: string]: string } = {};
const fetchWithCache = (url: string, callback: (text: string) => void) => {
if (url in _cache) {
callback(_cache[url]);
} else {
fetchUrl(url, (text) => {
_cache[url] = text;
callback(text);
});
}
};
- 위의 코드의 문제점 : 캐시가 있는 경우 콜백 함수가 동기로 호출되기 떄문에 사용하기가 무척 어려워진다.
let requestStatus: "loading" | "success" | "error";
const getUser = (userId: string) => {
fetchWithCache(`/user/${userId}`, (profile) => {
requestStatus = "success";
});
requestStatus = "loading";
};
getUser 호출한 후에 requestStatus 값은 캐시 여부에 따라 달라진다. 캐시 되어있다면, success로 변경된 후 바로 loading으로 돌아가버린다.
이 문제는 async를 사용해서 두 함수에게 일관적인 동작을 강제할 수 있다. async 사용하면 항상 비동기 코드를 작성하는 것과 같다.
onst fetchWithCache = async (url: string) => {
if (url in _cache) {
return _cache[url];
}
const response = await fetch(url);
const text = await response.text();
_cache[url] = text;
return text;
};
let requestStatus: "loading" | "success" | "error";
const getUser = async (userId: string) => {
requestStatus = "loading";
const profile = await fetchWithCache(`/user/${userId}`);
requestStatus = "success";
};
- async 함수에 프로미스를 반환하면 또 다른 프로미스로 래핑되지 않기 떄문에 Promise<Promise>가 아니라 Promise를 반환한다.
const getJSON = async (url: string) => {
const response = await fetch(url);
const jsonPromise = response.json();
return jsonPromise;
};
// const getJSON: (url: string) => Promise<any>
요약
코드 작성과 타입 추론 면에서 프로미스 사용하는 게 유리하다.
프로미스 생성하기 보다는 async/await 사용하기.
어떤 함수가 프로미스를 반환한다면 async로 선언하는 것이 좋다.
아이템 26. 타입 추론에 문맥이 어떻게 사용되는지 이해하기
- 타입스크립트는 타입을 추론할 때 값이 존재하는 문맥까지 살핀다.
type Language = "JavaScript" | "TypeScript" | "Python";
const setLanguage = (language: Language) {}
setLanguage("JavaScript")
let language = "JavaScript"
setLanguage(language2) // language2 타입이 string이라서 에러 발생
- 타입스크립트가 변수 할당 시점에 타입을 추론하고, let을 사용하고 있어서 변경될 값으로 추론하기때문에 더 넓은 타입인 string으로 추론함
개선하는 방법
타입 선언 추가해서 가능한 값 제한하기
let을 const로 변경해서 상수로 만들기 : 타입 체커에게 변경할 수 없다고 알려주기
let language2: Language = "JavaScript";
const language2 = "JavaScript";
튜플 사용 시 주의점
- 문맥과 값을 분리한 케이스
const panTo = (where: [number, number]) => {};
panTo([10, 20]);
const loc = [10, 20]; // 길이를 알 수 없는 숫자 배열이라서 number[]
panTo(loc); // number[] 타입이라서 에러 발생
개선해보자
- 타입 선언 제공
const loc: [number, number] = [10, 20];
- 상수 문맥 제공
const는 단지 값을 가리키는 참조가 변하지 않는 얉은 상수
as const는 그 값이 내부까지 (deeply) 상수라는 의미
상수로 처리하기 떄문에 readonly 타입이 된다.
매개변수에 readonly 구문 추가
- as const 사용
const panTo = (where: readonly [number, number]) => {};
panTo([10, 20]);
const loc = [10, 20] as const; // 타입이 readonly [10, 20]
panTo(loc); // number[] 타입이라서 에러 발생
as const 단점
- 타입 정의에 실수가 있으면 정의가 아니라 호출되는 곳에서 에러가 발생한다. 여러 겹 중첩된 객체에서 오류 발생시 원인 파악하기 어렵다.
객체에서 상수 분리시 주의점
ts
변수처럼 문맥에서 값 분리하면language
속성의 타입이 string으로 추론된다.
type Language = "JavaScript" | "TypeScript" | "Python";
interface GovernedLanguage {
language: Language;
organization: string;
}
const complain = (language: GovernedLanguage) => {};
complain({ language: "TypeScript", organization: "Microsoft" });
const ts = {
language: "TypeScript",
organization: "Microsoft",
};
complain(ts);
콜백 사용 시 주의점
- 타입스크립트는 콜백을 다른 함수로 전달할 때, 콜백의 매개변수 타입을 추론하기 위해서 문맥을 사용한다.
const callWithRandomNumbers = (fn: (n1: number, n2: number) => void) => {
fn(Math.random(), Math.random());
};
callWithRandomNumbers((a, b) => {
a; // 타입이 number
b; // 타입이 number
console.log(a + b);
});
// 콜백을 상수로 분리시 문맥이 소실되고 noImplicitAny 오류가 발생하게 된다.
const fn = (a, b) => {
console.log(a + b);
};
// 개선 방법 : 타입 구문 추가 or 타입 선언
const fn2 = (a: number, b: number) => {
console.log(a + b);
};
type Fn = (a: number, b: number) => void;
const fn3: Fn = (a, b) => {
console.log(a + b);
};
요약
변수로 분리해서 선언했을 때 오류가 발생하면 타입 선언 추가하기.
변수가 정말로 상수라면 상수 단언 (as const) 사용하기.
상수 단언 사용하면 정의한 곳이 아니라 사용한 곳에서 오류 발생하므로 주의하기
아이템 27. 함수형 기법과 라이브러리로 타입 흐름 유지하기
- 라이브러리를 타입스크립트와 조합하여 사용하면, 타입 정보 유지되면서 타입 흐름이 전달된다.
const csvData = "";
const rawRows = csvData.split("\n");
const headers = rawRows[0].split(",");
const rows = rawRows
.slice(1)
.map((rowStr) =>
rowStr
.split(",")
.reduce((row, val, i) => ((row[headers[i]] = val), row), {})
);
// loadash zipObject 함수 사용
const rows2 = rawRows
.slice(1)
.map((rowStr) => _.zipObject(headers, rowStr.split(",")));
라이브러리 사용시 장점
- 타입 구문을 별도로 추가하지 않아도 된다.
서드파티 라이브러리 종속성 추가할 때 고려할 부분
서드파티 라이브러리 기반으로 코드 짧게 줄이는데 시간이 많이 든다면, 사용하지 않는게 낫다.
함수 호출시 전달된 매개변수 값을 건드리지 않고 매번 새로운 값을 반환함으로써, 새로운 타입을 ㅗ안전하게 반환할 수 있다.
코드 예시
- 루프 사용해 단순 목록 만들기
interface BasketballPlayer {
name: string;
team: string;
salary: number;
}
declare const rosters: { [team: string]: BasketballPlayer[] };
// concat 타입 에러 개선하기 위해서 타입 구문 추가 필요
let allPlayers: BasketballPlayer[] = [];
for (const players of Object.values(rosters)) {
allPlayers = allPlayers.concat(players); // 코드 동작하지만 concat 관련 타입 에러 발생
}
// Argument of type 'BasketballPlayer[]' is not assignable to parameter of type 'ConcatArray<never>'.ts(2769)
// better way - flat메서드 이용
const allPlayers = Object.values(rosters).flat();
flat 메서드는 다차원 배열 평탄화해준다.
변수 변경되지 않도록 let 대신 const 사용할 수 있다.
예시
- 팀 별로 연봉순으로 정렬해서 최고 연봉 선수의 명단 만드는 로직
const teamToPlayers: { [team: string]: BasketPlayer[] } = {};
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 bestPaid = Object.values(teamToPlayers).map((player) => players[0]);
bestPaid.sort((playerA, playerB) => playerB.salary - playerA.salary);
console.log(bestPaid);
// loadsh 사용
const bestPaid = _(allPlayers)
.groupBy((player) => player.team)
.mapValues((players) => _.maxBy(players, (p) => p.salary)!)
.values()
.sortBy((p) => -p.salary)
.value(); // 타입이 BasketballPlayer[]
loadsh 사용 장점
가독성을 높인다.
null 아님 단언문을 딱 한번 사용 (타입 체커는 _.maxBy로 전달된 players 배열이 비어있지 않은지 알수 없다)
체인 사용해서 연산자의 등장 순서와 실행 순서가 동일하다.
요약
- 타입 흐름 개선하고, 가독성을 높이고, 명시적인 타입 구문의 필요성을 줄이기 위해 직접 구현하기보다는 내장된 함수형 기법과 loadash 같은 유틸리티 라이브러리 사용하는 것이 좋다 (!!)