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

0

6장 타입 선언과 @types

타입스크립트에서 의존성이 어떻게 동작하는지 알 수 있다.

의존성 관리 시 겪을 수 있는 문제와 해결책 예시

아이템45 devDependencies에 typescript와 @types 추가하기

dependencies

런타임에 필요한 라이브러리들

프로젝트를 npm에 공개하여 다른 사용자들이 설치하면 dependencies내의 라이브러리들이 함께 설치된다. (전이 의존성 - transitive)

devDependencies

프로젝트를 개발하고 테스트하는데 사용되지만 런타임에는 필요 없는 라이브러리들

프로젝트를 npm에 공개하여 다른 사용자들이 설치해도 decDependencies 내의 라이브러리들은 설치되지 않는다.

peerDependencies

런타임에 필요하긴 하지만, 의존성을 직접 관리하지 않는 라이브러리들

단적인 예로 플러그인(제이쿼리 플러그인)??

타입스크립트는 개발 도구이므로 타입스크립트와 관련된 라이브러리는 일반적으로 devDependencies에 속한다.


모든 타입스크립트 프로젝트에서 고려해야 할 두 가지 의존성

1. 타입스크립트는 시스템 레벨에 설치하지 말자

아래 두 가지 이유로 devDependencies에 넣는 것이 좋다.

  • 팀원들 모두가 항상 동일한 버전을 설치해야한다.
  • 프로젝트를 셋업할 때 별도의 단계가 없어야 한다.

devDependencies에 포함되어 있다면 npm install을 통해 팀원들 모두가 정확한 버전의 타입스크립트를 설치할 수 있다.

2. 타입 의존성(@types)을 고려하자

사용하려는 라이브러리에 타입선언이 포함되어 있지 않다면 @types 라이브러리를 devDependencies로 설치해 타입 선언을 가져오자

@types란? npm에 공개된 DefinitelyTyped(타입스크립트 커뮤니티에서 유지보수하는 자바스크립트 라이브러리의 타입을 정의한 모음)의 타입정의

타입 의존성을 devDependencies에 넣는 것이 항상 유효하지 않다. 자세한 내용은 다음 아이템에서..

요약

  • 타입스크립트를 시스템 레벨로 설치하지 말자. 프로젝트의 devDependencies에 포함시키자.
  • @types는 devDependencies에 포함시키자. 런타임에 @types가 필요한 경우라면 별도의 작업이 필요하다.

아이템46 타입 선언과 관련된 세 가지 버전 이해하기

우리는 단순히 라이브러리를 추가해 사용할 뿐 전이적(transitive) 의존성의 호환은 깊게 생각하지 않는다.

실제로 타입스크립트는 의존성 관리를 복잡하게 만든다.

타입스크립트 사용시 고려해야 할 세 가지

  • 라이브러리의 버전
  • @types의 버전
  • 타입스크립트의 버전

세가지 버전 중 하나라도 맞지 않으면 의존성과 상관없어 보이는 곳에서 엉뚱한 오류가 발생할 수 있다.

오류를 고치기 위해서는 타입스크립트 라이브러리 관리의 복잡한 매커니즘을 이해해야 한다

$ npm install react
+ react@16.8.6

$ npm install --save-dev @types/react
+ @types/react@16.8.19

react와 @types/react 모두 16.8 버전의 API를 나타낸다.

16.8.x는 버그나 누락으로 인한 수정과 추가에 따른 것

위 예제의 경우 라이브러리보다 타입 선언에 더 많은 업데이트가 있었다.

이렇게 라이브러리와 타입 정보의 버전이 별도로 관리되는 방식은 네 가지 문제가 있다

1. 라이브러리는 업데이트했지만 실수로 타입 선언은 업데이트하지 않은 경우

해결책

  • 타입 선업을 업데이트해 라이브러리와 버전을 맞추기
  • 보강 기법을 활용해 타입 정보를 프로젝트 자체에 추가하기
  • 타입 선언의 업데이트를 직접 작성하고 공개하여 커뮤니티에 기여하기

2. 라이브러리보다 타입 선언이 최신인 경우

타입 정보 없이 라이브러리를 사용하다가 뒤늦게 타입 선언을 설치했을 때 발생한다. 타입 체커는 최신의 API를 기준으로 코드를 검사하지만 런타임에 실제로 쓰이는 것은 과거버전이다.

해결책

  • 라이브러리의 버전을 올리거나 타입 선언의 버전을 내리기

3. 프로젝트의 타입스크립트 버전 < 라이브러리가 필요로 하는 타입스크립트 버전

@types 선언 자체에 오류가 발생한다.

해결책

  • 프로젝트의 타입스크립트 버전을 올리거나 라이브러리 타입 선언의 버전을 내리기

특정 타입스크립트 버전의 타입 정보를 설치하는 방법 예시 (typesVersions 제공해야 함)

$ npm install --save-dev @types/lodash@ts3.1

typesVersions는 지원하는 라이브러리가 1% 미만이다.
프로젝트의 타입스크립트 버전을 올리는 것이 현실적인 해결책이지 않을까?

4. @types 의존성이 중복될 수 있다

프로젝트가 @types/foo 와 @types/bar 에 의존하고 @types/bar 가 호환되지 않는 버전의 @types/foo 에 의존한다면 npm은 중첩된 폴더에 별도로 해당 버전을 설치해 문제를 해결한다.

전역 네임스페이스에 있는 타입 선언 모듈일 때 문제가 발생한다.

이 경우 npm ls @types/foo 를 실행해 어디서 타입 선언 중복이 발생했는지 추적할 수 있다.

해결책

  • @types/foo 나 @types/bar를 업데이트해 버전이 호환되게 하기

일부 타입스크립트로 작성된 라이브러리들은 자체적으로 타입 선언을 번들링한다

번들링 방식은 버전 불일치 문제를 해결하기는 하나 부수적인 네 가지 문제점이 있다

1. 보강 기법으로 해결할 수 없는 오류가 있는 경우, 타입스크립트 버전이 올라가면서 오류가 발생하는 경우

번들된 타입에서는 @types의 버전 선택이 불가능하다.

단 하나의 잘못된 타입 선언으로 타입스크립트의 버전을 올리지 못하는 불상사가 생길 수 있다.

2. 프로젝트 내의 타입 선언이 다른 라이브러리의 타입 선언에 의존하는 경우

타입 선언은 보통 devDependencies에 설치된다.

공개된 프로젝트를 다른 사용자가 설치했을 때 devDepedencies는 설치되지 않으므로 타입 오류가 발생하게 된다.

이런 경우 dependencies에 설치해야 하나?

3. 과거 버전 타입 선언에 문제가 있는 경우

과거 버전을 업데이트 해야 하지만 번들링된 타입 선언에서는 어려운 일이다.

DefinitelyTyped는 여러 버전을 동시에 유지보수 할 수 있는 메커니즘을 가지고 있다.

4. 타입 선언의 패치 업데이트를 자주 하기 어렵다.

요약

  • @types 의존성은 라이브러리 버전, @types 버전, 타입스크립트 버전과 관련이 있다
  • 라이브러리를 업데이트하면 @types도 업데이트해야 한다
  • 타입스크립트로 작성된 라이브러리는 타입 선언을 자체적으로 포함시키고, 자바스크립트로 작성된 라이브러리는 타입 선언을 DefinitelyTyped에 공개하는 것이 좋다.

아이템47 공개 API에 등장하는 모든 타입을 익스포트하기

타입스크립트를 사용하다 보면 서드파티 모듈에서 익스포트되지 않는 타입 정보가 필요한 경우가 생긴다
라이브러리 제작자는 프로젝트 초기에 타입 익스포트부터 작성해야 한다

아래는 제작자가 타입을 노출시키지 않으려고 일부러 익스포트하지 않았다고 가정한 사례

interface SecretName {
  first: string;
  last: string;
}

interface SecretSanta {
  name: SecretName;
  gift: string;
}

export function getGift(name: SecretName, gift: string): SecretSanta {
  // COMPRESS
  return {
    name: {
      first: 'Dan',
      last: 'Van',
    },
    gift: 'MacBook Pro',
  };
  // END
}

함수 시그니처에 등장하는 타입은 추출해낼 수 있다.

type MySanta = ReturnType<typeof getGift>; // SecretSanta
type MyName = Parameters<typeof getGift>[0]; // SecretName

공개 API 매개변수에 놓이는 순간 타입은 노출된다.

따라서 숨기려 하지말고 사용자를 위해 익스포트하자


아이템48 API주석에 TSDoc 사용하기

JSDoc 스타일의 주석을 달자. 편집기에서 툴팁으로 표시해준다.

타입스크립트 관점에서 TSDoc이라고도 한다

/** Generate a greeting. */
function greetFullTSDoc(name: string, title: string) {
  return `Hello ${title} ${name}`;
}

타입 정의에 사용하는 예시

각 필드에 마우스를 올려보면 설명을 볼 수 있다.

interface Vector3D {}
/** A measurement performed at a time and place. */
interface Measurement {
  /** Where was the measurement made? */
  position: Vector3D;
  /** When was the measurement made? In seconds since epoch. */
  time: number;
  /** Observed momentum */
  momentum: Vector3D;
}

TSDoc 주석을 마크다운 형식으로 꾸며진다.

/**
 * This _interface_ has **three** properties:
 * 1. x
 * 2. y
 * 3. z
 */
interface Vector3D {
  x: number;
  y: number;
  z: number;
}

주석을 장황하게 쓰지 말자

훌륭한 주석은 간단히 요점만 언급한다

TSDoc에서는 타입 정보를 명시하지 말자(param, returns)

요약

  • 익스포트된 함수, 클래스 타입에 TSDoc을 형태의 주석을 달면 편집기에서 정보를 표시할 수 있다
  • TSDoc에서 마크다운을 사용할 수 있다
  • TSDoc 주석에 타입 정보를 포함하지 않는다

아이템49 콜백에서 this에 대한 타입 제공하기

자바스크립트의 this는 다이나믹 스코프이다(호출된 방식에 따라 값이 달라진다)

this는 전형적으로 객체의 현재 인스턴스를 참조하는 클래스에서 많이 쓰인다

하지만 this가 반드시 클래스 인스턴스에 바인딩 되어야 하는 것은 아니다. 어떤 값이든 바인딩할 수 있다.

따라서 라이브러리들은 일부 API에서 this의 값을 사용할 수 있게 한다.

document.querySelector('input')!.addEventListener('change', function (e) {
  console.log(this); // 이벤트가 발생한 input 엘리먼트 출력
});

아래 두 예시의 차이점 알아보기

class ResetButton {
  constructor() {
    this.onClick = this.onClick.bind(this)
    // 해당 인스턴스에 생성됨
    // 인스턴스의 onClick은 프로토타입의 onClick보다 속성탐색순서가 앞선다
    // 따라서 render 메서드는 바인딩된 onClick을 참조하게 된다
  }
  render() {
    return makeButton({ text: 'Reset', onClick: this.onClick })
  }
  onClick() { // 프로토타입에 정의됨. 모든 인스턴스에서 공유
    alert(`Reset ${this}`)
  }

class ResetButton {
  render() {
    return makeButton({ text: 'Reset', onClick: this.onClick })
  }
  onClick = () => {
    alert(`Reset ${this}`)
    // this가 항상 인스턴스를 참조한다
    // ResetButton이 생성될 때마다 제대로 바인딩된 새로운 onClick이 생성됨
  }
}

작성중인 라이브러리에서 this를 사용하는 콜백함수가 있다면 this바인딩 문제를 고려하자

function addKeyListener(
  el: HTMLElement,
  fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
  el.addEventListener('keydown', (e) => {
    fn.call(el, e);
  });
}

콜백함수의 첫 번째 메개변수 this는 특별하다

this바인딩이 체크되어 실수를 방지한다

function addKeyListener(
  el: HTMLElement,
  fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
  el.addEventListener('keydown', (e) => {
    fn(el, e);
    //     ~ 1개의 인수가 필요한데 2개를 가져왔습니다.
  });
}

function addKeyListener(
  el: HTMLElement,
  fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
  el.addEventListener('keydown', (e) => {
    fn(e);
    // ~~~~~ 'void'형식의 'this' 컨텍스트를
    //       메서드의 'HTMLElement' 형식 'this'에 할당할 수 없습니다.
  });
}

요약

  • this바인딩이 동작하는 원리 이해하기
  • 콜백함수에서 this를 사용한다면 타입 정보를 명시하기

아이템50 오버로딩 타입보다는 조건부 타입을 사용하기

double 이라는 함수를 만들어보자. 숫자는 2배 해주고 문자열은 두번 반복해줘야 한다.

이렇게 만들어봤다

function double(x: number | string): number | string;
function double(x: any) {
  return x + x;
}

반환 타입이 string | number 로 고정되어 마음에 들지 않는다

const num = double(12); // string | number
const str = double('x'); // string | number

제네릭을 사용해 해결해보았다

function double<T extends number | string>(x: T): T;
function double(x: any) {
  return x + x;
}

하지만 타입이 너무 구체적으로 추론되었고 실제 값과 다르다

const num = double(12); // Type is 12
const str = double('x'); // Type is "x"

아래와 같이 오버로딩을 쓸 수도 있지만

function double(x: number): number;
function double(x: string): string;
function double(x: any) {
  return x + x;
}

이런 정상적인 호출에서 오류가 발생하는 문제가 있다.

function f(x: number | string) {
  return double(x);
  // ~ Argument of type 'string | number' is not assignable
  //   to parameter of type 'string'
}

물론 오버로딩을 하나 추가해서 해결할 수 있지만 좋은 해결책은 아니다.

function double(x: number): number;
function double(x: string): string;
function double(x: string | number): string | number;
function double(x: any) {
  return x + x;
}

가장 좋은 해결책은 조건부 타입을 사용하는 것!

function double<T extends number | string>(
  x: T
): T extends string ? string : number;
function double(x: any) {
  return x + x;
}

// function f(x: string | number): string | number
function f(x: number | string) {
  return double(x);
}

오버로딩 타입은 독립적으로 처리되는 반명, 조건부 타입은 타입 체커가 단일 표현식으로 받아들이기 때문에 유니온 문제를 해결할 수 있다.

요약

  • 오버로딩 타입을 작성 중이라면 조건부타입으로 개선할 수 있을지 검토해보자

아이템51 의존성 분리를 위해 미러 타입 사용하기

csv 파싱 라이브러리를 만든다고 가정했을 때 NodeJS 사용자를 위해 매개변수에 Buffer 타입을 허용했다.

이 경우 @types/node 타입 선언을 설치해야 했다.(devDependencies에)

function parseCSV(contents: string | Buffer): { [column: string]: string }[] {
  if (typeof contents === 'object') {
    // It's a buffer
    return parseCSV(contents.toString('utf8'));
  }
  // COMPRESS
  return [];
  // END
}

Buffer는 NodeJS + Typescript 개발자에게만 필요하므로 다른 그룹의 사용자는 혼란스러울 것이다

따라서 이런 의존성을 분리하고(@types/node를 사용하지 않고) 필요한 속성과 메서드만 별도로 작성할 수 있다.(구조적 타이핑 적용)

interface CsvBuffer {
  toString(encoding: string): string;
}

function parseCSV(
  contents: string | CsvBuffer
): { [column: string]: string }[] {
  // COMPRESS
  return [];
  // END
}

// NodeJS의 Buffer 사용도 가능(구조적 타이핑)
parseCSV(new Buffer('column1,column2\nval1,val2', 'utf-8'));

이렇게 라이브러리가 의존하는 라이브러리의 타입에만 의존한다면, 미러링(필요한 선언부만 추출하는 것)을 고려해보는 것이 좋다

하지만 타입 선언 대부분을 추출해야 한다면, 의존성을 추가하는 것이 낫다..

요약

  • 의존성을 분리할 때는 구조적 타이핑 사용
  • 자바스크립트 사용자가 @types 의존성을 가지지 않게 하기
  • 웹 개발자가 NodeJS 관련 의존성을 가지지 않게 하기

아이템52 테스팅 타입의 함정에 주의하기

타입 선언을 테스트 시 함수의 실행만 체크하지 말고, 반환하는 타입도 체크해주자

변수를 도입할수도 있고 헬퍼함수를 작성할 수도 있다.

변수의 경우 미사용 변수 경고 린팅 규칙에 위배될 수 있으므로 헬퍼함수 사용 권장

declare function map<U, V>(array: U[], fn: (u: U) => V): V[];

function assertType<T>(x: T) {}
assertType<number[]>(map(['john', 'paul'], (name) => name.length));

하지만 선언된 것보다 적은 매개변수의 함수를 할당하는 것이 가능한 타입스크립트의 특성상 아래와 같은 문제 발생..

assertType<{ name: string }[]>(
  map(beatles, (name) => ({
    name,
    inYellowSubmarine: name === 'ringo',
  }))
); // OK

매개변수와 반환값을 분리하여 테스트 할 수 있다.(미사용 변수 경고도 없음!)

const double = (x: number) => 2 * x;

let p: Parameters<typeof double> = null!;
assertType<[number]>(p);

let r: ReturnType<typeof double> = null!;
assertType<number>(r);

아래와 같은 선언은 모듈 전체에 any 타입을 할당한다(?)

타입 안전성을 포기하게 된다..

declare module 'overbar';

이러한 어려움 때문에 타입 체커와 독립적인 도구를 사용하기도 한다.

dtslint 예시

const beatles = ['john', 'paul', 'george', 'ringo'];
map(
  beatles,
  function (
    name, // $ExpectType string
    i, // $ExpectType number
    array // $ExpectType string[]
  ) {
    this; // $ExpectType string[]
    return name.length;
  }
); // $ExpectType number[]

글자 자체가 같은지 비교하여 테스트한다.

단점은 number | stringstring | number 가 같은 것을 인지하지 못한다는 것

요약

  • 타입도 테스트하자
  • 함수 타입의 동일성과 할당 가능성의 차이점을 알자
  • 콜백의 경우 매개변수의 타입도 테스트하고, this가 쓰인다면 그것도 역시 테스트하자
  • any를 주의하고, 엄격한 테스트를 위해 dtslint와 같은 도구를 이용하자
©2024 dlwl98
github
PostsAbout