객체를 생성할 때는 속성을 하나씩 추가하기보다는 여러 속성을 포함해서 한꺼번에 생성해야 타입 추론에 유리합니다.
interface Point {
x: number;
y: number;
}
const pt: Point = {
x: 3,
y: 4
}
// 객체를 반드시 각각 나눠서 만들어야 한다면 "타입 단언문(as)"을 사용해 타입 체커를 통과화면 됩니다.
const pt = {} as Point;
pt.x = 3;
pt.y = 4;
작은 객체를 조합해서 큰 객체를 만들어야 하는 경우에도 여러 단계를 거치지 않는 것이 좋습니다.
const pt = {x: 3, y: 4};
const id = {name: 'leeJS'};
const namedPoint = {}
Object.assign(namedPoint, pt, id);
namedPoint.name; // {} 형식에 'name' 속성이 없습니다.
// '객체 전개 연산자' ...를 사용하면 됩니다.
const namedPoint = {...pt, ...id};
namedPoint.name; // 정상, 타입이 string
// 객체 전개 연산자를 사용하면 타입 걱정 없이 필드 단위로 객체를 생성할 수도 있습니다.
interface Point {
x: number;
y: number;
}
const pt0 = {};
const pt1 = {...pt0, x: 3};
const pt: Point = {...pt1, y: 4;}; // 정상
// 위에 코드는 객체에 속성을 추가하고 타입스크립트가 새로운 타입을 추론할 수 있게 해 유용합니다.
타입에 안전한 방식으로 조건부 속성을 추가하려면, 속성을 추가하지 않는 null 또는 {}으로 객체 전개를 사용하면 됩니다.
declare let hasMiddle: boolean;
const name = {first: 'js', last: 'lee'};
const president = {
...name,
...(hasMiddle ? {middle: 'S'} : {})
};
// 타입이 선택적 속성을 가진 것으로 추론되는 것을 확인할 수 있습니다.
const president: {
middle?: string;
first: string;
last: string;
}
전개 연산자로 한꺼번에 여러 속성을 추가할 수도 있습니다.
declare let hasDates: boolean;
const name = {first: 'js', last: 'lee'};
const pharaoh = {
...name,
...(hasDates ? {start: -2589, end: -2566} : {})
};
// 타입이 유니온으로 추론됩니다. (start와 end가 항상 함께 정의됩니다.)
const pharaoh: {
start: number;
end: number;
first: string;
last: string;
} | {
first: string;
last: string;
}
선택적 필드 방식으로 표현하려면 헬퍼 함수를 사용하면 됩니다.
function add<T extends object, U extends object>(a: T, b: U | null): T & Partial<U> {
return {...a, ...b};
}
const pharaoh = add(name, hasDates ? {start: -2589, end: -2566} : null);
pharaoh.start; // 정상, 타입이 number | undefined
객체나 배열을 변환해서 새로운 객체나 배열을 생성하고 싶을 때 루프 대신 내장된 함수형 기법 또는 로대시(Lodash) 같은 유틸리티 라이브러리를 사용하는것이 좋습니다.
####⭐️ 요약
📍 속성을 제각각 추가하지 말고 한꺼번에 객체로 만들어야 합니다.
📍 안전한 타입으로 속성을 추가하려면 객체 전개를 사용하면 됩니다.
타입스크립트에서 별칭을 신중하게 사용해야 합니다. 그래야 코드를 잘 이해할 수 있고 오류도 쉽게 찾을 수 있습니다.
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) {
const box = polygon.bbox;
if (polygon.bbox) {
if (pt.x < box.x[0] || pt.x > box.x[1] || pt.y < box.y[0] || pt.y > box.y[1]) {
return false;
}
}
}
// 위에 코드의 box의 경우 객체가 undefined 일 수 있습니다.
// 위에 오류의 경우 별칭을 일관성 있게 사용하면 오류를 방지할 수 있습니다.
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
const box = polygon.bbox;
if (box) {
if (pt.x < box.x[0] || pt.x > box.x[1] || pt.y < box.y[0] || pt.y > box.y[1]) {
return false;
}
}
}
객체 비구조화를 이용하면 보다 간결한 문법으로 일관된 이름을 사용할 수 있습니다.
(배열과 중첩된 구조에서도 사용할 수 있습니다.)
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;
}
}
}
별칭은 타입 체커뿐만 아니라 런타임에도 혼동을 야기할 수 있습니다.
const {bbox} = polygon;
if (!bbox){
calculatePolygonBbox(polygon); // polygon.bbox가 채워집니다.
// 이제 polygon.bbox와 bbox는 다른 값을 참조합니다.
}
타입스크립트의 제어 흐름 분석은 지역 변수에는 꽤 잘 동작합니다. 그러나 객체 속성에서는 주의해야 합니다.
function fn(p: Polygon) {/* ... */}
polygon.bbox // 타입이 BoundingBox | undefined
if (polygon.bbox) {
polygon.bbox // 타입이 BoundingBox
fn(polygon);
polygon.bbox // 타입이 BoundingBox
}
타입스크립트는 함수가 타입 정제를 무효화하지 않는다고 가정합니다. 그러나 실제로는 무효화 될 가능성이 있습니다. polygon.bbox로 사용하는 대신 bbox 지역 변수로 뽑아내서 사용하면 bbox 타입은 정확히 유지되지만, polygon.bbox의 값과 같게 유지되지 않을 수 있습니다.
####⭐️ 요약
📍 별칭은 타입스크립트가 타입을 좁히는 것을 방해합니다. 따라서 변수에 별칭을 사용할 때는 일관되게 사용해야 합니다.
📍 비구조화 문법을 사용해서 일관된 이름을 사용하는 것이 좋습니다.
📍 함수 호출이 객체 속성의 타입 정제를 무효화할 수 있다는 점을 주의해야 합니다. 속성보다 지역 변수를 사용하면 타입 정제를 믿을 수 있습니다.
콜백보다는 프로미스나 async/await 를 사용해야 하는 이유
ex) 병렬로 페이지를 로드하고 싶다면 Promise.all을 사용해서 프로미스를 조합하면 됩니다.
async function fetchPages() {
const [response1, response2,response3] = await Promise.all([
fetch(url1), fetch(url2), fetch(url3)
]);
// ...
}
이런 경우는 await와 구조 분해 할당이 궁합이 잘 맞습니다.
입력된 프로미스들 중 첫 번째가 처리될 때 완료되는 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를 사용해야 합니다.
const getNumber = async () => 42; // 타입이 () => Promise<number>
const getNumber = () => Promise.resolve(42); // 타입이 () => Promise<number>
// 비동기 함수로 통일하도록 강제하는 데 도움이 됩니다.
함수는 항상 동기 또는 항상 비동기로 실행되어야 하며 절대 혼용해서는 안됩니다.
콜백이나 프로미스를 사용하면 실수로 반(half)동기 코드를 작성할 수 있지만, async를 사용하면 항상 비동기 코드를 작성합니다.
async 함수에서 프로미스를 반환하면 또 다른 프로미스로 래핑되지 않습니다.
####⭐️ 요약
📍 콜백보다는 프로미스를 사용하는 게 코드 작성과 타입 추론 면에서 유리합니다.
📍 가능하면 프로미스를 생성하기 보다는 async와 await를 사용하는 것이 좋습니다. 간결하고 직관적인 코드를 작성할 수 있고 모든 종류의 오류를 제거할 수 있습니다.
📍 어떤 함수가 프로미스를 반환한다면 async로 선언하는 것이 좋습니다.
타입스크립트는 타입을 추론할 때 단순히 값만 고려하지 않고 값이 존재하는 곳의 문맥까지 확인합니다.
자바스크립트는 코드의 동작과 실행 순서를 바꾸지 않으면서 표현식을 상수로 분리해 낼 수 있습니다.
type Language = 'JavaScript' | 'TypeScript' | 'Python';
function setLanguage(language: Language) {/* ... */};
setLanguage('JavaScript'); // 정상
let language = 'JavaScript';
setLanguage(language); // 'string' 형식의 인수는 'Language' 형식의 매개변수에 할당될 수 없습니다.
// 값을 변수로 분리해 내면, 타입스크립트는 할당 시점에 타입을 추론합니다.
// 해결 방법은 두가지가 있습니다.
// 1. 타입 선언에서 language의 가능한 값을 제한하는 것입니다.
let language: Language = 'JavaScript'; // 타입 선언의 경우 오타가 있었다면 오류를 표시해 주는 장점도 있습니다.
setLanguage(language); // 정상
// 2. language를 상수로 만드는 것입니다.
const language = 'JavaScript'; // 재할당해야 할 경우 타입 선언이 필요합니다.
setLanguage(language); // 정상
문맥과 값을 분리하면 근본적인 문제를 발생시킬 수 있습니다.
문자열 리터럴 타입과 마찬가지로 튜플 타입에서도 문제가 발생합니다.
// 매개변수는 (latitude,longitude) 쌍입니다.
function panTo(where: [number, number]) {/* ... */}
panTo([10, 20]); // 정상
const loc = [10, 20]; // 얕은(shallow) 상수
panTo(loc); // 'number[]' 형식의 인수는 '[number, number]' 형식의 매개변수에 할당될 수 없습니다.
// 타입스크립트가 locd의 타입을 number[]로 추론합니다.
// 타입 선언을 제공하는 방법을 시도
const loc: [number, number] = [10, 20];
panTo(loc); // 정상
// as const 로 사용할 경우
const loc = [10, 20] as const;
panTo(loc); // 'readonly [10, 20]' 형식은 'readonly'이며 변경 가능한 형식 '[number, number]'에 할당할 수 없습니다.
// 위에 코드일 경우 너무 과하게 정확합니다. where의 내용이 불변이라고 보장해야 합니다.
function panTo(where: readonly [number, number]) {/* ... */}
const loc = [10, 20] as const;
panTo(loc); // 정상
as const는 문맥 손실과 관련한 문제를 깔끔하게 해결할 수 있지만, 한 가지 단점을 가지고 있습니다.
타입 정의에 실수가 있다면 오류는 타입 정의가 아니라 호출되는 곳에서 발생 하다는 것입니다.
(즉, 여러 겹 중첩된 객체에서 오류가 발생한다면 근본적인 원인을 파악하기 어렵습니다.)
문맥에서 값을 분리하는 문제는 문자열 리터럴이나 튜플을 포함하는 큰 객체에서 상수를 뽑아낼 때도 발생합니다.
type Language = 'JavaScript' | 'TypeScript' | 'Python';
interface GovernedLanguage {
laguage: Language;
organization: string;
}
function complain(language: GovernedLanguage) {/* ... */}
complain({ language: 'TypeScript', organization: 'Microsoft' }); // 정상
const ts = {
language: 'TypeScript',
organization: 'Microsoft',
};
complain(ts);
// '{language: string; organization: string;}' 형식의 인수는
// 'GovernedLanguage' 형식의 매개변수에 할당될 수 없습니다.
// 'language' 속성의 형식이 호환되지 않습니다.
// 'string' 형식은 'Language' 형식에 할당할 수 없습니다.
// 해결 방안으로는 타입 선언을 추가하거나 상수 단언(as const)을 사용해 해결합니다.
콜백을 다른 함수로 전달할 때, 타입스크립트는 콜백의 매개변수 타입을 추론하기 위해 문맥을 사용합니다.
function callWithRandomNumbers(fn: (n1: number, n2: number) => void) {
fn(Math.random(), Math.random());
}
calWithRandomNumbers((a, b) => {
a; // 타입이 number
b; // 타입이 number
console.log(a + b);
})
// 콜백을 상수로 뽑아내면 문맥이 소실되고 noImplicitAny 오류가 발생합니다.
const fn = (a, b) => {
console.log(a + b); // 'a', 'b' 매개변수에는 암시적으로 'any' 형식이 포함됩니다.
}
callWithRandomNumbers(fn);
// 타입 구문을 추가해서 해결할 수 있습니다.
const fn = (a: number, b: number) => {
console.log(a + b); // 'a', 'b' 매개변수에는 암시적으로 'any' 형식이 포함됩니다.
}
callWithRandomNumbers(fn);
// 함수 표현식에 타입 선언을 적용해도 해결가능합니다.
####⭐️ 요약
📍 변수를 뽑아서 별도로 선언했을 때 오류가 발생한다면 타입 선언을 추가해야 합니다.
📍 변수가 정말로 상수라면 상수 단언(as const)을 사용해야 합니다. 그러나 상수 단언을 사용하면 정의한 곳이 아니라 사용한 곳에서 오류가 발생하므로 주의해야 합니다.
ex) NBA 팀의 모든 선수 명단
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[]' 형식이 포함됩니다.
}
// 이 오류를 수정하려면 타입 구문을 추가해야 합니다.
let allPlayers: BasketballPlayer[] = [];
for (const players of Object.values(rosters)){
allPlayers = allPlayers.concat(players); // 정상
}
// 더 나은 해법은 Array.prototype.flat 을 사용하는 것입니다.
const allPlayers = Object.values(rosters).flat(); // 정상 타입이 BasketballPlayer[]
ex) allPlaters를 가지고 각 팀별로 연봉 순으로 정렬해서 최고 연봉 선수의 명단을 만들 경우
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 bestPaid = Object.values(teamToPlayers).map(players => players[0]);
bestPaid.sort((playerA, playerB) => playerB.salary - playerA.salary);
console.log(bestPaid);
// 결과
[
{ team: 'GSW', salary: 37457154, name: 'Stephen Curry'},
{ team: 'HOU', salary: 35654150, name: 'Chris Paul'},
{ team: 'LAL', salary: 35654150, name: 'Lebron James'},
{ team: 'OKC', salary: 35654150, name: 'Russell Westbrook'},
{ team: 'DET', salary: 32088932, name: 'Blake Griggin'},
...
]
// 로대시를 사용해서 동일한 코드를 구현할 경우
const bestPaid = _(allPlayers)
.groupBy(player => player.team)
.mapValues(players => _.maxBy(players, p => p.salary)!)
.values()
.sortBy(p => -p.salary)
.value() // 타입이 Basketball Player[]
로대시를 사용할 경우 길이가 절반으로 줄었고, 보기에도 깔끔하며, null 아님 단언문을 딱 한번만 사용했습니다. 또한 로대시와 언더스코어의 개념인 '체인'을 사용했기 때문에, 더 자연스러운 순서로 일련의 연상을 가성할 수 있었습니다.
// 체인을 사용하지 않을 경우
_.c(_.b(_.a(v)))
// 체인을 사용할 경우
_(v).a().b().c().value()
// _(v)는 값을 '래핑(wrap)' 하고, .value()는 '언래핑(unwrap)' 합니다.
로대시의 어떤 기발한 단축 기법이라도 타입스크립트로 정확하게 모델링될 수 있습니다.
내장된 Array.prototype.map 대신 _.map을 사용하려는 한가지 이유는 콜백을 전달하는 대신 속성의 이름을 전달할 수 있기 때문입니다.
const namesA = allPlayers.map(player => player.name) // 타입이 string[]
const namesB = _.map(allPlayers, player => player.name) // 타입이 string[]
const namesC = _.map(allPlayers, 'name') // 타입이 string[]
// 타입 추론 예제
const salaries = _.map(allPlayers, 'salary'); // 타입이 number[]
const teams = _.map(allPlayers, 'team'); // 타입이 string[]
const mix = _.map(allPlayers, Math.random() < 0.5 ? 'name' : 'salary'); // 타입이 (string | number)[]
내장된 함수형 기법들과 로대시 같은 라이브러리에 타입 정보가 잘 유지되는 것은 우연이 아닙니다.
함수 호출 시 전달된 매개변수 값을 건드리지 않고 매번 새로운 값을 반환함으로써, 새로운 타입으로 안전하게 반환할 수 있습니다. 그러므로 라이브러리를 사용할 때 타입 정보가 잘 유지되는 점을 십분 활용해야 타입스크립트의 원래 목적을 달성할 수 있습니다.
####⭐️ 요약
📍 타입 흐름을 개선하고, 가독성을 높이고 명시적 타입 구문의 필요성을 줄이기 위해 직접 구현하기보다는 내장된 함수형 기법과 로대시 같은 유틸리티 라이브러리를 사용하는 것이 좋습니다.