이펙티브 타입스크립트 | 3장 타입추론

dev_hee·2022년 8월 19일
0

TypeScript

목록 보기
3/6
post-thumbnail

본 문은 이펙티브 타입스크립트를 읽고 알게된 내용을 정리한 글입니다.


[아이템 19] 추론 가능한 타입을 사용해 장황한 코드 방지하기

타입스크립트의 많은 타입 구문은 사실 불필요하다. 코드의 모든 변수에 타입을 선언하는 것은 비생산적이며 형편없는 스타일이다.

// bad
let x: number = 12;

// good
let x = 12; // number로 이미 타입 추론 됨

타입 스크립트는 입력을 받아 연산하는 함수가 어떤 타입을 반환하는지 정확히 알고있다. 따라서 반환 타입을 작성하지 않아도 되는 경우가 있다.

function square(nums: number[]){
 return nums.map(x => x * x); 
}
const squares = square([1, 2, 3, 4]); // number[]

정보가 부족해서 타입스크립트가 스스로 타입을 판단하기 어려운 상황도 존재한다. 이럴 때 명시적 타입 구문이 필요하다. 함수의 매개변수가 그러하다.

타입 구문이 필요한 경우

  • 함수/ 메서드 시그니처

타입 구문이 필요하지 않은 경우

  • 함수 내의 지역 변수
  • 매개변수 기본값이 있는 경우
  • 타입 정보가 있는 라이브러리의 콜백함수 매개변수 타입 (ex. express)
// request, response의 타입 선언은 필요하지 않다.
app.get('/health', (request, response) => {
  response.send('OK');
});

타입이 추론되지만 타입을 명시하고 싶은 경우

  • 객체 리터럴을 정의할 때
    객체 리터럴에 타입을 명시하면 잉여 속성 체크에 의해서 타입 오타같은 오류를 잡을 수 있다. 그리고 변수가 사용되는 순간이 아니라 할당하는 시점에서 오류를 표시해준다.
    만약 타입 구문을 제거하면 잉여 속성 체크가 동작하지 않고, 객체가 사용되는 곳에서 타입 오류가 발생한다.

  • 함수의 반환에 타입을 명시

    • 타입 추론이 가능할지라도 구현상의 오류가 함수를 호출한 곳까지 영향을 미칠 수 있기 때문에 타입 구문을 명시하는 것이 좋다.
    • 반환 타입을 명시하면 함수에 대해 더욱 명확하게 알 수 있다.
    • 반환값에 명명된 타입을 사용할 수 있다. (number 처럼 두루뭉실한 타입이 아니라 구체적인 타입을 명시할 수 있음)
    • 반환 타입에 대한 주석을 작성할 수 있어서 더욱 자세한 설명이 가능하다.

eslint 규칙중 no-inferrable-types 를 사용해 작성된 모든 타입 구문이 정말 필요한 것인지 확인할 수 있다.


[아이템 20] 다른 타입에는 별도의 변수 사용하기

변수의 값은 바뀔 수 있지만 그 타입은 바뀌지 않아야 한다.
id 타입은 string 인데 number 값을 할당할 수 없다.

let id = "12-34-56"
fetchProduct(id)
id =123456 // 'number' 형식은 'string' 형식에 할당할 수 없습니다.
fetchProductBySerialNumber(id)

차라리 별도의 변수를 도입하여 다음처럼 작성하는 것이 좋다.

const id = "12-34-56"
fetchProduct(id)
const serial =123456 // 정상
fetchProductBySerialNumber(id)

별도의 변수를 사용하는게 바람직한 이유는 다음과 같다.

  • 서로 관련이 없는 두 개의 값을 분리한다.
  • 변수명을 더 구체적으로 지을 수 있다.
  • 타입 추론을 향상시키며, 타입 구문이 불필요해진다.
  • 타입이 더 간결해진다. (string|number 대신 stringnumber 사용)
  • let 대신 const 로 변수를 선언하게 된다.

[아이템 21] 타입 넓히기

타입스크립트는 "타입 넓히기(widening)" 과정을 거쳐서 타입을 추론한다.

타입 넓히기 과정

지정된 단일 값을 가지고 할당 가능한 값들의 집합을 유추하기 위한 방법이다.

const mixed = ['x', 1];

위 코드는 다음과 같은 많은 타입이 될 수 있다.

('x' | 1)[]
['x', 1]
[string, number]
readonly [string, number]
(string|number)[]
readonly (string|number)[]
[any, any]
any[]

타입스크립트는 명확성과 유연성 사이의 균형을 유지하려고 한다.
오류를 잡기 위해서는 구체적으로 타입을 추론해야 하지만, 잘못된 추론을 할 정도로 구체적으로 수행하지는 않는다.
예를 들어, 1은 같은 값으로 초기화 되는 속성을 적당히 number 타입으로 추론한다.

따라서 위의 예제에서는 적절한 균형을 위해 타입스크립트는 (string|number)[] 로 타입을 추론한다.

타입 넓히기 때문에 발생하는 오류

interface Vector3 { x:number; y:number; z:number; }
function getComponent(vector: Vector3, axis: 'x'|'y'|'z') {
 return vector[axis]; 
}

let x = 'x'; // string
let vec = {x: 10, y: 20, z:30};
getComponent(vec, x); // string 형식의 인수는 'x'|'y'|'z' 형식의 배개변수에 할당될 수 없습니다.

위의 예제에서 변수 xstring 으로 추론하기 때문에 타입 오류가 발생한다.

타입 추론 강도를 제어하는 방법

  1. const 키워드
const x = 'x'; // 타입이 'x'
let vec = {x: 10, y: 20, z:30};
getComponent(vec, x); // 정상

x는 재할당 될 수 없으므로 가장 좁은 타입 'x' 로 추론한다.

하지만 객체와 배열의 경우에는 const 키워드를 사용하더라도 참조값을 재할당 할 수 없는 것이지, 프로퍼티와 요소를 수정하는 것은 가능하기 때문에 부수효과를 방지할만큼 만능은 아니다.

만약 아래 처럼 객체를 선언한다면, 각 요소를 let으로 할당된 것처럼 다룬다. 따라서 타입{ x: 1 } 이 아닌, 타입 { x : number } 으로 추론한다. 따라서 v.x 를 다른 숫자로 재할당할 수 있게 되지만 string으로는 안되며, 다른 속성을 추가하지 못한다.

const v = { x: 1 }; // 타입이 { x : number }
v.x = 3; // 정상
v.x = '4'; // 오류: Type 'string' is not assignable to type 'number'.
v.name = '민지'; // 오류: Property 'name' does not exist on type '{ x: number; }'.
  1. 명시적 타입 구문을 제공
const v: { x: 1|3|5; } = {
 x: 1, 
}; // 타입이 { x: 1|3|5; }
  1. 타입 체커에 추가적인 문맥을 제공

예를 들어 함수의 매개변수로 값을 전달하는 경우가 이에 해당한다. (아이템 26에서 다룰 예정)

  1. const 단언문을 사용

변수 선언이 아니라 const 단언이다. const 단언문은 온전히 타입 공간의 기법이다.
예제

const v1 = {
 x: 1,
 y: 2,
}; // 타입은 { x: number; y: number;}

const v2 = {
 x: 1 as const, // const 단언
 y: 2,
}; // 타입은 { x: 1; y: number;}

const v3 = {
 x: 1, 
 y: 2,
} as const; // const 단언
// 타입은 { readyonly x: 1; readyonly y: 2;}

const a1 = [1,2,3]; // 타입은 number[]
const a2 = [1,2,3] as const; // 타입이 readonly [1,2,3]

[아이템 22] 타입 좁히기

타입 넓히기의 반대 개념이다.
타입스크립트가 넓은 타입으로부터 좁은 타입으로 진행하는 과정으로 조건문을 통해서 타입을 좁혀나갈수 있다.

타입 좁히기 예시는 다음과 같다.

null 체크

예제

타입 체커는 일반적으로 이런 조건문에서 타입 좁히기를 잘하지만, 타입 별칭이 존재한다면 그러지 못할 수도 있다. (아이템 24장)

const el = document.getElementById('foo'); // 타입이 HTMLElement | null
if (el) { // 타입 좁히기
  el // 타입이 HTMLElement
  el.innerHTML = 'Party Time'.blink();
} else {
  el // 타입이 null
  alert('No element #foo');
}

예외 던지기

분기문에서 예외를 던지거나 함수를 반환하여 블록의 나머지 부분에서 변수의 타입을 좁힐 수 있다.
예제

const el = document.getElementById('foo'); // 타입이 HTMLElement | null
if (!el) throw new Error('Unable to find #foo');
  
el; // 타입이 HTMLElement
el.innerHTML = 'Party Time'.blink();

instanceof 사용하기

예제

function contains(text:string, search:string|RegExp){
  if (search instanceof RegExp) {
    search; // 타입이 RegExp
    return !!search.exec(text);
  }
  search // 타입이 string
  return text.includes(search);
}

key in object 속성 체크

예시

interface A { a: number };
interface B { b: number };

function pickAB(ab: A | B) {
  if ('a' in ab) ab // 타입이 A
  else ab // 타입이 B
  ab // 타입이 A | B
}

Array.isArray 내장 함수

예시

function contains(text: string, terms: string|string[]){
  const termList = Array.isArray(terms) ? terms : [terms];
  termList // 타입이 string[]
}

태그된 유니온, 구별된 유니온

명시적으로 태그를 붙여서 타입을 좁힐 수 있다.
예시

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':
      e // DownloadEvent
      break; 
    case 'upload':
      e // UploadEvent
      break;
  }
}

사용자 정의 타입 가드

타입스크립트가 타입을 식별하지 못할때 식별을 돕기 위한 커스텀 함수를 도입할 수 있다.
함수가 매개변수에 대한 타입 체킹을 한 후 boolean (el is HTMLInputElement) 를 반환하여 타입을 좁힌다.
el is HTMLInputElement 는 함수의 반환이 true 인 경우, 타입 체커에게 매개변수 타입을 좁힐 수 있다고 알려주는 것이다.

function isInputElement(el: HTMLElement): el is HTMLInputElement {
  return 'value' in el;
}

function getElementContent(el: HTMLElement){
  if (isInputElement(el)) {
    el; // 타입이 HTMLInputElement
    return el.value;
  }
  el; // 타입이 HTMLElement
  return el.textContent;
}

filter 함수로 undefined 를 걸러 내려고 해도 잘 동작하지 않을 수 있다.

const fruits = ['apple', 'lemon', 'cherry', 'watermelon'];
const choices = ['apple', 'cherry'].map(
  choice => fruits.find(fruit => fruit === choice)
); // 타입이 (string | undefined)[]

const choices = ['apple', 'cherry'].map(
  choice => fruits.find(fruit => fruit === choice)
).filter(choice => choice !== undefined); // 타입이 (string | undefined)[]

이럴 때 타입 가드를 사용하면 타입을 좁힐 수 있다.

function isDefined<T>(x: T | undefined): x is T {
 return X !== undefined; 
}

const choices = ['apple', 'cherry'].map(
  choice => fruits.find(fruit => fruit === choice)
).filter(isDefined); // 타입이 string[]

타입 좁히기 실패 예제

  1. 잘못된 null 체크

자바스크립트에서 null 은 object 이기 때문에 null이 제외되지 않는다.

const el = document.getElementById('foo'); // 타입이 HTMLElement | null
if(typeof el === 'object') {
  el; // 타입이 HTMLElement | null
}
  1. 기본형 값이 잘못된 경우

빈 문자열, 0 모두 false이기 때문에 타입이 전혀 좁혀지지 않는다.

function foo(x?: number|string|null){
  if(!x) {
    x; // 타입이 string | number | null | undefined
  }
}

[아이템 23] 한꺼번에 객체 생성하기

변수의 값은 변경될 수 있지만, 타입스크립트의 타입은 일반적으로 변경되지 않는다.
객체를 생성할 때는 속성을 하나씩 추가하기 보단, 여러 속성을 포함해서 한꺼번에 생성해야 타입 추론에 유리하다.

const pt = {}
pt.x = 3
// ~ Property 'x' does not exist on type '{}'
pt.y = 4
// ~ Property 'y' does not exist on type '{}'

pt 타입은 {} 을 기준으로 추론되기 때문에 오류가 발생한다.
이런 문제는 객체를 한번에 정의하면 해결할 수 있다.

interface Point {
  x: number
  y: number
}
const pt = {
  x: 3,
  y: 4,
} // OK

타입 단언문을 사용해 객체를 나눠서 만들기

객체를 반드시 각각 나눠서 만들어야한다면, 타입 단언문(as) 를 사용해 타입 체커를 통과하게 할 수 있다. 하지만 그래도 객체를 한꺼번에 만드는게 더 낫다.

interface Point {
  x: number
  y: number
}
const pt = {} as Point // 타입 단언
pt.x = 3
pt.y = 4 // OK

스프레드 연산자 활용하기

작은 객체들을 조합해서 큰 객체를 만들어야 하는 경우에는 스프레드 연산자 ... 를 사용하여 한꺼번에 만들 수 있다.

const pt = { x: 3, y: 4 }
const id = { name: 'Pythagoras' }

const namedPoint = { ...pt, ...id }
namedPoint.name // OK, type is string

타입에 조건부 속성을 추가하기

예제

스프레드 연산자를 사용하면 선택적 속성을 추가할 수 있다.

declare let hasMiddle: boolean
const firstLast = { first: 'Harry', last: 'Truman' }
const president = { ...firstLast, ...(hasMiddle ? { middle: 'S' } : {}) }

// const president: {
//    middle?: string;
//    first: string;
//    last: string;
// }

하지만 다음과 같이 2개 이상의 프로퍼티를 스프레드 연산자를 사용해 추가한다면 선택적 속성이 아닌 유니온 타입으로 추론된다.
4.0.5 버전 이하에서는 2개 이상의 프로퍼티를 스프레드 연산자를 사용해 추가한다면 유니온 타입으로 추론된다.
4.0.5 버전 이하 예제

declare let hasDate: boolean
const nameTitle = { name: 'Khufu', title: 'Paraoh' }
const pharaoh = {
  ...nameTitle,
  ...(hasDate ? { start: -2589, end: -2566 } : {}),
}
// const pharaoh: {
//     start: number;
//     end: number;
//     name: string;
//     title: string;
// } | {
//     name: string;
//     title: string;
// }

pharaoh.start // Error. Property 'start' does not exist on type '{ name: string; title: string; }'.

하지만 4.1.5 이상에서는 선택적 타입으로 추론한다.
4.1.5 버전 이상 예제

declare let hasDate: boolean
const nameTitle = { name: 'Khufu', title: 'Paraoh' }
const pharaoh = {
  ...nameTitle,
  ...(hasDate ? { start: -2589, end: -2566 } : {}),
}
// const pharaoh: {
//     start?: number | undefined;
//     end?: number | undefined;
//     name: string;
//     title: string;
// }

pharaoh.start // OK, start?: number | undefined

선택적 필드 방식으로 표현하기 위해 다음처럼 헬퍼 함수를 사용해도 된다.

예제

function addOptional<T extends object, U extends object>(a: T, b: U | null): T & Partial<U> {
  return { ...a, ...b }
}

declare let hasMiddle: boolean
const firstLast = { first: 'Harry', last: 'Truman' }
const president = addOptional(firstLast, hasMiddle ? { middle: 'S' } : null)
president.middle // OK, type is string | undefined

객체, 배열을 변환해서 새로운 객체나 배열을 생성하기

객체, 배열을 변환해서 새로운 객체나 배열을 생성하고 싶을 때, 루프를 순회하는 대신 함수형 기법(map, filter)이나 로대시(lodash)같은 유틸리티 라이브러리를 사용하는 것이 한번에 객체 생성하기 관점에서 옳다.


[아이템 24] 일관성 있는 별칭 사용하기

const borough = { name: 'Brooklyn', location: [40.688, -73.979] }
const loc = borough.location

borough.location 배열에 loc 이라는 별칭(alias)를 만들었다.
별칭의 값을 변경하면 원래 속성값도 변경된다.

이런 별칭을 남발하면 제어 흐름을 분석하기 어렵다. 별칭은 신중하게 사용해야 코드를 잘 이해할 수 있고, 오류도 쉽게 찾을 수 있다.

다각형을 표현하는 자료구조를 가정해보자.

interface Coordinate {
  x: number
  y: number
}

interface BoundingBox {
  x: [number, number]
  y: [number, number]
}

interface Polygon {
  // 다각형의 기하학적 구조: exterior, holes
  exterior: Coordinate[]
  holes: Coordinate[][]
  bbox?: BoundingBox // 필수가 아닌 최적화 속성
}

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[1] || pt.y > polygon.bbox.y[1]) {
      return false
    }
  }

  // ... more complex check
}

타입 체커에서 별칭의 문제점

위의 코드는 잘 동작하지만 ploygon.bbox 가 반복되므로, 코드 중복을 줄이기 위해 다음과 같이 작성할 수 있다.
하지만 strictNullChecks가 활성화 되어있다면 ploygon.bbox의 별칭인 box가 코드 흐름 분석을 방해 하였기 때문에 오류가 발생한다.

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[1] ||
      pt.y > box.y[1]
    ) {
      //     ~~~                ~~~  Object is possibly 'undefined'
      return false
    }
  }
  // ...
}

이런 오류는 별칭은 일관성 있게 사용한다는 기본 원칙(golden rule) 을 지키면 방지할 수 있다.

function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const box = polygon.bbox
  if (box) { // 일관성 있게 polygon.bbox -> box 별칭을 사용
    if (pt.x < box.x[0] || pt.x > box.x[1] || pt.y < box.y[1] || pt.y > box.y[1]) {
      // OK
      return false
    }
  }
  // ...
}

타입 체커의 문제는 해결 되었지만, 코드를 읽는 사람 입장에서는 boxbbox 는 같은 값인데 다른 이름을 사용해서 가독성이 좋지않다.

객체 디스트럭쳐링 할당(비구조화 할당) 를 사용해 간결한 문법으로 일관된 이름을 사용할 수 있다.

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 < x[0] || pt.y > y[1]) {
      return false
    }
  }
  // ...
}

객체 비구조화 할당의 주의해야할 두 가지가 있다.

  1. 전체 bbox 속성이 아니라 x, y가 선택적 속성일 경우 속성 체크가 더 필요하다. 따라서 타입의 경계에 null 값을 추가하는 것이 좋다. (아이템 31)

  2. bbox 에는 선택적 속성이 적합했지만 holes는 그렇지 않다. holes가 선택적이라면 값이 없거나 빈배열([]) 이었을 것이다. 차이가 없는데 이름을 구별한 것이다. 빈 배열은 "holes 없음"을 나타내는 좋은 방법이다.

런타임때 별칭의 문제점

const { bbox } = polygon
if (!bbox) {
  calculatePolygonBbox(polygon) // polygon.bbox가 채워짐
  // 이제 polygon.bbox 와 bbox 는 다른 값을 참조한다.
}

bbox 가 null 또는 undefined 인 경우 bboxpolygon.bbox 참조값을 바라보는것이 아니다. calculatePolygonBbox 를 통해 polygon.bbox 에 값을 채워넣으면 서로 다른 값을 참조하게 된다.

타입스크립트가 객체 속성 제어 흐름을 분석하기

타입스크립트의 제어 흐름 분석은 지역 변수에선 잘 동작하지만, 객체 속성에서는 주의해야한다.

function fn(p: Polygon) {
  /* ... */
}

polygon.bbox // Type is BoundingBox | undefined
if (polygon.bbox) {
  polygon.bbox // Type is BoundingBox
  fn(polygon) // polygon.bbox을 제거할 가능성이 있다.
  polygon.bbox // Type is still BoundingBox
}

fn(polygon) 호출은 polygon.bbox을 제거할 가능성이 있으므로 타입을 BoundingBox | undefined로 되돌리는 것이 안전할 것이다.
그러나 그러면 함수를 호출할 때 마다 undefined 인지 아닌지 속성 체크를 반복해야해서 번거롭다.

그래서 타입스크립트는 함수가 타입 정제를 무효화하지 않는다고 가정한다. 하지만 실제로는 무효화될 가능성이 있다. (즉, 함수가 타입을 변경시키지 않는다고 가정)

그리고 polygon.bbox로 사용하는 대신 bbox 지역 변수로 뽑아내서 사용하면 bbox의 타입은 정확히 유지되지만 polygon.bbox의 값과 같게 유지되지 않을 수 있다.

예제

const polygon: Polygon = { exterior: [{x: 1, y: 1}], holes: [[{x: 1, y: 2}], [{x: 3, y: 4}]], bbox: { x: [1,2], y: [3, 4] } }

function fn(p: Polygon) {
  p.bbox = undefined;
}

const { bbox } = polygon 
if (bbox) {
  bbox // BoundingBox
  fn(polygon) // polygon.bbox을 제거함

  bbox // BoundingBox
  polygon.bbox // BoundingBox | undefined

  console.log(bbox, polygon.bbox) // { x: [1,2], y: [3, 4] }, undefined
}

[아이템 25] 비동기 코드에는 콜백 대신 async 함수 사용하기

콜백 지옥 callback hell

과거에는 비동기 동작을 모델링 하기 위해 콜백 패턴을 사용했다.
콜백은 코드를 중첩시키고 직관적으로 이해하기 어렵게 만들며, 요청을 병렬로 실행하거나 오류 상황을 빠져나오기 곤란하게 한다.

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

프로미스

ES2015에서 콜백 지옥을 극복하기 위해 프로미스 (promise) 개념을 도입했다. 프로미스는 미래에 가능해질 어떤 것을 나타낸다. 프로미스는 다음과 같은 장점이 있다.

  • 프로미스는 코드의 중첩을 줄인다.
  • 실행 순서도 코드 순서와 일치한다.
  • 오류를 처리하기 쉬워진다.
  • Promise.all 과 같은 고급 기법을 사용할 수 있다.
const page1Promise = fetch(url1)
page1Promise
  .then(response1 => {
    return fetch(url2)
  })
  .then(response2 => {
    return fetch(url3)
  })
  .then(response3 => {
    // ...
  })
  .catch(error => {
    // ...
  })

async/await

ES2017에서 async/await 키워드를 도입해 콜백 지옥을 더욱 간단하게 처리할 수 있게 되었다.

async function fetchPages() {
  const response1 = await fetch(url1)
  const response2 = await fetch(url2)
  const response3 = await fetch(url3)
  // ...
}

await 키워드는 각각의 프로미스가 처리(resolve)될 때까지 fetchPages 함수의 실행을 멈춘다.
async 함수 내에서 await중인 프로미스가 거절(reject)되면 예외를 던진다. 이는 try/catch 구문으로 에러를 캐치할 수 있다.

async function fetchPages() {
  try {
    const response1 = await fetch(url1)
    const response2 = await fetch(url2)
    const response3 = await fetch(url3)
    // ...
  } catch (e) {
    // ...
  }
}

프로미스나 async/await 을 사용해야 하는 이유

  • 콜백보다는 프로미스가 코드를 작성하기 쉽다.
  • 콜백보다는 프로미스가 타입을 추론하기 쉽다.

예를 들어, 병렬로 페이지를 로드하고 싶다면 Promise.all 을 사용해서 프로미스를 조합하면 된다.

async function fetchPages() {
  const [response1, response2, response3] = await Promise.all([fetch(url1), fetch(url2), fetch(url3)])
  // ...
}

[아이템 26] 타입 추론에 문맥이 어떻게 사용되는지 이해하기

타입스크립트는 타입을 추론할 때 단순히 값만 고려하지 않고 문맥을 살핀다.
하지만 문맥을 고려해 타입을 추론하면 가끔 이상한 결과가 나올 수 있다.
타입 추론에서 문맥이 어떻게 반영되는지 이해하면 대처할 수 있다.

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

function setLanguage(language: string) {
  /* ... */
}

// 1. 인라인 형태
setLanguage('JavaScript') // OK

// 2. 참조 형태
let language = 'JavaScript'
setLanguage(language) // OK

이제 문자열 타입을 더 특정해서 문자열 리터럴 타입의 유니온으로 바꿔보자.

예제

type Language = 'JavaScript' | 'TypeScript' | 'Python'
function setLanguage(language: Language) {
  /* ... */
}

setLanguage('JavaScript') // OK

let language = 'JavaScript'
setLanguage(language) // Error. Argument of type 'string' is not assignable to parameter of type 'Language'.

인라인형태에서 타입스크립트는 함수 선언을 통해 매개변수가 Language 타입이어야 한다는 것을 알고 있다.
문자열 리터럴 'JavaScript' 는 할당 가능하므로 정상이다.
하지만 이 값을 변수로 분리해내면, 타입스크립트는 할당 시점에 타입을 추론한다. 이 경우는 string 으로 추론했기 때문에 Language 타입으로 할당이 불가능하여 오류가 발생한다.

해결하는 방법은 두 가지가 있다.

  1. language 의 타입을 선언해 값을 제한하는 것
let language: Language = 'JavaScript'
setLanguage(language) // OK
  1. language 를 상수로 만드는 것
const language = 'JavaScript'
setLanguage(language) // OK

const 를 사용해 타입 체커에게 language 는 더 이상 변경할 수 없다는 것을 알려준다. 그러면 타입스크립트는 더 정확한 타입인 문자열 리터럴 'JavaScript' 로 추론할 수 있다.

하지만 이 과정에서 문맥에서 값을 분리하였다. 문맥과 값을 분리하면 추후에 근본적인 문제를 발생시킬 수 있다. 문맥 소실로 인해 발생하는 오류와 해결하는 방법을 살펴보자.

1. 튜플

문자열 리터럴 타입과 마찬가지로 튜플 타입에서도 문제가 발생한다.

이동이 가능한 지도를 보여주는 프로그램을 작성한다고 생각해보자.

예제

type Language = 'JavaScript' | 'TypeScript' | 'Python'
function setLanguage(language: Language) {
  /* ... */
}
// Parameter is a (latitude, longitude) pair.
function panTo(where: [number, number]) {
  /* ... */
}

panTo([10, 20]) // OK

const loc = [10, 20] // number[]
panTo(loc) // Argument of type 'number[]' is not assignable to parameter of type '[number, number]'.

해결법 0

const loc: [number, number] = [10, 20]
panTo(loc) // OK

해결법 1

as const를 사용해 상수 문맥을 제공하자. as const는 그 값이 내부까지 상수라는 사실을 타입스크립트에게 알린다.

하지만 as const는 너무 과하게 정확하다. readonly 타입이라 panTo의 매개변수로 전달할 수 없다.

예제

const loc = [10, 20] as const // readonly [10, 20]
panTo(loc)
// ~~~ Type 'readonly [10, 20]' is 'readonly'
//     and cannot be assigned to the mutable type '[number, number]'

따라서 panTo 함수에 readonly 구문을 추가하면 오류를 고칠 수 있다.

function panTo(where: readonly [number, number]) {
  /* ... */
}

const loc = [10, 20] as const
panTo(loc) // OK

as const는 문맥 손실과 관련한 문제를 해결할 수 있지만, 단점을 가지고 있다.
만약 타입 정의에 실수가 있다면 오류는 타입 정의가 아니라 호출되는 곳에서 발생한다는 것이다.

아래에서 오류는 loc 변수를 선언한 곳에서 발생한것이지만, panTo를 호출하는 곳에서 오류가 발생한다.

function panTo(where: readonly [number, number]) {
  /* ... */
}
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'

여러 겹 중첩된 객체에서 오류가 발생한다면 근본적인 원인을 파악하기 어렵다.

2. 객체

문맥에서 값을 분리하는 문제는 문자열 리터럴이나 튜플을 포함하는 큰 객체에서 상수를 뽑아낼 때도 발생한다.

예제

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'

해결책 0

타입 선언을 추가한다.

const ts: GovernedLanguage = {
  // ... 
}

해결책 1

상수 단언 as const을 사용하여 해결한다.
예제

const ts = {
  language: 'TypeScript',
  organization: 'Microsoft',
} as const

3. 콜백

콜백을 다른 함수로 전달할 때, 타입스크립트는 콜백의 매개변수 타입을 추론하기 위해 문맥을 사용한다.

예제

function callWithRandomNumbers(fn: (n1: number, n2: number) => void) {
  fn(Math.random(), Math.random())
}

callWithRandomNumbers((a, b) => {
  a // Type is number
  b // Type is number
  console.log(a + b)
})

콜백을 상수로 뽑아내면 문맥이 소실되고 noImplicitAny 오류가 발생한다.

예제

function callWithRandomNumbers(fn: (n1: number, n2: number) => void) {
  fn(Math.random(), Math.random())
}

const fn = (a, b) => {
    // Parameter 'a' implicitly has an 'any' type.
    // Parameter 'b' implicitly has an 'any' type.
  console.log(a + b)
}

callWithRandomNumbers(fn)

해결책 0

매개변수에 타입 구문을 추가해서 해결할 수 있다.

const fn = (a: number, b: number) => {
  console.log(a + b)
}
callWithRandomNumbers(fn)

해결책 1

전체 함수 표현식에 타입 선언을 적용한다.

예제

type callbackType =  (n1: number, n2: number) => void

function callWithRandomNumbers(fn: callbackType) {
  fn(Math.random(), Math.random())
}

const fn: callbackType = (a, b) => {
  console.log(a + b)
}

callWithRandomNumbers(fn)

[아이템 27] 함수형 기법과 라이브러리로 타입 흐름 유지하기

파이썬, C, 자바 등에서 볼 수 있는 표준 라이브러리가 자바스크립트에는 포함되어 있지 않아서 수년간 많은 라이브러리들은 표준 라이브러리 역할을 대신하기 위해 노력해왔다. (ex. jQuery, Underscore, Lodash, Ramda) 이런 라이브러리들의 일부 기능(map, filter, reduce 등)은 순수 자바스크립트에서도 구현되어 있다. 이런 기법은 루프를 대체할 수 있기 때문에 자바스크립트에서 유용하게 사용될 수 있으며, 타입스크립트와 조합하여 사용하면 더욱 편리하다. 그 이유는 타입 정보가 그대로 유지되면서 타입 흐름을 계속 전달되도록 하기 때문이다. 반면 직접 루프를 구현하면 타입 체크에 대한 관리도 직접 해야한다.

CSV 데이터를 파싱하는 예를 들어보자.

  1. 자바스크립트 절차형 프로그래밍 형태
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
})
  1. 자바스크립트 함수형 형태
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),
  {}))
  1. Lodash의 zipObject 함수 사용
import _ from 'lodash'

const csvData = '...'
const rawRows = csvData.split('\n')
const headers = rawRows[0].split(',')

const rows = rawRows.slice(1).map(rowStr => _.zipObject(headers, rowStr.split(',')))

이렇게 서드파티 라이브러리 종속성을 추가할 때는 신중해야한다. 만약 서드파티를 사용해 코드를 짧게 줄이는데 시간이 더 많이 들어간다면, 사용하지 않는 것이 더 낫다.

하지만 타입스크립트를 사용하는 경우엔 서드파티 라이브러리르 사용하는 것이 무조건 유리하다. 타입 정보를 참고하며 작업할 수 있기 때문이다.

위 3가지 예제를 타입스로 작성하면 다음과 같은 오류를 발생한다. 예제

const csvData = '...'
const rawRows = csvData.split('\n')
const headers = rawRows[0].split(',')

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 '{}'
    {},
  ),
)

두 버전 모두 {} 타입이므로 {[column: string]: string} 또는 Record<string, string>을 제공하면 오류가 해결된다. 예제

하지만 로대시 버전은 별도의 수정 없이도 타입 체커를 통과한다. 예제 타입 구문 없이도 rows의 타입이 정확하다.

데이터의 가공이 정교해질수록 이런 장점은 더욱 분명해진다.
예를 들어, 모든 NBA팀의 선수 명단을 가지고 있다고 가정해보자.

루프를 사용해 단순 목록을 만들려면 배열에 concat을 사용해야한다. 다음 코드는 동작되지만 타입 체크는 되지 않는다.

interface BasketballPlayer {
  name: string
  team: string
  salary: number
}
declare const rosters: { [team: string]: BasketballPlayer[] }

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
}

이 오류를 고치려면 allPlayers에게 타입 구문을 추가해야한다. 예제

let allPlayers: BasketballPlayer[] = []
for (const players of Object.values(rosters)) {
  allPlayers = allPlayers.concat(players) // OK
}

하지만 더 나은 해결법은 Array.prototype.flat을 사용하는 것이다. 예제

const allPlayers = Object.values(rosters).flat()

참고: You should add es2019 or es2019.array to your --lib setting for TypeScript to recognize array.flat() and flatMap().

allPlayers를 가지고 각 팀별로 연봉 순으로 정렬해서 최고 연봉 선수의 명단을 만든다고 가정해보자.

  1. 로대시 없는 방법

함수형 기법을 쓰지 않는 부분은 타입 구문이 필요하다.

interface BasketballPlayer {
  name: string
  team: string
  salary: number
}

declare const rosters: { [team: string]: BasketballPlayer[] }
const allPlayers = Object.values(rosters).flat()

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)
  1. 로대시를 사용하는 경우

길이가 절반으로 줄었고, 보기에 깔끔하고, null 아님 단언문을 딱 한번만 사용했다.

또한 chain 개념을 사용해서 더 자연수러운 순서로 일련의 연산을 작성할 수 있다.

// requires node modules: @types/lodash
import _ from 'lodash'

interface BasketballPlayer {
  name: string
  team: string
  salary: number
}
declare const rosters: { [team: string]: BasketballPlayer[] }
const allPlayers = Object.values(rosters).flat()

const bestPaid = _(allPlayers)
  .groupBy(player => player.team)
  .mapValues(players => _.maxBy(players, p => p.salary)!)
  .values()
  .sortBy(p => -p.salary)
  .value() // Type is BasketballPlayer[]

체인

체인은 자연스러운 순서로 일련의 연산을 작성할 수 있게 해준다.

// 체인이 아닌 경우
c(b(a(v)))

체인을 사용하지 않으면 뒤에서부터 연산이 수행된다.

// 체인
_(v).a().b().c().value()

체인은 연산자의 등장 순서와 실행 순서가 동일하다.

_(v)는 값을 래핑(wrap)하고, .value()는 언래핑(unwrap)한다. 래핑된 값의 타입을 보기 위해 체인의 각 함수 호출을 조사할 수 있다.

Array.prototype.map 대신 _.map을 사용하려는 이유는 무엇일까?

  1. 콜백을 전달하는 대신, 속성의 이름을 전달할 수 있다.

namesC 에서 처럼 name를 전달하여 순회할 수 있다.

const namesA = allPlayers.map(player => player.name) // Type is string[]
const namesB = _.map(allPlayers, player => player.name) // Type is string[]
const namesC = _.map(allPlayers, 'name') // Type is string[]
profile
🎨그림을 좋아하는 FE 개발자👩🏻‍💻

0개의 댓글