[3장] 타입 추론
이펙티브 타입스크립트 3장을 정리한 내용입니다.
작성일 2024.02.14
페이지가 생성된 시간 2024.10.01 21:01:06

0

3장 타입 추론

숙련된 타입스크립트 개발자는 적은 수의 타입 구문을 사용한다

타입스크립트의 타입 추론을 적극적으로 활용하고, 중요한 부분에만 타입 구문을 사용한다.

3장에서는 타입 추론에서 발생할 수 있는 몇 가지 문제와 해법을 안내한다.

3장을 읽고 난 후 알게 되는 것들

  • 어떻게 타입 추론이 이루어지나
  • 타입 선언은 언제 작성해야하나
  • 타입 추론이 가능하더라도 명시적 타입 선언이 필요한 상황은 언제인가

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

모든 변수에 타입 선언을 하는 것은 비생산적이다.

편집기에서 마우스를 올려보면 추론되는 타입을 알 수 있음.

const person: {
  name: string;
  born: {
    where: string;
    when: string;
  };
  died: {
    where: string;
    when: string;
  };
} = {
  name: 'Sojourner Truth',
  born: {
    where: 'Swartekill, NY',
    when: 'c.1797',
  },
  died: {
    where: 'Battle Creek, MI',
    when: 'Nov. 26, 1883',
  },
};

// 위 처럼 하지말자(거추장스럽다)

const person = {
  name: 'Sojourner Truth',
  born: {
    where: 'Swartekill, NY',
    when: 'c.1797',
  },
  died: {
    where: 'Battle Creek, MI',
    when: 'Nov. 26, 1883',
  },
};

// 이렇게만 해도 타입 추론이 잘 된다.

string으로 예상하기 쉽지만, 사실 const로 선언되었기 때문에 'y'가 더 정확한 타입이죠

const axis1: string = 'x'; // Type is string
const axis2 = 'y'; // Type is "y"

이상적인 타입스크립트 코드는 함수/메서드 시그니처(매개변수, 리턴)에만 타입 구문을 작성하고 함수 내의 지역 변수에는 타입 구문을 넣지 않음

function logProduct(product: Product) {
  const { id, name, price } = product;
  // 비구조화 할당 역시 타입 추론이 되므로 명시적 타입 구문이 불필요
  console.log(id, name, price);
}

불필요한 타입 구문을 생략하고 코드를 읽는 사람이 구현 로직에만 집중할 수 있게 하자.

함수 매개변수에 타입 구문을 생략할 수 있는 경우

매개변수의 기본값을 지정해주는 경우

function parseNumber(str: string, base = 10) {
  // base의 타입이 number로 추론된다
}

타입 정보가 있는 라이브러리를 사용할 때

app.get('/health', (request: express.Request, response: express.Response) => {
  response.send('OK');
});

// 아래와 같이 생략이 가능
app.get('/health', (request, response) => {
  response.send('OK');
});

// 여기서 콜백을 값으로 분리한다면 생략이 불가능하다

명시적 타입 구문이 쓰이는 상황을 알아보자

  • 개발자의 실수나 구현상의 오류를 올바른 위치에 표시해주도록 해보자
  • 함수 작성 시 시그니처를 먼저 작성하고 그에 맞추어 구현을 해보자 (주먹구구식 구현을 방지해준다)
    • async를 통해 프로미스 반환을 강제하는 것과 일맥상통
  • 명명된 타입을 사용하고 싶을 때 (추론된 타입이 복잡할수록 명명된 타입의 이점이 커진다)

객체 리터럴을 정의 시 명시적 타입 구문

  • 객체 리터럴은 잉여 속성 체크가 동작해 오타를 잡는 데 효과적이다.
  • 할당하는 시점에 오류가 나게 된다.
interface Product {
  id: string;
  name: string;
  price: number;
}

const elmo: Product = {
  id: '048188 627152',
  name: 'Tickle Me Elmo',
  priec: 28.99,
  // 위와 같은 오타를 잡는데 효과적
};

함수의 반환 타입을 명시적으로 작성하기

const cache: { [ticker: string]: number } = {};
function getQuote(ticker: string) {
  if (ticker in cache) {
    return cache[ticker]; // 여기서는 number를 반환
  }
  // 아래에서는 Promise<number>를 반환
  return fetch(`https://quotes.example.com/?q=${ticker}`)
    .then((response) => response.json())
    .then((quote) => {
      cache[ticker] = quote;
      return quote;
    });
}

아래와 같이 반환 타입을 명시적으로 작성하면 구현상 오류 부분에 정확하게 오류를 표시해주는 것을 확인할 수 있다.

const cache: { [ticker: string]: number } = {};
function getQuote(ticker: string): Promise<number> {
  if (ticker in cache) {
    return cache[ticker];
    // ~~~~~~~~~~~~~ Type 'number' is not assignable to 'Promise<number>'
  }
  return fetch(`https://quotes.example.com/?q=${ticker}`)
    .then((response) => response.json())
    .then((quote) => {
      cache[ticker] = quote;
      return quote;
    });
}
// async를 이용하는 것도 해결법

아이템20 다른 타입에는 다른 변수를 사용하자

이유는?

  • 서로 관련 없는 변수를 분리한다.
function fetchProduct(id: string) {}
function fetchProductBySerialNumber(id: number) {}
const id = '12-34-56';
fetchProduct(id);

const serial = 123456;
fetchProductBySerialNumber(serial);
  • 변수명을 더 구체적으로 지을 수 있다.(범용 타입을 사용한다면 그에 맞게 변수명이 포괄적이어야 하겠죠?)
  • 타입 추론을 향상시킨다.
  • 타입이 간결해진다.
  • let 대신 const를 사용하게된다. const는 타입 추론이 더 정확하다
let a = 'a'; // 타입이 string
const a = 'a'; // 타입이 'a'

가려지는 변수?

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

{
  const id = 123456; // OK
  fetchProductBySerialNumber(id); // OK
}

두 id는 이름이 같지만 서로 다른 블럭(스코프?)에 있음. 서로 아무런 관련이 없다.

위와 같은 경우(다른 타입, 같은 변수명) 혼란을 줄 수 있다.

따라서 다른 타입, 다른 변수명을 꼭 기억하자.

많은 개발팀이 린터 규칙을 통해 가려지는 변수 사용을 금지하고 있다

아이템21 타입 넓히기

리마인드: 런타임에 모든 변수는 유일한 값이다. 하지만 정적 분석 시점(컴파일 전?)에 변수의 타입은 가능한 값의 집합이다.

타입 넓히기의 정의 = 지정된 단일 값을 가지고 '가능한 값'의 집합을 유추하는 것

interface Vector3 {
  x: number;
  y: number;
  z: number;
}
function getComponent(vector: Vector3, axis: 'x' | 'y' | 'z') {
  return vector[axis];
}
// let의 경우 재할당을 고려해 타입 넓히기가 동작하는 듯 하다
// 지정된 단일 값이 'x'임에도 x의 가능한 값의 집합은 string임을 이해하자
// x의 타입이 'x' | 'y' | 'z' 로 추론됐으면 하는 것은 우리의 마음이다. 타입스크립트는 알 수 없다
let x = 'x';
let vec = { x: 10, y: 20, z: 30 };
getComponent(vec, x);
// ~ Argument of type 'string' is not assignable to
//   parameter of type '"x" | "y" | "z"'

객체의 경우 타입스크립트의 넓히기 알고리즘은 let과 const가 동일하다. (let처럼 동작한다)

타입스크립트의 기본 동작(타입 넓히기, 타입추론?)을 재정의하는 방법

  • 명시적 타입 구문 사용하기
const v: { x: 1 | 3 | 5 } = {
  x: 1,
};
  • 타입 체커에게 추가적인 문맥 제공하기 (아이템26)

    • 보통 문맥과 값을 분리했을 때 오류가 발생한다.
    • 분리한 값에 타입 선언을 추가해 해결할 수 있다.
    • 변수가 정말로 상수라면 상수 단언(as const)를 사용하자. (단, 상수 단언은 정의한 곳이 아닌 사용한 곳에서 오류가 발생하므로 주의)
  • 넓히기로 인한 오류라고 생각된다면 상수 단언(as const)를 고려해보자

아이템22 타입 좁히기

여러가지 방법이 있음

null 체크하기

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

instanceof 사용

// 앞에서 배웠듯 RegExp는 클래스겠죠?
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) {
  ab; // Type is A | B
  if ('a' in ab) {
    ab; // Type is A
  } else {
    ab; // Type is B
  }
}

내장 함수 사용

function contains(text: string, terms: string | string[]) {
  const termList = Array.isArray(terms) ? terms : [terms];
  termList; // Type is 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; // Type is DownloadEvent
      break;
    case 'upload':
      e; // Type is UploadEvent
      break;
  }
}

사용자 정의 타입 가드 사용

function isInputElement(el: HTMLElement): el is HTMLInputElement {
  return 'value' in el; // 반환이 true인 경우에 매개변수의 타입을 좁힐 수 있다.
}

function getElementContent(el: HTMLElement) {
  if (isInputElement(el)) {
    el; // Type is HTMLInputElement
    return el.value;
  }
  el; // Type is HTMLElement
  return el.textContent;
}
const jackson5 = ['Jackie', 'Tito', 'Jermaine', 'Marlon', 'Michael'];

const members = ['Janet', 'Michael']
  .map((who) => jackson5.find((n) => n === who))
  .filter((who) => who !== undefined);

// 아래와 같이 타입 가드를 이용해 좁힐 수 있다.

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

const members = ['Janet', 'Michael']
  .map((who) => jackson5.find((n) => n === who))
  .filter(isDefined);

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

타입스크립트에서 객체의 속성을 하나씩 추가한다면?

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

interface Point {
  x: number;
  y: number;
}
const pt: Point = {};
// ~~ Type '{}' is missing the following properties from type 'Point': x, y
pt.x = 3;
pt.y = 4;

오류를 만난다. 간단히 객체를 한번에 정의하면 해결가능하다. (물론 타입 단언을 사용하면 하나씩 추가 할 수 있지만 좋은 방법은 아니다)

객체들을 조합할 때에도 객체 전개 연산자를 이용해 한번에 정의하자

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

안전한 방식으로 조건부 속성 추가하기

declare let hasMiddle: boolean;
const firstLast = { first: 'Harry', last: 'Truman' };
const president = { ...firstLast, ...(hasMiddle ? { middle: 'S' } : {}) };
// null 또는 {} 를 사용한다

한꺼번에 여러 조건부 속성을 추가 할 때 주의점(?)

declare let hasDate: boolean;
const nameTitle = { name: 'Khufu', title: 'Paraoh' };
const pharaoh = {
  ...nameTitle,
  ...(hasDate ? { start: -2589, end: -2566 } : {}),
};
// 책에서는 pharaoh가 유니온 타입으로 추론된다고 하지만
// 최신 타입스크립트에서는 속성이 조건부 타입으로 잘 추론이 됩니다.
// 하지만 배우는 입장이니 헬퍼 함수도 알아보면

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

아이템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) {
  polygon.bbox; // Type is BoundingBox | undefined
  const box = polygon.bbox;
  box; // Type is BoundingBox | undefined
  if (polygon.bbox) {
    polygon.bbox; // Type is BoundingBox
    box; // Type is BoundingBox | undefined
  }
}

// 일관된 별칭 사용
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  polygon.bbox; // Type is BoundingBox | undefined
  const box = polygon.bbox;
  box; // Type is BoundingBox | undefined
  if (box) {
    box; // Type is BoundingBox
  }
}

// 다른 이름을 사용하기 보다는 객체 비구조화 사용하기
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;
    }
  }
  // ...
}

객체의 속성에서 주의할 점

polygon.bbox; // Type is BoundingBox | undefined
if (polygon.bbox) {
  polygon.bbox; // Type is BoundingBox
  fn(polygon);
  polygon.bbox; // Type is still BoundingBox
}

타입스크립트는 함수가 타입 정제를 무효화하지 않는다고 가정.

그러나 실제로는 무효화될 가능성이 있음.. (객체 변이 관련한 문제일까?)

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

콜백의 사용 시 코드의 실행 순서가 직관적이지 않음.

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

위 예시에서는 반대이지만 병렬적으로 실행되거나 오류가 있다면 예측하기 어려움.

책에서 이야기하는 콜백보다는 프로미스나 async/await를 이용해야 하는 이유

  • 콜백보다 프로미스가 코드 작성이 쉽다
  • 콜백보다 프로미스가 타입 추론이 쉽다
// 콜백 스타일
function fetchPagesCB() {
  let numDone = 0;
  const responses: string[] = [];
  const done = () => {
    const [response1, response2, response3] = responses;
    // ...
  };
  const urls = [url1, url2, url3];
  urls.forEach((url, i) => {
    fetchURL(url, (r) => {
      responses[i] = url;
      numDone++;
      if (numDone === urls.length) done();
    });
  });
}

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

타입스크립트 타입 추론과 프로미스

function timeout(millis: number): Promise<never> {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject('timeout'), millis);
  });
}

// return 타입이 Promise<Response | never> 일까?
async function fetchWithTimeout(url: string, ms: number) {
  return Promise.race([fetch(url), timeout(ms)]);
}

// Promise<Response | never>는 Promise<Response>로 간단해진다

일반적으로 프로미스를 작성하기보다 async/await를 사용해야 하는 이유

  • 일반적으로 더 간결하고 직관적인 코드가 된다
  • async 함수는 항상 프로미스를 반환한다

이렇게 하지 말자

const _cache: { [url: string]: string } = {};

function fetchWithCache(url: string, callback: (text: string) => void) {
  if (url in _cache) {
    callback(_cache[url]); // 동기적
  } else {
    fetchURL(url, (text) => {
      // 비동기적
      _cache[url] = text;
      callback(text);
    });
  }
}

let requestStatus: 'loading' | 'success' | 'error';

function getUser(userId: string) {
  fetchWithCache(`/user/${userId}`, (profile) => {
    requestStatus = 'success';
  });
  requestStatus = 'loading';
}

캐시된 경우 동기적으로 실행되어 함수 종료 후 requestStatus의 값이 'loading'이 되어버린다

결론: 콜백이나 프로미스를 사용하면 코드가 반동기적이게 될 수 있다. 하지만 async를 사용하면 항상 비동기 코드를 작성할 수 있다. 따라서 어떤 함수가 프로미스를 반환한다면 async로 선언하자.

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

타입스크립트는 타입 추론시 값과 문맥을 모두 살핀다.

문맥이 정확히 뭘 말하는 것인가..

변수 사용시 주의점

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

setLanguage('JavaScript'); // 정상: 값 분리 X

let language = 'JavaScript'; // 타입이 string
setLanguage(language); // 오류: 값 분리 O
// ~~~~~~~~ Argument of type 'string' is not assignable
//          to parameter of type 'Language'

해결하려면?

let language: Language = 'JavaScript'; // 타입 선언, 타입이 Language
const language = 'JavaScript'; // 상수 선언, 타입이 'JavaScript'

튜플 사용 시 주의점

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

panTo([10, 20]); // 정상: 값 분리 X

const loc = [10, 20]; // 타입이 number[]
panTo(loc); // 오류: 값 분리 O
//    ~~~ Argument of type 'number[]' is not assignable to
//        parameter of type '[number, number]'

해결하려면?

const loc: [number, number] = [10, 20]; // 타입 선언

// 상수 단언
function panTo(where: readonly [number, number]) {
  // 매개변수의 타입을 readonly로 해줘야 한다.
}
const loc = [10, 20] as const;

// 위와 같은 방법은 타입 정의에 실수가 있다면 호출부에서 오류가 발생하므로 주의
const loc = [10, 20, 30] as const; // 여기서는 오류가 발생하지 않음
panTo(loc); // 여기서 오류가 발생

객체 사용 시 주의점

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'

해결하려면?

타입 선언과 상수 단언을 사용한다.

콜백 사용 시 주의점

타입스크립트는 콜백의 매개변수의 타입을 문맥을 통해 추론한다.

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

callWithRandomNumbers((a, b) => {
  a; // 타입이 number
  b; // 타입이 number
  console.log(a + b);
});

값으로 함수를 분리해내면?

문맥이 소실되고 noImplicit Any 오류가 발생

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

// 매개변수에 타입 선언을 해주면 해결 가능
// 함수 표현식에 타입 선언을 해주면 해결 가능

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

자바스크립트에서는 절차형과 함수형이 취향차이다.

하지만 타입스크립트에서는 타입 흐름을 개선하고, 가독성을 높이고, 명시적 타입 구문을 줄이기 위해 내장된 함수형 기법과 로대시(lodash)와 같은 유틸리티 라이브러리를 사용해야 한다.

자바스크립트를 프로젝트에서 서드파티 라이브러리를 추가하는 것은 신중해야 한다.

서드파티로 바꾸는 것이 시간이 더 들 수 있기 때문

하지만 타입스크립트에서는 서드파티를 사용하는 것이 무조건 유리하다.

라이브러리를 사용할 때 타입정보가 잘 유지되는 것을 활용해야 타입스크립트의 원래 목적을 달성할 수 있다.

// 절차형, 함수형 모두 같은 오류가 발생
// 해결하려면 인덱스 시그니처 명시적 타입 구문을 사용해야 함
const csvData = '...';
const rawRows = csvData.split('\n');
const headers = rawRows[0].split(',');
import _ from 'lodash';
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 '{}'
    {}
  )
);

// 유틸리티 라이브러리 lodash 사용 시 별도의 타입 구문 없이도 타입 체커 통과
import _ from 'lodash';
const rows = rawRows
  .slice(1)
  .map((rowStr) => _.zipObject(headers, rowStr.split(',')));
// Type is _.Dictionary<string>[]

타입 구문을 줄이고 구현 로직에 집중하게 하는 코드를 작성하는데에 도움이 된다는 점에서 3장의 주제와 목적이 동일하다고 볼 수 있다

©2024 dlwl98
github
PostsAbout