이펙티브 타입스크립트 6장 ( Item 45 ~ 52 )
포스트
취소

이펙티브 타입스크립트 6장 ( Item 45 ~ 52 )

해당 포스트는 이펙티브 타입스크립트 6장을 읽고 정리한 포스트입니다.
책의 모든 내용을 작성하는 것이 아닌 주관적인 기준에 따라 필요한 정보만 정리했습니다.

📖 6장 타입 선언과 @types

📌 Item 45 ( devDependencies에 typescript와 @types 추가하기 )

0️⃣ dependencies와 devDependencies

  • dependencies: 현재 프로젝트를 실행할 때 필요한 라이브러리 기록 ( 런타임에 사용될 라이브러리 )
  • devDependencies: 현재 프로젝트 개발과 테스트에만 사용되는 라이브러리 기록 ( 런타임에 제외될 라이브러리 )

devDependencies는 대표적으로 jest같은 테스트 라이브러리, TypeScript관련 라이브러리 등이 들어갑니다.

1️⃣ 프로젝트에서 고려할 의존성 두 가지

  1. TypeScript는 시스템 레벨에 설치하면 팀원끼리 버전의 차이가 있을 수 있기 때문에 devDependencies에 넣고 설치하기 ( -g X )
  2. 일반적으로 타입 의존성(@types)은 devDependencies에 넣지만, 런타임에 필요한 경우가 있을 수 있음

🎊 Item 45 결론

  1. TypeScript를 시스템에 설치하지 말고 devDependencies에 명시하기
  2. 일반적으로 타입 의존성(@types)은 devDependencies에 포함하기

📌 Item 46 ( 타입 선언과 관련된 세 가지 버전 이해하기 )

TypeScript를 사용할 때는 세 가지를 주의해야 합니다.

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

위 세 가지가 서로 일치하지(호환되지) 않으면 엉뚱한 곳에서 오류가 발생할 수 있습니다.

메이저 / 마이너 버전이 일치한다면 타입에 대한 문제는 없음 ( 패치는 일치하지 않아도 됨 )

0️⃣ 타입 별도 관리의 문제점

라이브러리와 타입 버전이 별도로 관리되기 때문에 몇 가지 문제가 발생할 수 있습니다.

  1. 라이브러리와 타입의 버전이 일치하지 않는 경우
  2. 현재 TypeScript 버전보다 더 높은 버전을 요구하는 라이브러리를 사용하는 경우
  3. @types의 의존성이 중복되면서 버전이 다른 경우

세 가지 경우 모두 설치된 버전을 맞춰주면 해결됩니다.
주의할 점은 어떤 것을 설치/업데이트하면 버전이 항상 일치하는지 체크하는 것이 중요합니다.
( react를 업데이트하면 버전도 메이저/마이너 버전이 같도록 업데이트하기.. 👍 )

1️⃣ 타입 같이 관리의 문제점

axios같은 라이브러리를 타입이 분리되어 있지 않습니다.( @types/axios같은 것은 없음 )
이와 같은 번들링 방식이라고 부르는데 이 방식의 문제점을 알아보겠습니다.

  1. 번들된 타입 선언에 보강 기법으로 해결할 수 없는 문제가 있는 경우
    ( @types의 버전 선택 불가능 )
  2. 프로젝트 내의 타입 선언이 다른 라이브러리의 타입 선언에 의존하는 경우
    ( 프로젝트 배포 시 devDependencies가 설치되지 않아 오류 발생 )
  3. 프로젝트 과거 버전의 타입에 문제가 있는 경우
  4. 타입 선언에 대한 업데이트의 어려움

🎊 Item 46 결론

  1. 라이브러리/@types/TypeScript 버전이 모두 타입과 관련이 있음
  2. 라이브러리를 업데이트하는 경우 @types도 업데이트하기
  3. TypeScript로 작성된 라이브러리라면 타입을 자체적으로 갖고, 반대라면 DefinitelyTyped에 공개하는 것을 권장

📌 Item 47 ( 공개 API에 등장하는 모든 타입을 익스포트하기 )

(1)의 타입들은 내부적으로만 사용하기 때문에 공개하지 않는다고 해서 접근할 수 없는 타입이 아닙니다.
(2)를 내보냈기 때문에 (3)처럼 모든 타입을 추출할 수 있습니다.
따라서 사용자를 위해서 사용하는 모든 타입은 내보내는 것이 좋습니다. ( 굳이 숨길 필요 없음 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// (1)
type Movie = { title: string };
type FetchMovieRequest = { category: string };
type FetchMovieResponse = { movies: Movie[] };
type FetchMovieHandler = (body: FetchMovieRequest) => FetchMovieResponse;

// (2)
export const apiFetchMovie: FetchMovieHandler = (body) => {
  return { movies: [{ title: "기생충" }] };
};

// (3)
type RequestType = Parameters<typeof apiFetchMovie>[0];
type ResponseType = ReturnType<typeof apiFetchMovie>;

🎊 Item 47 결론

  1. 사용하는 모든 타입은 내보내기

📌 Item 48 ( API 주석에 TSDoc 사용하기 )

함수에는 JSDoc/TSDoc을 사용하면 함수에 대한 부가적인 설명을 추가할 수 있습니다.

아래처럼 작성하고 함수 호출에 마우스를 올려보면 TSDoc으로 작성한 설명이 툴팁으로 표시됩니다.
또한 markdown의 형태도 지원이 되기 때문에 참고해서 작성하면 좋습니다.
그리고 특수한 규칙( @param, @returns )을 이용하면 더 명시적인 주석을 작성할 수 있습니다.

다만 주의할 점은 TSDoc에서는 매개변수와 반환 값의 타입을 적으면 안되고, 주석은 간결하게 작성하는 것이 좋습니다.
그리고 함수명과 매개변수로 충분히 추론이 가능하다면 굳이 작성할 필요는 없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
/** "func!!"를 반환하는 함수 */
const func = () => "func!!";

/**
 * 숫자 **두 개**를 받아서 더한 값을 반환하는 함수
 * @param a 피연산자 1
 * @param b 피연산자 2
 * @returns "a"와 "b"를 더한 값
 */
const addTwo = (a: number, b: number) => a + b;

/** 마크다운 적용 **굵은 글씨**, _이텔릭_ [블로그](https://1-blue.github.io/) */
const markdownFunc = () => "";

🎊 Item 48 결론

  1. export한 함수에는 JSDoc/TSDoc으로 주석을 달면 좋음
  2. @param, @returns 등을 활용하기
  3. markdown 사용 가능
  4. TypeScript는 주석에 타입 정보 포함하지 않기

📌 Item 49 ( 콜백에서 this에 대한 타입 제공하기 )

이 아이템에서는 대부분 JavaScript에 대한 내용이라서 짧게 정리하겠습니다.
JavaScript의 대부분은 정적/렉시컬 스코프인 반면, this는 동적/다이나믹 스코프입니다.

0️⃣ 정적 스코프와 동적 스코프

  • 정적(렉시컬) 스코프: 선언되는 시점에 스코프가 결정
  • 동적(다이나믹) 스코프: 호출되는 시점에 스코프가 결정

코어 자바스크립트 3장에는 이런 내용이 있습니다.
아래와 같은 객체에서 say()라는 함수를 메서드라고 부를 수 있을까요..?
say()의 결과는 항상 "Aatrox"가 나올까요..?

1
2
3
4
5
6
const person = {
  name: "Aatrox",
  say() {
    console.log(this.name);
  },
};

말장난이라고 느낄 수 있지만, 정답은 위 코드만 보고는 say()를 메서드라고 단언할 수 없고, 실행 결과도 확실할 수 없습니다.
하지만 메서드라고 부르는 이유는 대부분은 person.say()라고 사용할 거라고 생각하기 때문이죠.

만약 (2)처럼 호출한다면 say()는 더 이상 메서드도 아니면서, this도 일반적으로 생각하는 값이 아니게 됩니다.
요지는 this의 결정 시기는 함수가 호출하는 시점에 정해지기 때문에 선언되는 코드만 보고 예측할 수 없다는 것입니다.
( 즉, this는 다이나믹 스코프를 따른다는 것이죠. )

1
2
3
4
5
6
7
// (1)
person.say(); // "Aatrox"

const { say } = person;

// (2)
say(); // undefined

1️⃣ TypeScript의 this

함수의 첫 번째 인자를 this로 정해주면 TypeScript에서 인식해서 특별하게 처리합니다.

  1. (3)을 보면 this자체를 매개변수에서 제외시켰습니다.
  2. (2)에서는 this를 제대로 바인딩하라는 타입 오류가 발생합니다.

결과적으로 (4)처럼 명시적으로 this를 바인딩해줘야 하기 때문에 개발자의 실수를 줄일 수 있습니다.
그리고 (5)처럼 화살표 함수인지 / 일반 함수인지에 따라서 this를 제대로 추론해줍니다.

addEventListener(type, listener)에서도 listener의 첫 번째 인자로 this가 정의되어 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function addKeyListener(
  el: HTMLElement,
  fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
  el.addEventListener("keydown", function (e) {
    fn(e); // (2) Error: 'void' 형식의 'this' 컨텍스트를 메서드의 'HTMLElement' 형식 'this'에 할당할 수 없습니다.
    fn(this, e); // (3) Error: 1개의 인수가 필요한데 2개를 가져왔습니다.
    fn.call(this, e); // (4)
  });
}

addKeyListener(document.createElement("input"), function (e) {
  // (5)
  console.log(this); // HTMLElement
  console.log(e); // KeyboardEvent
});

addKeyListener(document.createElement("input"), (e) => {
  // (5)
  console.log(this); // globalThis
  console.log(e); // KeyboardEvent
});

🎊 Item 49 결론

  1. this 바인딩의 원리 이해하기
  2. 콜백 함수에서 this를 사용한다면 첫 번째 인자로 타입 명시하기

📌 Item 50 ( 오버로딩 타입보다는 조건부 타입 사용하기 )

아래는 string | number을 받아서 받은 대로 반환하는 함수입니다.
저희는 v1number, v2string을 생각했지만, 타입 체커는 둘 다 string | number로 추론합니다.
이 문제를 여러 가지 방법으로 해결해보겠습니다.

1
2
3
4
5
6
function sum(x: string | number): number | string {
  return x;
}

const v1 = sum(1); // v1: string | number
const v2 = sum("1"); // v2: string | number

0️⃣ 제네릭

가장 쉽게 생각나는 방식입니다.
하지만 제네릭을 사용하면 너무 구체적으로 추론됩니다.

1
2
3
4
5
6
function sum<T extends string | number>(x: T): T {
  return x;
}

const v1 = sum(1); // v1: 1
const v2 = sum("1"); // v1: "1"

1️⃣ 함수 오버로딩

함수 오버로딩을 사용하면 원하던 대로 추론이 됩니다.
하지만 (1)처럼 string | number의 경우 정해진 오버로드가 없기 때문에 타입 오류가 발생합니다.
( 오버로드를 추가하면 해결되지만 더 좋은 방법이 있기 때문에 다음으로 넘어가보겠습니다. )

1
2
3
4
5
6
7
8
9
10
11
function sum(x: string): string;
function sum(x: number): number;
function sum(x: string | number): string | number {
  return x;
}

const v1 = sum(1); // v1: number
const v2 = sum("1"); // v1: string

// (1)
const func = (x: string | number) => sum(x); // Error: 이 호출과 일치하는 오버로드가 없습니다.

2️⃣ 제네릭과 조건부 타입과 오버로딩 사용하기

조건부 타입을 이용하면 함수 오버로딩을 줄이면서 조금 더 정교하게 동작하도록 만들 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
function sum<T extends string | number>(x: T): T extends string ? string : number;
function sum(x: string | number) {
  return x;
}

const v1 = sum(1); // v1: number
const v2 = sum("1"); // v2: string

const func = (x: string | number) => sum(x);
const v3 = func("1"); // v3: string | number
const v4 = func(1); // v4: string | number

3️⃣ 조건부 타입

아래 내용에 대해서는 inpa - 조건부 타입을 보고 정리했기 때문에 해당 블로그를 읽어보시는 것을 추천드립니다.

교재를 읽고 테스트하다가 아무리 테스트해도 이상한 결과가 나오는 부분을 발견해서 30분동안 책이 잘못 적혔나.. 생각했던 부분이 있어서 작성해보겠습니다.

일단 조건부 타입의 사용 방법에 대해서 먼저 알아보겠습니다.
삼항 연산자에 extends를 사용하면 아래와 같은 결과를 얻을 수 있습니다.

1
2
type MyType1 = 1 extends number ? true : false; // true
type MyType2 = 1 extends string ? true : false; // false

근데 아래와 같은 코드를 실행해보면 특이한 결과가 나옵니다.
(2)(3)은 이전과 같은 결과가 나오는데 (4)에서 특이한 값이 나옵니다.
제네릭만 제외하면 똑같은 코드인데 다르게 결과가 나오는 이유는 리터럴 타입과 제네릭 타입에 조건부 타입이 다르게 동작한다고 합니다.

1
2
3
4
5
6
type MyType1 = 1 | 2 | 3 extends number ? string : number; // (2) string
type MyType2 = 1 | 2 | "3" extends number ? string : number; // (3) number

type MyGeneric<T> = T extends string ? string : number;

type MyType3 = MyGeneric<1 | 2 | "3">; // (4) string | number

(3)1 | 2 | "3"이 모두 number에 속하는지 확인하고 결과를 정합니다.
(4)는 아래의 (5)처럼 동작하기 때문에 string | number와 같은 결과나 나오게 됩니다.

1
2
3
4
5
// (5) number | string
type MyType5 =
  | (1 extends string ? string : number)
  | (2 extends string ? string : number)
  | ("3" extends string ? string : number);

이런 부분을 사실 이전에 공부해서 포스팅를 했는데 전혀 인지를 못하고 까먹었네요.
교재에서도 따로 설명없이 (5)처럼 동작한다고만 적혀 있어서 한참 생각하고 헤멨네요… 🥲

🎊 Item 50 결론

  1. 오버로딩 보다는 조건부 타입을 사용하는 것이 좋음

📌 Item 51 ( 의존성 분리를 위해 미러 타입 사용하기 )

CSV를 파싱하는 라이브러리를 만들어서 배포한다고 가정해보겠습니다.
아래는 Node.js를 호환하기 위해서 Buffer라는 타입을 허용했습니다.

물론 정상적으로 동작하지만 두 가지 문제점이 있습니다.

  1. TypeScript를 사용하지 않는 경우 불필요한 타입 추가
  2. Node.js를 사용하지 않는 TypeScript 웹 개발자 ( @types/node에 의존하기 때문 )

저희는 @types/nodeBuffer라는 타입에서 극히 일부분의 메서드만 사용하기 때문에 그 부분만 나눠서 타입을 생성하면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// "Buffer"는 "@types/node"에 존재합니다. ( 설치된 타입 대체 용도로 사용 .. 실제로는 타입을 설치했다고 가정 )
type Buffer = {
  toString: (encoding: string) => string;
};

type ParseCSVHanlder = (contents: string | Buffer) => { [column: string]: string }[];

const parseCSV: ParseCSVHanlder = (contents) => {
  if (typeof contents === "object") {
    return parseCSV(contents.toString("utf-8"));
  }

  return [{ column: contents }];
};

TypeScript구조적 타이핑을 따르기 때문에 사용할 부분만 선언해서 사용해도 문제 없이 동작합니다.
아래처럼 사용하는 것을 미러링이라고 하고 일부분의 타입만 필요하다면 미러링을 사용하는 것이 좋을 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// "Buffer" 대신 선언한 타입
type CSVBuffer = {
  toString: (encoding: string) => string;
};

type ParseCSVHanlder = (contents: string | CSVBuffer) => { [column: string]: string }[];

const parseCSV: ParseCSVHanlder = (contents) => {
  if (typeof contents === "object") {
    return parseCSV(contents.toString("utf-8"));
  }

  return [{ column: contents }];
};

🎊 Item 51 결론

  1. 필수가 아닌 의존성을 불리할 때는 구조적 타이핑을 사용
  2. JavaScript에서 @types, 웹에서 Node.js의 의존성을 갖지 않도록 해야 함

📌 Item 52 ( 테스팅 타입의 함정에 주의하기 )

dtslint같은 라이브러리를 이용해서 TypeScript의 테스트를 진행하는 것이 좋다고 합니다.

여기서는 어떤 방식으로 타입을 비교하고, 각 방식의 어떤 함정이 숨어있는지 확인해보겠습니다.

아래 코드가 전역적으로 선언되었다고 가정하고 코드를 작성하겠습니다.

1
2
3
4
5
6
7
8
// 테스트 라이브러리라고 가정
declare function test(info: string, testFunc: () => void): void;

// 테스트할 함수
declare function map<T, U>(array: T[], func: (u: T) => U): U[];

// 타입 테스트 함수
function assertType<T>(x: T) {}

0️⃣ 원시 타입 테스트

(1), (2)는 타입을 체크하지만, 약간의 문제가 있습니다.
(3)처럼 테스트 함수를 이용해서 테스트하는 것이 더 확실하게 테스트를 수행합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// (1) 함수 실행에 대해서만 체크함
test("map() Test - 1", () => {
  map(["1", "2", "3"], (v) => Number(v));
});

// (2) 불필요한 변수 발생
test("map() Test - 2", () => {
  const result: number[] = map(["1", "2", "3"], (v) => Number(v));
});

// (3) 1,2 해결
test("map() Test - 3", () => {
  assertType<number[]>(map(["1", "2", "3"], (v) => Number(v)));
});

1️⃣ 객체 타입 테스트

객체나 함수같은 경우 유연하게 동작하는 경우가 많기 때문에 일반적인 방법으로 비교하면 확실하게 비교할 수 없습니다.

(4)같은 경우에는 비교 대상이 더 많은 property를 갖고 있지만 에러를 발생하지 않습니다.
(5)는 매개변수 개수까지 모두 비교하지 않기 때문에 에러가 발생하지 않습니다.

따라서 (5)Parameters<T>, ReturnType<T>를 이용해서 테스트하면 더 확실하게 비교할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// (4) 객체의 경우 정확한 비교를 하지 못함 ( 구조적 타이핑 )
test("map() Test - 4", () => {
  // "{ age: number, name: string }[]"와 "{ age: number }[]"를 비교하지만 에러를 발생하지 않음
  assertType<{ age: number }[]>(
    map(["1", "2", "3"], (v) => ({ age: +v, name: "이름" + v }))
  );
});

// (5) 함수의 매개변수가 더 적은 경우 허용
test("map() Test - 5", () => {
  const add = (x: number, y: number) => x + y;

  assertType<(x: number, y: number, z: number) => number>(add);
});

// (6) 5 해결 ( 매개변수를 정확하게 비교 )
test("map() Test - 7", () => {
  const add = (x: number, y: number) => x + y;

  const p: Parameters<typeof add> = null!;
  const r: ReturnType<typeof add> = null!;

  assertType<[number, number, number]>(p); // Error: '[x: number, y: number]' 형식의 인수는 '[number, number, number]' 형식의 매개 변수에 할당될 수 없습니다.
  assertType<number>(r);
});

🎊 Item 52 결론

  1. 타입을 테스트할 때는 함수 타입의 동일성과 할당 가능성의 차이에 대해 알아야 함
  2. 콜백이 있는 함수를 테스트할 때는 this와 콜백 매개변수의 타입 모두 테스트해야 함
  3. any를 주의하고 dtslint같은 도구를 사용하는 것이 좋음

📮 레퍼런스

  1. « 이펙티브 타입스크립트 6장 » ( 댄 밴더캄 지음, 장원호 옮김, 인사이트, 2021 )
  2. 1-blue - 코어 자바스크립트 3장(this 결정 시점)
  3. inpa - 조건부 타입
  4. 1-blue - 조건부 타입
  5. 1-blue - 구조적 타이핑
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

TypeScript Exercises

리덕스 툴킷 ( Redux-Toolkit + TypeScript + React )