본문 바로가기

HTML ⁄ CSS ⁄ JS

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

이펙티브 타입스크립트 정주행 스터디 3주차

 

19 추론으로 장황한 코드 방지하기

타입스크립트 초심자라면 코드를 작성하면서 가능한 한 모든 곳에 타입 구문을 넣으려고 할 수 있다. 그러나 숙련된 타입스크립트 사용자라면 최대한 타입 추론을 이용하고, 필요할 때만 타입을 명시한다.

만약 타입을 중복해서 선언하면, 변경사항이 생겼을 때 한쪽만 갱신하고 반대 쪽을 갱신하지 않아 문제가 생길 수 있다. 이러한 이유로 모든 변수에 타입 구문을 추가하는 것은 필요하지 않을 뿐더러 더욱 큰 비효율을 가져오게 된다.

eslint의 no-inferrable-types 룰을 이용하면 충분히 추론 가능한 상황인데 굳이 타입을 명시한 경우를 잡아낼 수 있다.

다만 함수의 입력과 출력은, 즉 함수의 시그니처는 명확하게 명시해주는 것이 좋다. 이렇게 해야 IDE 상에서 타입을 확인할 때, 더욱 직관적이다. 또, 함수의 코드가 조금 바뀌더라도 함수의 시그니처를 크게 바뀌지 않을 가능성이 높은데, 만약 함수의 입출력 타입을 지정해놓는다면 마치 '테스트' 처럼 리팩토링 상황에서 명시해둔 타입이 지켜지는지 확인하는 용도로 활용할 수도 있다.

 

20 다른 타입에는 다른 변수 사용하기

약타입 언어인 자바스크립트에서는 타입이 다른 값을 같은 변수에 할당할 수 있다. 그러나 타입스크립트에서는 타입이 다른 값을 다룰 때는 아예 다른 변수를 사용하는 것이 좋다.

string | number 과 같이 유니온으로 타입을 확장하는 방식으로 여러 타입을 하나에 받는다면, 하나의 변수에 다른 타입을 할당할 수는 있다. 그러나 이후 해당 변수를 어떤 함수의 매개변수로 넘기면서 타입이 무엇인지 다시 검사하고 그에에 따라 다른 로직을 작성해주어야 한다. 프로그램의 복잡도가 올라가게 되는 것이다. 

별도의 변수를 사용한다면, 연관성이 낮은 두 값을 분리하여 각 변수의 이름도 더 명확하게 지을 수 있게된다. 덕분에 타입도 간결해질 것이고, 타입 추론도 향상된다. 값이 변경되지 않아 let 대신 const 를 사용할 수 있다면 이는 타입 추론에 더더욱 도움이 된다.

 

21 타입 넓히기

타입스크립트에서는 타입이 명시되지 않았을 경우, 할당된 값을 통해 어떤 값들이 할당 가능한 집합인지, 즉 어떤 타입인지 추론해낸다. 이 과정을 '타입 넓히기(Type Widening)'라고 한다.

let x = 'x'; // 타입은 'x'가 아니라 string
const mixed = ['x', 1]; // 타입은 (string | number)[]

타입 넓히기 과정 중 타입스크립트는 유연하면서도 명확한 타입을 추론하기 위해 노력한다. 객체의 경우, 각 프로퍼티를 let으로 할당한 것처럼 다루게 된다. (위 예시의 배열에서도 let 으로 'x'와 1을 할당한 것과 같다.) 이 과정을 통해 타입스크립트가 추론해낸 타입은 개발자의 의도와 100% 일치한다고 장담할 수 없다.

타입의 의도와 다르게 넓어지는 것을 막기 위해서는, const 사용, as const 사용 등을 시도해 볼 수 있다.

const x = 'x'; // 타입은 'x'

const obj1 = { x: 1, y: 2 }; // 타입은 { x: number, y: number }
const obj2 = { x: 1 as const, y: 2 }; // 타입은 { x: 1, y: number }
const obj3 = { x: 1, y: 2 } as const; // 타입은 { readonly x: 1, readonly y: 2 }

 

 

22 타입 좁히기

타입 넓히기와 반대로, 분기문 등으로 통해 타입을 점점 좁혀가는 '타입 좁히기(Type Narrowing)'도 있다. 이 과정을 타입 정제(refine)라고 표현하기도 한다.

타입 좁히기를 하는 방법에는 instanaceof 연산자, in 연산자, 에러 throw하기, 태그된 유니온(tagged union), 사용자정의 타입가드(user-defiend type guard) 등의 방법으로 다양하게 타입 좁히기를 시도할 수 있다.

사용자정의 타입가드는 리턴값의 타입을 'p는 t이다.' 형태의 문장으로 표현(type predicate)해서 사용한다. 

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

// parameterName is Type 형태
// 단, parameterName는 함수시그니처에 있는 파라미터이어야 함.
 
let pet = getSmallPet();
 
if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

개발자가 타입을 좁혀가는 경우, 타입스크립트는 대부분 기대한 대로 동작한다. 그렇지만 타입을 섣부르게 판단하기 보다는 항상 꼼꼼히 확인하는 것이 바람직하다.

 

23 한꺼번에 객체 생성하기

객체를 생성할 때, 프로퍼티를 하나씩 추가하는 것보다 필요한 프로퍼티 모두를 한번에 객체에 넣어 생성하는 것이 타입추론에 더 좋다. 프로퍼티를 객체 생성 후 추가하면 다음과 같이 타입 에러가 발생한다.

const point = {};

point.x = 3; // 에러, '{}' 형식에 'x' 속성이 없습니다.

만약 어쩔 수 없이 프로퍼티를 나중에 추가해야 한다면 다음과 같이 작성할 수 있긴하지만, 필요한 프로퍼티가 다 들어오지 않을 수 있다는 여지가 생긴다.

interface Point {
  x: number;
}

const point = {} as Point; // 정상

point.x = 3; // 정상

프로퍼티를 optional 로 추가하고 싶다면 다음의 방법을 사용할 수 있다.

declare let hasMiddle: boolean;

const firstLast = { first: 'Haru', last: 'Kim' };
const person = { ...firstLast, ...(hasMiddle ? { middle: 'D' } : {})};

// 타입이 { middle?: string, first: string, last: string };


declare let hasDates: boolean;

const nameTitle = { name: 'exercise', category: 'routine' };
const dance = { ...nameTitle, ...(hasDates ? { start: 18, end: 20 } : {})};

// 타입이 
// { start: number, end: number, name: string, title: string } 
// | {name: string | title: string };

위와 같이 둘 이상의 프로퍼티를 추가하면 동시에 추가될 것으로 가정하여 유니온으로 추가된다. optional 로 추가하고 싶다면 다음의 헬퍼함수를 이용할 수 있다.

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

 

24 일관성 있는 별칭 사용하기

객체의 프로퍼티를 코드 내에서 간결하게 사용하기 위해 별칭(Alias)을 추가하는 경우, 일관되지 않은 이름으로 혼란을 줄 수 있다.

function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const box = polygon.bbox; // bbox, box라는 서로 다른 이름이 쓰이고 있다.

  if (box) {
    // ...
  }
}

 

일관된 이름을 사용하기 위해서는 구조분해 할당(destructuring)을 사용하는 것이 좋다. 단, 여전히 별칭을 사용하고 있기 때문에, polygon.bbox와 지역변수 bbox의 타입은 같더라도 값은 서로 달라질 수 있다는 점을 알아두자.

function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const { bbox } = polygon;

  if (bbox) {
    // ...
  }
}

 

 

 

25 콜백 대신 async 함수 사용하기

비동기 함수를 작성할 때, async/await를 사용하는 것이 타입 추론에 좋다. 콜백보다는 프로미스가, 프로미스보다는 async/await 가 좋다.

ES2015 이전에는 비동기 로직을 작성하다 보면 어쩔 수 없이 코드의 가독성이 떨어지는 문제가 있었다. 이는 콜백지옥(callback hell) 또는 파멸의 피라미드(pyramid of doom)으로 불리기도 했다.

ES2015에 도입된 Promise와 ES2017에 도입된 async/await 덕분에 비동기 코드의 가독성은 크게 개선되었다.

// 병렬적으로 페이지를 로드하고 싶은 경우

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

 // 처음으로 처리된 프로미스가 생겼을 때 완료하고 싶은 경우

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

// 타임아웃 추가하기
function timeout(ms: number): Promise<never> {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject('timeout'), ms);
  });
}
async function fetchWithTimeout(url: string, ms: number) {
  return Promise.race([fetch(url), timeout(ms)];
}

위에서 fetchWithTimeout은 리턴값의 타입을 명시하지 않아도 Promise<Response>로 타입이 잘 추론된다. Promise.race의 반환타입은 입력 타입들의 유니온이다. fetch(url)은 Promise<Response>, timeout(ms)는 Promise<never> 타입을 반환하기 때문에 Promise<Response | never>가 되고, never가 공집합이기 때문에 결국 Promise<Response>가 되는 것이다.

 

26 타입 추론 컨텍스트 이해하기

타입스크립트에서 타입 추론 과정에는 값 뿐만 아니라 값이 존재하는 곳의 컨텍스트 까지 고려된다. 컨텍스트를 고려하다보니 타입 추론의 결과가 기대와 다르기도 하기 때문에, 타입스크립트 사용자는 이에 대해서 잘 알고 있어야 한다.

인라인(inline) 형태로 작성하는 것과 변수를 선언해서 참조 형태로 작성하는 경우 타입 추론이 다르게 동작한다. 

type Language = 'JS' | 'TS';

function setLanguage(language: Language) {
  // ...
}

// 인라인형태
setLanguage('JS'); // 정상
 
// 참조형태
let language = 'JS';
setLanguage(language); // 에러, 'string' 형식의 인수는 'Language'형식의 매개변수에 할당할 수 없습니다.

// 해결방법
// let language: Language = 'JS';
// const language = 'JS';

참조형태로 작성하면 컨텍스트가 소실되어 타입에러가 발생한다. 이 문제는 변수 선언시 타입을 지정해주거나, let 대신 const 를 사용해서 해결할 수 있다.

 

튜플을 사용하는 경우에도 비슷한 문제가 발생할 수 있다. 튜플의 경우 const 를 사용하더라도 타입에러가 다음과 같이 발생할 수 있다.

function panTo(where: [number, number]) { /* ... */ }
 
// 인라인 형태
panTo([10, 20]); // 정상

// 참조형태
const location = [10, 20];
panTo(location); // 에러, number[] 형식은 [number, number] 형식에 할당할 수 없습니다.

// 해결방법
// function panTo(where: readonly [number, number]) { /* ... */ }
// const location = [10, 20] as const;
// 
// panTo(location);

그 값이 내부까지 deep하게 상수임을 타입스크립트에게 전달하기 위해 as const 를 사용할 수 있다. 그리고 이 상수를 받을 수 있도록 매개변수에도 readonly 를 추가해주면 문제가 해결된다. 단, as const를 사용하면 depth가 깊은 객체에서 선언 단계에서 발생한 에러를 찾기 어려워지는 문제가 생긴다.

 

27 타입 흐름 유지하기

만약 어떤 유틸함수를 직접 구현한다면, 타입을 직접 명시해야 하고 이후 타입 체크에 대한 관리도 직접 해야 한다. 반면, 로대시(lodash)와 같은 유틸 라이브러리를 사용하면, 타입 정보를 유지하면서 타입 흐름(type flow)을 계속 전달할 수 있다. 따라서 라이브러리에서 제공하는 타입 정보를 십분 활용하는 것이 좋다.