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

이펙티브 타입스크립트 북스터디 3장 아이템 19-27

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

타입 추론이란?

타입스크립트에서 타입 구문을 명시하지 않는다면, 일반적으로 변수의 타입 혹은 함수가 반환하는 타입은 처음 등장할 때 결정되는데, 이것을 타입 추론이라고 합니다.

  • 변수의 타입을 명시하지 않고 값을 할당할 경우

    let x = 12; // number 타입으로 추론
    
    const person = {
      name: 'Sojourner Truth',
      born: {
        where: 'Swartekill, NY',
        when: 'c.1797',
      }
    }; // { name: string; born: { where: string; when: string; }; } 타입으로 추론
  • 함수의 반환 타입을 명시하지 않았을 경우

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

이처럼 타입 추론은 타입 구문을 생략하여 장황한 코드를 작성하지 않게 함으로써 코드를 읽는 사람이 구현 로직에 집중할 수 있게 해줍니다.

그러면 언제 타입 추론을 활용하는 것이 좋고, 언제 타입을 명시하는 것이 좋은지 알아봅시다.

타입 추론을 활용하는 경우

  • 원시값을 할당

    let str = 'string';
    const bool = true;
    const num = 12;
  • 함수 내에서 생성된 지역 변수

    interface Product {
      id: string;
      name: string;
      price: number;
    }
    function logProduct(product: Product) {
      const { id, name, price } = product; // 비구조화 할당문을 사용
    	/* ... */
    }
  • 함수에서 기본값이 있는 매개변수

    function parseNumber(str: string, base = 10) {
    	/* ... */
    }
  • 타입 정보가 있는 라이브러리에서 콜백 함수의 매개변수

    namespace express {
      export interface Request {}
      export interface Response {
        send(text: string): void;
      }
    }
    interface App {
      get(path: string, cb: (request: express.Request, response: express.Response) => void): void;
    }
    const app: App = null!;
    
    // Don't do this:
    app.get('/health', (request: express.Request, response: express.Response) => {
      response.send('OK');
    });
    
    // Do this:
    app.get('/health', (request, response) => {
      response.send('OK');
    });

타입을 명시하는 경우

  • 객체 리터럴 정의

    객체 리터럴을 정의할 때, 타입을 명시하면 타입스크립트는 잉여 속성 체크를 하기 때문에 객체가 사용되는 곳에서 오류가 발생하는 것이 아니라 객체를 선언한 곳에 오류가 발생합니다.

    interface Product {
      id: string;
      name: string;
      price: number;
    }
    
    // 타입을 명시하지 않은 경우
    const furby = {
    	id: 630509430963,
    	name: 'Furby',
    	price: 35,
    };
    logProduct(furby);
            // ~~~~~ Argument .. is not assignable to parameter of type 'Product'
            //         Types of property 'id' are incompatible
            //         Type 'number' is not assignable to type 'string'
    
    
    // 타입을 명시한 경우
    const typedFurby: Product = {
    	id: 630509430963, // ~~ Type 'number' is not assignable to type 'string'
    	name: 'Furby',
    	price: 35,
    };
    logProduct(typedFurby);
  • 함수의 반환 타입

    함수의 반환 타입을 명시하면 함수에 대해 더욱 명확하게 알 수 있고, 명명된 타입을 사용할 수 있기 때문에 타입을 명시하는 것이 좋습니다.

    interface Vector2D {
      x: number;
      y: number;
    }
    function add(a: Vector2D, b: Vector2D): Vector2D {
      return { x: a.x + b.x, y: a.y + b.y };
    }

아이템 20- 다른 타입에는 다른 변수 사용하기

변수에 기본형 타입을 할당하는 방법

타입스크립트에서는 변수에 값을 재할당할 때, 변수를 초기화할 때 지정한 타입과 다른 타입의 값을 할당하면 오류가 발생합니다.

function fetchProduct(id: string) { /* ... */ }
function fetchProductBySerialNumber(id: number) { /* ... */ }

let id = "12-34-56";
fetchProduct(id);

id = 123456; // '123456' is not assignable to type 'string'.
fetchProductBySerialNumber(id);
                        // ~~ Argument of type 'string' is not assignable to
                        //    parameter of type 'number'

위의 오류를 해결하기 위해 id 의 타입을 string | number 와 같은 유니온 타입으로 변경할 수 있지만, 이러한 타입은 간단한 string 이나 number 타입에 비해 다루기가 더 어렵습니다.

이럴 때는 별도의 변수를 도입하는 것이 더 바람직합니다.

function fetchProduct(id: string) { /* ... */ }
function fetchProductBySerialNumber(id: number) { /* ... */ }

const id = "12-34-56";
fetchProduct(id);

const serial = 123456; // OK
fetchProductBySerialNumber(serial); // OK

이렇게 별도의 변수를 도입하면 다음과 같은 장점을 가질 수 있습니다.

  • 서로 관려이 없는 두 개의 값을 분리합니다. ( idserial )
  • 변수명을 더 구체적으로 지을 수 있습니다.
  • 타입 추론을 향상시키며, 타입 구문이 불필요해집니다.
  • 타입이 좀 더 간결해집니다. ( string | number 대신 stringnumber 사용 )
  • let 대신 const 로 변수를 선언하게 되어 코드가 간결해지고, 타입 체커가 타입을 추론하기에도 좋습니다.

따라서 타입이 다른 값을 다룰 때는 변수를 재사용하지 않고 별도의 변수명을 사용하는 것이 좋습니다.

아이템 21 - 타입 넓히기

변수가 초기화 될 때, 타입이 추론되는 과정

타입스크립트에서는 타입을 추론할 때, 지정된 단일 값을 가지고 할당 가능한 값들의 집합을 유추합니다. 이러한 과정을 **넓히기(widening)**라고 부릅니다.

넓히기 과정에서는 일반적으로 변수가 선언된 후로는 타입이 바뀌지 않는 것을 가정하고 명확성과 유연성 사이의 균형을 유지해서 타입을 추론하려고 합니다.

기본형 값의 넓히기 과정 예시

let x = 'x';

변수 x 에는 타입을 명시하지 않았으므로 타입스크립트는 타입 추론을 하게 되는데, 할당 가능한 값들의 집합에는 다음과 같은 후보군이 존재합니다.

  • any
  • string
  • 'x'

일반적으로 타입을 추론할 때, 변수가 선언된 후로는 타입이 바뀌지 않는 것을 가정하기 때문에 위 후보군 중에서 any 타입은 다른 타입도 할당할 수 있어 후보군에서 제외됩니다. 또한 변수 xlet 으로 선언되었기 때문에, 'x'라는 string literal이 아닌 다른 string 으로 재할당될 수 있을 것이라 생각하여 타입스크립트는 최종적으로 변수 x의 타입을 string 으로 추론하게 됩니다.

const x = 'x';

위의 예시와 달리 변수 xconst 로 선언되었기 때문에, 다른 string 으로 재할당이 불가능하여 타입스크립트는 최종적으로 변수 x 의 타입을 string literal인 'x' 로 추론하게 됩니다.

객체의 넓히기 과정 예시

const v = { x: 1 };

변수 v 에 할당 가능한 값들의 집합에는 다음과 같은 후보군이 존재합니다.

  • { readonly x: 1 }
  • { x: number }
  • { [key: string]: number }

객체의 경우 넓히기 알고리즘은 각 요소를 let 으로 할당된 것처럼 다룹니다. 따라서 변수 v 의 타입은 { x: number } 가 됩니다.

타입 추론 강도 제어하기

지금까지 타입스크립트에게 지정된 값만을 가지고 타입을 추론하게 했는데, 다음과 같은 방법으로 타입 추론의 강도를 제어할 수 있습니다.

  • 명시적 타입 구문 제공

    const v: {x: 1|3|5} = {
      x: 1,
    };  // Type is { x: 1 | 3 | 5; }
  • 추가적인 문맥을 제공 (아이템 26에서 다룸)

  • const 단언문 사용

    // 값 뒤에 as const를 작성하면, 타입스크립트는 최대한 좁은 타입으로 추론합니다.
    const v1 = {
      x: 1,
      y: 2,
    };  // Type is { x: number; y: number; }
    
    const v2 = {
      x: 1 as const,
      y: 2,
    };  // Type is { x: 1; y: number; }
    
    const v3 = {
      x: 1,
      y: 2,
    } as const;  // Type is { readonly x: 1; readonly y: 2; }

아이템 22 - 타입 좁히기

여러가지 타입이 가능할 때, 문맥에 따라 특정 타입으로 추론하게 하는 방법

타입 좁히기는 타입스크립트가 넓은 타입으로부터 좁은 타입으로 추론하는 과정을 말하는데, 다음과 같은 방법으로 타입을 좁힐 수 있습니다.

  • 조건문

    const el = document.getElementById('foo'); // Type is HTMLElement | null
    if (el) {
      el // Type is HTMLElement
    } else {
      el // Type is null
    }
  • 예외를 던지거나 함수를 반환(early return)

    // 예외를 던지는 방법
    const el = document.getElementById('foo'); // Type is HTMLElement | null
    if (!el) throw new Error('Unable to find #foo');
    el; // Now type is HTMLElement
  • instanceof

    function contains(text: string, search: string|RegExp) {
      if (search instanceof RegExp) {
        search  // Type is RegExp
        return !!search.exec(text);
      }
      search  // Type is string
      return text.includes(search);
    }
  • 속성 체크

    interface A { a: number }
    interface B { b: number }
    function pickAB(ab: A | B) {
      if ('a' in ab) {
        ab // Type is A
      } else {
        ab // Type is B
      }
      ab // Type is A | B
    }
  • Array.isArray

    function contains(text: string, terms: string|string[]) {
      const termList = Array.isArray(terms) ? terms : [terms];
      termList // Type is string[]
      // ...
    }
  • 태그된 유니온(tagged union) 또는 구별된 유니온(discriminated union)

    각 객체의 동일한 프로퍼티에 객체마다 다른 값을 지정하여 서로 다른 객체를 구분하는 방법

    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  // Type is DownloadEvent
          break;
        case 'upload':
          e;  // Type is UploadEvent
          break;
      }
    }
  • 타입 식별을 돕기 위한 커스텀 함수

    function isInputElement(el: HTMLElement): el is HTMLInputElement {
      return 'value' in el;
    }
    
    function getElementContent(el: HTMLElement) {
      if (isInputElement(el)) {
        el; // Type is HTMLInputElement
        return el.value;
      }
      el; // Type is HTMLElement
      return el.textContent;
    }

    함수의 반환 타입에 el is HTMLInputElement 라고 지정하면, 함수의 반환되는 값이 true 인 경우 타입 체커에게 매개변수의 타입을 좁힐 수 있다고 알려줄 수 있습니다. 이러한 기법을 사용자 정의 타입 가드라고 합니다.

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

변수에 객체를 할당하는 방법

자바스크립트에서는 객체를 생성할 때 다음과 같이 생성할 수 있습니다.

const pt = {};
pt.x = 3;
pt.y = 4;

하지만 위의 예제를 타입스크립트에서 사용하면 다음과 같은 오류가 발생합니다.

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

따라서 타입스크립트에서는 객체를 생성할 때, 속성을 하나씩 추가하기보다는 여러 속성을 포함해서 한꺼번에 생성하는 것이 좋습니다.

타입스트립트에서 객체 생성 방법

  • 타입 선언 활용 (type 키워드 or 인터페이스)

    interface Point { x: number; y: number; }
    const pt: Point = {
      x: 3,
      y: 4,
    };  // OK
  • 타입 추론 활용

    const pt = {
      x: 3,
      y: 4,
    };
  • 타입 단언문 활용

    interface Point { x: number; y: number; }
    const pt = {} as Point;
    pt.x = 3;
    pt.y = 4;  // OK

타입스크립트에서 여러 객체를 합치는 방법

작은 객체들을 조합해서 큰 객체를 만들어야 하는 경우 **객체 전개 연산자(...)**을 사용하면 됩니다.

interface Point { x: number; y: number; }
const pt = { x: 3, y: 4 };
const id = { name: 'Pythagoras' };
const namedPoint = { ...pt, ...id };
namedPoint.name;  // OK, type is string

// 객체 전개 연산자를 사용하면 필드 단위로 객체를 생성할 수도 있습니다.
const pt0 = {};
const pt1 = { ...pt0, x: 3 };
const pt: Point = { ...pt1, y: 4 };  // OK

객체에 조건부 속성을 추가하려면, 속성을 추가하지 않는 null 또는 {}으로 객체 전개를 사용하면 됩니다.

// 조건부 속성 추가하는 방법
declare let hasMiddle: boolean;
const firstLast = { first: 'Harry', last: 'Truman' };
const president = { ...firstLast, ...(hasMiddle ? { middle: 'S' } : {}) };
// 한꺼번에 여러 속성을 추가하는 경우
declare let hasDates: boolean;
const nameTitle = { name: 'Khufu', title: 'Pharaoh' };
const pharaoh = {
  ...nameTitle,
  ...(hasDates ? { start: -2589, end: -2566 } : {})
};
// pharaoh의 타입은 { start: number; end: number; name: string; title: string; } | { name: string; title: string; } 가 된다.

// 만약 { start?: number; end?: number; name: string; title: string; } 타입으로 만들고 싶다면 헬퍼 함수를 사용하면 된다.
function addOptional<T extends object, U extends object>(
	a: T, b: U | null
): T & Partial<U> {
  return { ...a, ...b };
}
const optionalPharaoh = addOptional(
	namedTitle,
  hasDates ? { start: -2589, end: -2566 } : null
);
optionalPharaoh.start // OK, type is number | undefined

아이템 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) {
  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
}

위 예제에서 isPointInPolygon 함수 내부에서 polygon.bbox 라는 것이 중복되어 사용되고 있어 중복을 제거하는 리팩토링을 해보겠습니다.

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;
    }
  }
  // ...
}

위와 같이 비구조화 할당을 이용하면 보다 간결한 문법으로 일관된 이름을 사용할 수 있습니다.

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

타입스크립트는 타입을 추론할 때 단순히 값만 고려하지는 않습니다. 값이 존재하는 곳의 문맥까지도 살피는데, 이렇게 문맥을 고려해 타입을 추론하면 가끔 이상한 결과가 나옵니다. 이때 타입 추론에 문맥이 어떻게 사용되는지 이해하고 있다면 제대로 대처할 수 있습니다.

문자열 사용 시 주의점

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

setLanguage('JavaScript');  // 이 문맥에서는 string literal로 타입을 추론하기 때문에 OK

// 오류가 발생하는 경우
let language = 'JavaScript'; // 이 문맥에서는 language를 string 타입으로 추론
setLanguage(language);
         // ~~~~~~~~ Argument of type 'string' is not assignable
         //          to parameter of type 'Language'
// 오류 해결책
// 1. let을 그대로 사용하고 타입 명시한다.
let language: Language = 'JavaScript'; // 이 문맥에서는 language를 string literal로 타입을 추론
setLanguage(language); // OK

// 2. const로 변수를 선언한다.
const language = 'JavaScript'; // 이 문맥에서는 language를 string literal로 타입을 추론
setLanguage(language); // OK

튜플 사용 시 주의점

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

panTo([10, 20]);  // 이 문맥에서는 타입을 [number, number]로 추론하기 때문에 OK

// 오류가 발생하는 경우
const loc = [10, 20]; // 이 문맥에서는 loc의 타입을 number[] 타입으로 추론
panTo(loc);
//    ~~~ Argument of type 'number[]' is not assignable to
//        parameter of type '[number, number]'
// 오류 해결책
// 1. 변수에 타입을 명시한다.
const loc: [number, number] = [10, 20];
panTo(loc);  // OK

// 2. const 단언문을 사용하고 함수 매개변수의 타입을 readonly로 변경한다.
function panTo(where: readonly [number, number]) { /* ... */ }
const loc = [10, 20] as const; // 이 문맥에서는 loc의 타입을 readonly [number, number] 타입으로 추론
panTo(loc);  // OK

객체 사용 시 주의점

type Language = 'JavaScript' | 'TypeScript' | 'Python';
interface GovernedLanguage {
  language: Language;
  organization: string;
}

function complain(language: GovernedLanguage) { /* ... */ }

complain({ language: 'TypeScript', organization: 'Microsoft' });  // 이 문맥에서는 language와 organization을 string literal로 타입을 추론하기 때문에 OK

// 오류가 발생하는 경우
const ts = {
  language: 'TypeScript',
  organization: 'Microsoft',
};
complain(ts); // 이 문맥에서는 language와 organization을 string 타입으로 추론
//       ~~ 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'
// 오류 해결책
// 1. 변수에 타입을 명시한다.
const ts: GovernedLanguage = {
  language: 'TypeScript',
  organization: 'Microsoft',
};
complain(ts); // OK

// 2. const 단언문을 사용한다.
const ts = {
  language: 'TypeScript',
  organization: 'Microsoft',
} as const;
complain(ts); // OK

콜백 사용 시 주의점

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

callWithRandomNumbers((a, b) => { // 이 문맥에서 a와 b를 number 타입으로 추론
  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);
// 해결책
// 1. 매개변수에 타입 구문을 추가한다.
const fn = (a: number, b: number) => {
  console.log(a + b);
}
callWithRandomNumbers(fn);

// 2. 함수 시그니처를 사용한다.
type CallWithRandomNumbers = (n1: number, n2: number) => void;
const fn: CallWithRandomNumbers = (a, b) => {
  console.log(a + b);
}
callWithRandomNumbers(fn);

디지엠유닛원 주식회사

  • 대표이사 권혁태
  • 개인정보보호책임자 정경영
  • 사업자등록번호 252-86-01619
  • 주소
    서울특별시 금천구 가산디지털1로 83, 6층 601호(가산동, 파트너스타워)
  • 이메일 unit1@dgmit.com