Typescript 3장(23 ~ 27)

이종서·2022년 11월 26일
1

TypeScript

목록 보기
4/9

Item 23. 한꺼번에 객체 생성하기

객체를 생성할 때는 속성을 하나씩 추가하기보다는 여러 속성을 포함해서 한꺼번에 생성해야 타입 추론에 유리합니다.

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) 같은 유틸리티 라이브러리를 사용하는것이 좋습니다.

####⭐️ 요약
📍 속성을 제각각 추가하지 말고 한꺼번에 객체로 만들어야 합니다.
📍 안전한 타입으로 속성을 추가하려면 객체 전개를 사용하면 됩니다.

Item 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, 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의 값과 같게 유지되지 않을 수 있습니다.

####⭐️ 요약
📍 별칭은 타입스크립트가 타입을 좁히는 것을 방해합니다. 따라서 변수에 별칭을 사용할 때는 일관되게 사용해야 합니다.
📍 비구조화 문법을 사용해서 일관된 이름을 사용하는 것이 좋습니다.
📍 함수 호출이 객체 속성의 타입 정제를 무효화할 수 있다는 점을 주의해야 합니다. 속성보다 지역 변수를 사용하면 타입 정제를 믿을 수 있습니다.

Item 25. 비동기 코드에는 콜백 대신 async 함수 사용하기

콜백보다는 프로미스나 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를 사용해야 합니다.

  • 일반적으로 더 간결하고 직관적인 코드가 됩니다.
  • async 함수는 항상 프로미스를 반환하도록 강제됩니다.
const getNumber = async () => 42; // 타입이 () => Promise<number>

const getNumber = () => Promise.resolve(42); // 타입이 () => Promise<number>

// 비동기 함수로 통일하도록 강제하는 데 도움이 됩니다.

함수는 항상 동기 또는 항상 비동기로 실행되어야 하며 절대 혼용해서는 안됩니다.

콜백이나 프로미스를 사용하면 실수로 반(half)동기 코드를 작성할 수 있지만, async를 사용하면 항상 비동기 코드를 작성합니다.

async 함수에서 프로미스를 반환하면 또 다른 프로미스로 래핑되지 않습니다.

####⭐️ 요약
📍 콜백보다는 프로미스를 사용하는 게 코드 작성과 타입 추론 면에서 유리합니다.
📍 가능하면 프로미스를 생성하기 보다는 async와 await를 사용하는 것이 좋습니다. 간결하고 직관적인 코드를 작성할 수 있고 모든 종류의 오류를 제거할 수 있습니다.
📍 어떤 함수가 프로미스를 반환한다면 async로 선언하는 것이 좋습니다.

Item 26. 타입 추론에 문맥이 어떠헥 사용되는지 이해하기

타입스크립트는 타입을 추론할 때 단순히 값만 고려하지 않고 값이 존재하는 곳의 문맥까지 확인합니다.

자바스크립트는 코드의 동작과 실행 순서를 바꾸지 않으면서 표현식을 상수로 분리해 낼 수 있습니다.

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)을 사용해야 합니다. 그러나 상수 단언을 사용하면 정의한 곳이 아니라 사용한 곳에서 오류가 발생하므로 주의해야 합니다.

Item 27. 함수형 기법과 라이브러리로 타입 흐름 유지하

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)[]

내장된 함수형 기법들과 로대시 같은 라이브러리에 타입 정보가 잘 유지되는 것은 우연이 아닙니다.
함수 호출 시 전달된 매개변수 값을 건드리지 않고 매번 새로운 값을 반환함으로써, 새로운 타입으로 안전하게 반환할 수 있습니다. 그러므로 라이브러리를 사용할 때 타입 정보가 잘 유지되는 점을 십분 활용해야 타입스크립트의 원래 목적을 달성할 수 있습니다.

####⭐️ 요약
📍 타입 흐름을 개선하고, 가독성을 높이고 명시적 타입 구문의 필요성을 줄이기 위해 직접 구현하기보다는 내장된 함수형 기법과 로대시 같은 유틸리티 라이브러리를 사용하는 것이 좋습니다.

profile
프론트엔드 개발자

0개의 댓글