본문 바로가기

HTML ⁄ CSS ⁄ JS

[이펙티브 타입스크립트] 6장 타입선언과 @types

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

 

45 typescript와 @types 추가하기

typescript와 개발 단계에서는 필요하지만 런타임에는 필요하지 않다. 따라서 dependencies가 아닌 devDependencies에 추가해야한다. typescript를 의존성으로 관리하면, 팀원들 간에 같은 버전을 쓰는 것을 보장할 수 있다.

@types도 마찬가지로 devDependencies에 추가한다. 예를 들어 리액트와 웹팩을 사용하는 경우 아래의 패키지를 devDependencies에 추가한다. 

@types/react
@types/react-dom
@types/webpack
@types/webpack-dev-server
@types/node

 

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

타입스크립트에서 의존성 관리를 더 깊이 이해하기 위해서 다음 3가지 버전을 살펴보자.

1) 라이브러리의 버전
2) 타입 선언(@types)의 버전
3) typescript의 버전

$ npm i react
$ npm i @types/react -D

+ react@17.0.2
+ react@17.0.37

위 경우에 라이브러리 보다 @types 모듈에서 더 많은 패치버전을 릴리즈한 것을 확인할 수 있다.

그런데 이렇게 1) 라이브러리의 버전과 2) 타입 선언의 버전이 서로 상이할 경우 여러가지 문제점이 생길 수 있다. 이 경우 더 낮은 버전을 올리거나, 더 높은 버전을 낮춰서 해결할 수 있다.

3-a)프로젝트에서 사용하는 typescript버전과 3-b)라이브러리에 필요한 typescript버전이 서로 다른 경우에도 비슷하게 해결할 수 있다. 다음 예제처럼 타입스크립트의 특정 버전을 지정해서 타입 정보를 설치할 수 있다.

$ npm i -D @types/lodash@ts3.1

 

프로젝트 내에서 선언한 타입의 경우 필수는 아니지만 package.json에서 지정하는 것이 권장된다.

 

{
  // ...
  "types": "index.d.ts",
  // ...
}

라이브러리를 배포하는 경우, 타입스크립트로 작성했다면 위와 같이 타입 선언을 자체적으로 포함시키는 방법이 있고, 자바스크립트로 작성했다면 DefinitelyTyped라는 JS라이브러리의 타입들을 관리하는 커뮤니티에서 관리하는 타입 모음집에 추가하는 방법도 있다.

 

*트랜시티브 디펜던시(transitive dependency): 한 의존성이 다른 대상에 직접 옮아가는 현상을 가리킨다. '전이적 의존성'으로 직역할 수 있다.

 

 

47 공개 API의 모든 타입 export하기

어떤 라이브러리에서 타입을 모두 export 해두지 않았다면, 라이브러리 사용자는 해당 타입을 직접 import할 수는 없을 것이다. 그렇지만 사용자는 아래와 같이 제너릭으로 타입 정보를 추출해낼 수 있다. 

export function getGift(name: SecretName, gift: string): SecretSanta {
  // ...
}

// 제너릭으로 함수 시그니처 추출 가능
type MyName = Parameters<type of getGift>[0]
type MySanta = ReturnType<type of getGift>

 

이렇게 라이브러리 사용자가 직접 추출할 수 있기는하지만, 라이브러리를 제작하는 입장이라면 사용자의 편의성을 위해 공개 API에 등장하는 타입정보는 모두 export하는 것이 좋다.

 

48 API 주석에 TSDocs 쓰기

함수 앞에 // 로 시작하는 주석을 작성한다면 에디터에 아무런 표시가 되지 않지만, JSDocs 스타일로 /** */ 와 같이 작성하면 에디터 툴팁에 해당 주석 내용이 표시된다. 단, @param, @returns 와 같은 정보는 타입스크립트에서는 주석으로 소화하면 안된다.

/**
 * Generate a gretting.
 * @param name Name of the person to greet
 * @param title
 * @returns A greeting formatted for human consumption.
*/
function greetFullTSDoc(name: string, title: string) {
  return `Hello ${title} ${name}`;
}

 

타입 정의 부분에도 JSDocs 와 같이 TSDocs를 쓸 수 있다. TSDocs는 마크다운 형식으로 표시된다.

/** 
 * Measurement in a certain position and time 
 * 1. case1
 * 2. case2
 * 3. case3
*/

interface Measurement {
  /** Where was the measurement made? */
  position: Vector3D;
  /** When was the measurement made? In seconds since epoch */
  time: number;
  /** Momentum measured */
  momentum: Vector3D;
}

 

 

49 콜백에서 this의 타입 제공하기

콜백함수 안에서 this를 사용한다면 타입을 제공하는 것이 좋다.

this는 어디에서 '호출'되었느냐에 따라 동적으로 정해진다. (let, const로 선언된 변수의 스코프는 어디에 '정의'되어 있느냐에 따라 정적으로 정해지는 것과 대조적이다.) 그렇다보니, this를 사용하는 경우에는 this의 타입을 제공하는 것이 도움이 된다.

콜백의 첫번째 인자로 전달되는 this는 특별하게 처리된다.

 

 

50 오버로딩 타입보다는 조건부 타입 쓰기

타입스크립트는 오버로딩 타입들을 순차적으로 탐색하면서 일치하는 타입이 있는지 확인한다고 한다. 마지막 오버로딩 타입까지 체크했는데 일치하는 타입이 없을 경우 타입 에러가 발생한다.

아래는 함수 오버로딩의 예제는 유효하지 않은 상태를 포함한다. number를 넣었는데 string이 나온다거나, string을 넣었는데 number가 나오는 경우는 존재하지 않는다. 

function double(x: number|string): number|string;

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

 

유효하지 않은 상태를 제거하기 위해 아래와 같이 오버로딩 대신에 제너릭을 사용해볼 수 있다.

function double<T extends number|string>(x: T):T ;

 

이보다 더 정교하게 사용하기 위해 아래와 같이 유니온에 조건부 타입을 결합해서 사용해볼 수 있다. 

function double<T extends number|string>(x : T): T extends string ? string : number;

중요한 점은 유니온에 적용된 조건부 타입은, 각 유니온 구성요소별로 조건부 타입이 따로따로 적용된다는 점이다. 따라서, T에 number|string이 들어와도 정상적으로 동작한다는 점이다.

(number|string) extends string ? string : number
=> (number extends string ? string : number) | (string extends string ? string : number)
=> number | string

 

51 의존성 분리를 위해 미러 타입 쓰기

라이브러리를 작성하는 경우, 작성하려는 기능과 무관하게 특정 타입에 의존하게 된다면, 해당 타입의 선언부만 복사해서 라이브러리에 붙여넣는 것이 좋다. 이렇게 타입을 추출해서 추가하는 방식을 미러링(mirroring)이라고 한다.

매개변수의 타입을 'string | Buffer'로 지정한 경우를 가정해보자. Buffer는 NodeJS 타입 선언인 @types/node 에 정의되어있는 타입이다.

이 Buffer 타입을 포함해 라이브러를 배포한다면, 라이브러리 사용자는 @types/node를 devDependencies에 포함하게 된다. 그러면 타입스크립트가 아닌 자바스크립트를 사용하는 사용자 입장에서는 필요없는 @types 를 의존성으로 갖게되는 문제가 있다.

게다가, NodeJS 사용자가 아니라면 Buffer라는 타입이 필요하지도 않다.

이렇게 불필요하게 타입이 포함되는 경우를 방지하기 위해 NodeJS의 Buffer 타입을 사용하기 보다, 이와 호환되는 새로운 인터페이스를 정의하는 것이 좋다. 아래는 @types나 NodeJS 의존도를 없애기 위해서, Buffer의 미러 타입을 추가한예시이다.

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

function parseCSV(contents: string | CsvBuffer): { [column: string]: string }[] {
  // ....
}

 

언제 기존에 정의되어있는 타입을 그대로 사용할지, 언제 미러링을 사용할지는 경우에 따라 판단해야 한다. 만약 타입을 추출하는 작업이 반복된다면 차라리 타입선언을 그대로 활용하고 의존하는게 맞다.

 

 

 

52 테스팅 타입의 함정 주의하기

타입을 테스트하는 것은 쉽지 않은 작업이다. dstlint와 같은 도구를 활용하는 것이 좋다.

dtslint는 TypeScript 선언 파일에서 작성한 스타일은 괜찮은지, 정확성은 어떤지 테스트해주는 도구이다. (dtslint 는 현재 Definitenly-Typed tools 레포에서 통합관리되고 있다.)

dtslint의 $ExpectType 주석은 식(expression)이 반환한 타입이 일치하는지 테스트해준다. 식 위에 써도 되고 식이랑 같은 줄에 써도 된다. $ExpectError는 에러가 발생하는지 테스트해준다.

import { f } from "my-lib"; // f is(n: number) => void

// $ExpectType void
f(1);

// Can also write the assertion on the same line.
f(2); // $ExpectType void

// $ExpectError
f("one");