이펙티브 타입스크립트 3장 ( 19 ~ 27 )
포스트
취소

이펙티브 타입스크립트 3장 ( 19 ~ 27 )

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

📖 3장 타입 추론

📌 Item 19 ( 추론 가능한 타입을 사용해 장황한 코드 방지하기 )

TypeScript에서 추론을 해준다면 명시적으로 타입을 넣는 것은 좋지 않습니다.
오히려 명시적으로 넣다가 실수할 수도 있습니다.
또한 TypeScript의 추론이 더 세밀하고 정확할 때도 많습니다.

0️⃣ 타입 추론이 된다면 명시적으로 작성하지 말기

1
2
3
4
5
// (1)
let age: number = 10;

// (2)
let age = 10;

(1), (2)의 경우 모두 같은 타입으로 추론됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
// (3)
const person = {
  name: "alice",
  age: 26,
  vision: {
    left: 0.3,
    right: 0.3,
  },
};

// (4)
const func = (...args: number[]) => args.reduce((acc, curr) => acc + curr);

(3)처럼 복잡한 객체의 경우에도 정확하게 추론을 해주고, (4)처럼 함수의 경우에도 반환 값을 정확한 타입으로 추론해줍니다.
( (3)의 경우 일회용으로 타입을 정의하면 더 힘들고 귀찮은 작업이 되겠죠. )

1
2
// (4)
const z = 10;

일반적으로 (4)number라고 생각할 수 있지만, TypeScript10이라고 추론합니다.
생각해보면 const 원시 타입은 변경할 수 없으니 더 정확한 추론입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Product = {
  id: string;
  name: string;
  price: number;
};

const logProduct = (product: Product) => {
  // (5)
  const id: string = product.id;
  const name: string = product.name;
  const price: number = product.price;

  console.log(id, name, price);
};

(5)같은 경우도 명시적으로 타입을 적용하기 보다는 타입 추론을 믿고 그대로 사용하면 됩니다.
(5)처럼 작성했다가 나중에 Product가 바뀌게 되면 logProduct()의 내용도 바꿔야 합니다.

결론은 logProduct()의 매개변수처럼 타입 추론이 되지 않는 경우에만 명시적으로 작성(Product)하고, 타입 추론이 된다면 그대로 사용하는 것이 더 현명한 사용 방법입니다.

1️⃣ 명시적으로 타입을 작성해야 하는 경우

대부분의 경우 TypeScript에서 타입을 추론해서 정해줍니다.
하지만 명시적으로 타입을 작성해야 하는 경우 혹은 그냥 하면 좋은 경우가 있습니다.

1
2
3
4
5
6
7
8
9
10
11
// 위 예시(5)의 "logProduct()"를 쓴다고 가정

// (1)
const product = {
  id: 1,
  name: "keyboard",
  price: 200_000,
};

// (2) "logProduct()"에서 "id"의 타입이 맞지 않는다고 에러 발생
logProduct(product);

저희가 잘못적은 곳은 (1)지만 (2)에서 에러가 발생합니다.
이런 경우 아래와 같이 작성하면 (3)에서 id 타입에 관한 에러가 먼저 발생합니다.
타입에 관한 문제외에 오타같은 실수도 잡을 수 있습니다.

1
2
3
4
5
6
// (3)
const product: Product = {
  id: 1,
  name: "keyboard",
  price: 200_000,
};

또한 함수의 반환 타입같은 경우도 확실하게 명시하는 게 좋은 경우가 많습니다.

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
26
const someThingFetch = () => {
  // 자유 변수
  const _cache: { [id: string]: string } = {};

  // 클로저
  const fetchUser = (id: string) => {
    if (id in _cache) return _cache[id];

    return fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
      .then((res) => res.json())
      .then((res) => {
        _cache[id] = res.name;

        return res.name;
      });
  };

  return { fetchUser };
};

const { fetchUser } = someThingFetch();

// (4) Error 'string | Promise<any>' 형식에 'then' 속성이 없습니다. 'string' 형식에 'then' 속성이 없습니다.
fetchUser("1").then(console.log);
fetchUser("2").then(console.log);
fetchUser("1").then(console.log);

위 예시의 경우 캐싱하는 기능을 만들어서 같은 요청이 있는 경우에는 다시 네트워크 요청을 하지 않도록 구현한 예시입니다.
코드만 읽어봤을 때는 문제가 없어보이지만 fetchUser()의 리턴 값의 타입이 동일하지 않은 문제가 있습니다.
이미 캐싱된 데이터는 string 그게 아니면 Promise<any>를 반환합니다.
따라서 (4)에서 string 경우에 .then()을 쓸 수 없으니 오류가 발생합니다.
( 참고로 위의 예시의 캐싱기능은 정상적으로 동작하지 않습니다. ( 데이터를 요청해서 패치하고 있는 경우에는 중복된 요청을 보냄 ) )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const someThingFetch = () => {
  // 자유 변수
  const _cache: { [id: string]: string } = {};

  // 클로저
  const fetchUser = (id: string): Promise<any> => {
    // (5) Error 'string' 형식은 'Promise<any>' 형식에 할당할 수 없습니다.
    if (id in _cache) return _cache[id];

    return fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
      .then((res) => res.json())
      .then((res) => {
        _cache[id] = res.name;

        return res.name;
      });
  };

  return { fetchUser };
};

(5)처럼 반환 타입을 명시해주면 사용하는 부분이 아닌 반환하는 부분에서 타입체크를 해줍니다.
( 일반적으로 Promise를 반환한다면 async를 붙여주는 것이 좋습니다. )

🎊 Item 19 결론

  1. 일반적으로 타입 추론이 되는 경우에는 명시적으로 타입을 작성하지 말기
  2. 함수의 경우에는 함수 시그니처 타입을 사용하는 것이 좋음
  3. 정해진 타입이 있는 특별한 경우에 명시적으로 타입을 정하기

📌 Item 20 ( 다른 타입에는 다른 변수 사용하기 )

JavaScriptTypeScript의 다른 점중에 하나가 일반적으로 하나의 변수에 다른 타입을 사용할 수 없다는 것입니다.

1
2
3
4
5
// (1)
let v = 10;

// (2) Error: 'string' 형식은 'number' 형식에 할당할 수 없습니다.
v = "string";

위 코드가 TypeScript에서는 오류가 발생합니다.
v라는 변수는 (1)에서 number로 추론되었기 때문에 (2)에서는 오류가 발생하죠.

하지만 코드를 작성하다보면 두 가지의 타입이 모두 만족하는 변수가 필요한 경우도 있습니다.

1
2
3
4
5
// (3)
let v: number | string = 10;

// (4)
v = "string";

그러면 (3)처럼 사용하면 문제없이 동작합니다.
위와 같은 예시를 아래처럼 사용해볼 수 있습니다.
물론 문제없이 동작합니다.

1
2
3
4
5
6
7
8
declare function fetchProduct(id: string): void;
declare function fetchProductBySerialNumber(id: number): void;

let id: string | number = "123456";
fetchProduct(id);

id = 123456;
fetchProductBySerialNumber(id);

위 코드는 간단해서 쉽게 알아볼 수 있지만, 복잡한 코드라면 개발자가 코드를 읽을 때마다 현재 id의 타입에 대해 생각해야합니다.
동작에는 문제가 없지만 아래처럼 작성하는 것이 더 명시적이고 가독성도 좋습니다.
물론 변수가 하나 더 생기지만 제 기준에는 가독성이 좋아진다면 조금은 비효율적인 코드를 작성해도 상관없다고 생각하는 편입니다.

1
2
3
4
5
6
7
8
declare function fetchProduct(id: string): void;
declare function fetchProductBySerialNumber(id: number): void;

const id = "123456";
fetchProduct(id);

const serial = 123456;
fetchProductBySerialNumber(serial);

🎊 Item 20 결론

  1. 변수는 바뀔 수 있지만, 타입은 일반적으로 바뀌지 않습니다.
  2. 타입이 다른 값을 이용할 때는 같은 변수를 사용하기 보다는 각각의 변수에 다른 타입을 적용해서 사용하는 것이 좋습니다.

📌 Item 21 ( 타입 넓히기 )

JavaScript는 런타임에 변수가 하나의 값을 가집니다.
그리고 TypeScript는 컴파일러가 동작하는 시점에 그 변수가 가질 수 있는 값들의 집합인 타입을 가집니다.

이게 무슨 말이냐면 변수를 초기화할 때 타입을 명시해주지 않으면 초기화된 값을 보고 이 변수가 가질 수 있는 타입들의 집합을 유추해야한다는 의미입니다.

1
2
// (1)
let v = 10;

(1)과 같은 경우에는 let10이라는 값을 보고 vnumber라고 추론하는 것입니다.
이런 과정을 타입 넓히기라고 합니다.

0️⃣ TypeScript의 타입 추론 - 원시 타입

겉으로는 아래 코드가 전혀 문제가 없어보입니다.
하지만 실제로 작성해보면 (3)과 같은 에러가 발생하고 이는 (2)의 타입 넓히기 동작에 의해서 발생한 문제입니다.
사실 문제라기 보다는 변수를 선언할 때 확실하게 타입을 좁히지 않아서 그렇습니다.( const로 선언하지 않음 )
제가 초기화를 "x"로 했지만 TypeScript는 중간에 어떤 일이 일어나서 값이 바뀔 수 있다고 판단하기 때문에 getComponent()에 넘겨준 x의 값을 "x"라고 확신할 수 없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Vector3 = {
  x: number;
  y: number;
  z: number;
};
type GetComponent = (vector: Vector3, axis: "x" | "y" | "z") => number;

const getComponent: GetComponent = (vector, axis) => vector[axis];

// (2)
let x = "x";
const vec = { x: 1, y: 2, z: 3 };

// (3) 'string' 형식의 인수는 '"x" | "y" | "z"' 형식의 매개 변수에 할당될 수 없습니다.
getComponent(vec, x);

TypeScript는 항상 명확성과 유연성 사이의 균형을 유지하기 위해 노력해서 타입을 결정합니다.
(2)에서 가장 유연하게 타입을 정하면 any가 될 것이고, 가장 명확하게 타입을 정하면 "x"가 됩니다.
그 사이의 간극이 최대한 줄이고 개발자의 입장에서 어떤 타입을 의도한 것인지를 최대한 추측해서 가장 합리적인 타입을 부여합니다.
( any로하면 너무 유연해지고, "x"라면 변수의 수정이 불가능하니 그 사이의 타협점으로 string을 선택했습니다. )

1️⃣ TypeScript의 타입 추론 - 객체 타입

객체의 경우에도 넓게는 [key: string]: any, 좁게는 readonly name: string처럼 평가될 수 있겠지만 일반적으로는 let이 변수를 평가하듯이 평가됩니다.
따라서 champion타입은 (4)와 같이 평가가 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const champion = {
  name: "Aatrox",
  speed: 345,
  damage: true,
};

/**
 * (4)
 * {
 *   name: string;
 *   speed: number;
 *   damage: boolean;
 * }
 */

따라서 객체를 생성할 때는 나눠서가 아닌 한번에 생성하는 것이 맞습니다.

1
2
3
4
5
6
7
8
const champion = {
  name: "Aatrox",
  speed: 345,
  damage: true,
};

// '{ name: string; speed: number; damage: boolean; }' 형식에 'line' 속성이 없습니다.
champion.line = "top";

객체의 타입이 정해져있다면 아래와 같이 타입을 생성하고 명시하면 실수를 사전에 방지할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
type Champion = {
  name: string;
  speed: number;
  damage: boolean;
  line: "top" | "jug" | "mid" | "ad" | "sup";
};

// 'line' 속성이 '{ name: string; speed: number; damage: true; }' 형식에 없지만 'Champion' 형식에서 필수입니다.
const champion: Champion = {
  name: "Aatrox",
  speed: 345,
  damage: true,
};

2️⃣ 객체 타입 단언

만약 타입을 좁히고 싶다면 타입 단언을 사용하는 것이 좋습니다.( as const )

1
2
3
4
5
6
7
8
const champion = {
  name: "Aatrox",
  speed: 345 as const,
  damage: true,
};

// '350' 형식은 '345' 형식에 할당할 수 없습니다.
champion.speed = 350;

혹은 아래와 같이 객체 전체의 타입을 단언할 수 있습니다.

1
2
3
4
5
6
7
8
9
const champion = {
  name: "Aatrox",
  speed: 345,
  damage: true,
} as const;

// 읽기 전용 속성이므로 'speed'에 할당할 수 없습니다.
champion.speed = 350;
champion.damage = false;

🎊 Item 21 결론

  1. TypeScript가 넓히기를 통해 상수를 추론하는 방법에 대해 이해해야 합니다.
  2. 동작에 영향을 줄 수 있는 방법인 const, as const, 타입 구문, 문맥 등에 익숙해져야 합니다.

📌 Item 22 ( 타입 좁히기 )

0️⃣ 일반적인 타입 좁히기

if, instanceof, Array.isArray()등의 많은 방법을 이용하면 타입체커에게 타입에 대한 확신을 줄 수 있습니다.

1
2
3
4
5
6
7
8
9
const $button = document.querySelector(".button");

if ($button !== null) {
  $button; // Element
}

if ($button instanceof HTMLButtonElement) {
  $button; // HTMLButtonElement
}

1️⃣ 잘못된 타입 좁히기

(1)의 경우 typeof null === "object"이기 때문이고, (2)의 경우 ""0falsy한 값이기 때문에 잘못된 타입 좁히기입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
const $button = document.querySelector(".button");

// (1)
if (typeof $button === "object") {
  $button; // Element | null
}

const foo = (x?: number | string | null) => {
  // (2)
  if (!x) {
    x; // string | number | null | undefined
  }
};

2️⃣ 태그/구별된 유니온

타입을 좁히는 방법 중 하나는 명시적으로 태그를 붙이는 것입니다.
아래 코드로 예를 들어보자면 position이라는 태그를 만들어서 position을 기준으로 타입이 확정되도록 구현한 예시입니다.

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
type Aatrox = {
  position: "top";
  skill: string;
};
type Akali = {
  position: "mid";
  speed: number;
};
type Champion = Aatrox | Akali;

const logChampion = (champion: Champion) => {
  switch (champion.position) {
    case "top":
      // 여기서는 "Aatrox" 타입으로 확정
      console.log(champion.skill);
      break;
    case "mid":
      // 여기서는 "Akali" 타입으로 확정
      console.log(champion.speed);
      break;

    default:
      break;
  }
};

3️⃣ 사용자 정의 타입 가드

타입 체커에게 매개변수의 타입을 좁힐 수 있음을 명시적으로 알려주는 방법입니다.

1
2
3
4
5
6
7
8
9
10
// 짝수는 문자, 홀수는 숫자 ( candidate: (string | number)[] )
const candidate = Array(20)
  .fill(null)
  .map((v, i) => (i % 2 === 0 ? i + "" : i));

// (3) numbers: (string | number)[]
const odd = candidate.filter((v) => typeof v === "number");

// (4) filteredOdd: number[]
const filteredOdd = candidate.filter((v): v is number => typeof v === "number");

대부분 (3)의 경우에는 .filter()를 이용해서 숫자만 걸러냈으니 number[]이라는 타입이라고 생각합니다.
하지만 TypeScript에서는 우리가 (3)을 통해 무엇을 했는지 인지하지 못하기 때문에 명시적으로 “어떤 작업을 했어 그래서 리턴 타입이 어떤 타입이야”라고 알려줘야 합니다.
그때 사용하는 방법이 사용자 정의 타입 가이드입니다.
(4)처럼 파라미터 is 타입형태로 입력하면 타입 체커에게 타입에 대한 힌트를 줄 수 있습니다.

🎊 Item 22 결론

  1. 분기문 외에도 여러 종류의 타입 좁히기 방법에 대해 이해하기
  2. 태그/구별된 유니온과 사용자 정의 타입 가드의 사용법과 타입 좁히기에 대한 이해하기

📌 Item 23 ( 한꺼번에 객체 생성하기 )

TypeScript에서 객체를 생성하는 경우에는 한번에 정의하는 것이 좋습니다.

0️⃣ 객체 생성하기

1
2
3
4
5
6
7
8
9
10
11
// (1)
const myChampion = {
  name: "Aatrox",
  age: 10,
  damage: true,
};

// (2)
const myChampion2 = {};
// Error: '{}' 형식에 'name' 속성이 없습니다.
myChampion2.name = "Jayce";

(2)처럼 사용하게 되면 오류가 발생하게 됩니다.
{}인 값으로 추론되기 때문이죠.
이런 문제를 해결하기 위해서는 애초에 나눠서 한번에 선언하는 것이 좋지만 아래처럼 해결할 수 있습니다.

1
2
3
4
5
6
// (3) Error: '{}' 형식에 'Champion' 형식의 name, age, damage 속성이 없습니다.
const champion3: Champion = {};

// (4)
const champion4 = {} as Champion;
champion4.name = "Poppy";

1️⃣ 객체 합치기

위에서 봤듯이 이왕이면 객체를 한번에 생성하는 것이 좋고 분리해야 한다면 타입 단언문(as)을 사용하는 것이 바람직합니다.

하지만 객체를 합쳐야 하는 경우는 어떻게 할까요?

1
2
3
4
5
6
7
8
9
10
11
const champion = {};

const myChampionName = { name: "Aatrox" };

const myChampionAge = { age: 10 };

// (1)
Object.assign(champion, myChampionName, myChampionAge);

// (2) Error: '{}' 형식에 'name' 속성이 없습니다.
console.log(champion.name);

(1)과 같이 Object.assign()을 사용하는 경우에는 실제로는 동작하지만 TypeScript에서 타입을 추측하지 못합니다.
따라서 (2)에서도 에러가 발생하죠.

1
2
3
4
5
6
7
8
const myChampionName = { name: "Aatrox" };

const myChampionAge = { age: 10 };

// (3)
const champion = { ...myChampionName, ...myChampionAge };

console.log(champion.name);

(3)처럼 spread opeator를 이용해서 객체를 생성하면 타입 체커도 이해할 수 있고 개발자도 이해하기 쉽게 객체를 생성할 수 있습니다.

2️⃣ 조건부 속성을 갖는 객체

교재에서는 (4)처럼 선언하면 (5)처럼 타입이 결정되기 때문에 (7)에서 오류가 난다고 했습니다.
하지만 실제로 해보니 (6)처럼 타입이 결정돼서 오류가 나지 않습니다.
아마 버전이 업데이트되면서 바뀐 부분이 아닐까 생각합니다.

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
declare let isBoolean: boolean;

const myName = {
  name: "Aatrox",
};

const champion = {
  name: "Aatrox",
  ...(isBoolean && { age: 10 }),
};
/**
 * {
 *   age?: number | undefined;
 *   name: string;
 * }
 */

// (4)
const champion2 = {
  name: "Poppy",
  ...(isBoolean && { age: 13, position: "top" }),
};
/**
 * (5)
 * {
 *   name: string;
 * } | {
 *   name: string;
 *   age: number;
 *   position: string
 * }
 * 
 * (6)
 * {
 *   name: string;
 *   age?: number | undefined;
 *   position?: string | undefined;
 * }
 */

// (7) 오류 안남
champion2.position;

🎊 Item 23 결론

  1. 속성을 제각각 추가하지 말고 한번에 추가하는 것이 더 좋고, 안전하게 생성하려면 spread opeator를 사용하는 것이 좋습니다.
  2. 객체에 조건부로 속성을 추가하는 방법을 익히기

📌 Item 24 ( 일관성 있는 별칭 사용하기 )

객체를 사용하는 경우에는 일관성있게 같은 이름으로 사용해야합니다.

1
2
3
4
5
6
7
8
9
10
11
type Champion = {
  name: string;
  position?: string[];
  speed: number;
};

const champion: Champion = {
  name: "Aatrox",
  position: ["top", "mid"],
  speed: 345,
};

0️⃣ 별칭 사용하기

1
2
3
4
5
6
7
8
9
10
const isTop = (champion: Champion) => {
  // (1)
  const line = champion.position;

  // (2)
  if (champion.position) {
    // (3) Error: 'line'은(는) 'undefined'일 수 있습니다.
    console.log(...line);
  }
};

(1)에서는 다른 이름으로 뽑아내고, (2)에서는 본래 이름으로 비교를 했기 때문에 (3)에서 타입을 확정지을 수 없어 오류가 발생하게 됩니다.

1
2
3
4
5
6
7
8
9
10
const isTop = (champion: Champion) => {
  // (4)
  const line = champion.position;

  // (5)
  if (line) {
    // (6) Error: 'line'은(는) 'undefined'일 수 있습니다.
    console.log(...line);
  }
};

(5)처럼 뽑아낸 별칭으로 비교를 하는 것이 맞습니다.
물론 위 예시는 정상적으로 동작하지만, 최선은 아니라고 생각합니다.
왜냐하면 변수의 이름이 일관성있지 않기 때문이죠.( lineposition )

1️⃣ 비구조화를 이용한 별칭

(7)처럼 비구조화 할당을 이용하면 별칭의 이름도 일관성 있고, 가독성도 더 좋습니다.

1
2
3
4
5
6
7
8
const isTop = (champion: Champion) => {
  // (7)
  const { position } = champion;

  if (position) {
    console.log(...position);
  }
};

2️⃣ 객체에 대한 주의사항

아래의 예시에서 (8)은 정상적으로 동작함을 확신할 수 있습니다.
하지만 (9)에서 원본에 영향을 끼칠 수 있기 때문에 (10)에서 문제가 발생할 수 있고, 그 경우는 타입 체커가 확인해주지 않습니다.
따라서 func()순수함수임을 확신할 수 없다면 아래와 같이 사용하는 것은 위험합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
declare function func(champion: Champion): void;

const isTop = (champion: Champion) => {
  const { position } = champion;

  if (position) {
    // (8)
    console.log(...position);
    // (9)
    func(champion);
    // (10)
    console.log(...position);
  }
};

🎊 Item 24 결론

  1. 별칭은 일관되게 사용하고 되도록이면 비구조화 할당을 사용하기
  2. 함수의 호출로 인해 객체의 타입이 변경되는 것은 타입 체커가 확인해주지 않으므로 주의하기

📌 Item 25 ( 비동기 코드에는 콜백 대신 async 함수 사용하기 )

프로미스에 대한 학습이 부족하다면 비동기와 콜백/프로미스/async/await을 읽어보시면 도움이 됩니다.

ES6 이후에는 promiseasync/await을 이용해서 비동기를 처리합니다.
훨씬 직관적이고 효율적이기 때문입니다.

그리고 TypeScript 컴파일러가 async/await이 동작하도록 정교한 변환을 수행하기 때문에 런타임 즉, 실행에 관계없이 무분별하게 사용해도 됩니다.

0️⃣ Promise.race() 활용

이 부분은 JavaScript를 사용할 때도 몰랐던 활용법인데 교재에 나와있있고 유용하게 사용할 수 있는 방법인 것 같아서 정리해봅니다.

1
2
3
4
5
6
7
const timeout = (ms): Promise<never> => {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject("timeout"), ms);
  });
};

const fetchWithTimeout = async (url: string, ms: number) => Promise.race([fetch(url), timeout(ms)]);

정해진 시간안에 응답이 오지 않으면 timeoutreject하는 코드입니다.
솔직히 “Promise.race()를 보고 이거를 어디다 쓰지?”라는 생각을 했었는데 이렇게 활용할 수 있다는 것에 놀랐습니다.

1️⃣ Promise를 리턴하는 경우 async 사용하기

Promise를 리턴하는 함수는 async 키워드를 사용하는 것이 실수를 줄일 수 있어서 좋습니다.

1
2
3
4
5
// (1)
const func1 = () => Promise.resolve(1);

// (2)
const func2 = async () => 1;

(1)(2)의 경우 모두 Promise를 리턴하지만 (1)의 경우는 명시적으로 작성해줘야 하기 때문에 개발자에 의한 실수가 포함될 수 있습니다.
하지만 (2)는 굳이 작성하지 않아도 무조건 Promise로 리턴되기 때문에 개발자 입장에서는 실수를 고려할 필요 없이 반드시 Promise를 리턴한다고 판단할 수 있습니다.
( 구체적인 예시 - Item 19 )

1️⃣ 동기와 비동기 혼용하지 말기

아래는 동기와 비동기를 혼용한 예시입니다.
저도 읽고 수정하면서 이게 맞는지 생각을 많이 했는데 결론적으로 하고 싶은 말은 가독성도 안 좋고 동작 방식도 확실하지 않기 때문에 이런 코드는 그냥 사용하지 않는 것이 좋습니다.
( 가끔은 어떤 패턴이 있으면 그것보다 조금 더 복잡해지는 다른 방법을 생각해보고는 합니다. 그래서 그 방법이 성공했고 정상적으로 동작 하더라도 그 복잡함을 이해하기 보다는 이미 정해지고 만들어진 방법대로만 사용하는 것이 모두를 위해 좋습니다. ( 확실히 더 좋은 방법이고 근거가 있다면 그 방법을 사용하는 것도 좋을 것 같음 ) ( 그냥 개인적인 생각입니다. ) )

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
26
// 교재의 예시를 조금 수정했습니다. ( https://github.com/danvk/effective-typescript/blob/master/samples/ch03-inference/item-25-use-async-await/use-async-await-12.ts )

function fetchURL(url: string, cb: (response: any) => void) {
  fetch(url)
    .then((res) => res.json())
    .then((res) => cb(res));
}

const _cache: { [url: string]: any } = {};
function fetchWithCache(url: string, callback: (response: any) => void) {
  if (url in _cache) {
    callback(_cache[url]);
  } else {
    fetchURL(url, (response) => {
      _cache[url] = response;
      callback(response);
    });
  }
}
let requestStatus: "loading" | "success" | "error";
function getUser(userId: string) {
  fetchWithCache(`/user/${userId}`, (response: any) => {
    requestStatus = "success";
  });
  requestStatus = "loading";
}

결론은 위처럼 사용하지 말고 비동기가 있다면 비동기만으로 구현하는 것이 더 좋습니다.
( 특히 async/await을 사용하면 동기적으로 코드를 읽을 수 있어서 훨씬 좋습니다. )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const _cache: { [url: string]: string } = {};
async function fetchWithCache(url: string) {
  if (url in _cache) {
    return _cache[url];
  }
  const response = await fetch(url);
  const text = await response.text();
  _cache[url] = text;
  return text;
}

let requestStatus: "loading" | "success" | "error";
async function getUser(userId: string) {
  requestStatus = "loading";
  const profile = await fetchWithCache(`/user/${userId}`);
  requestStatus = "success";
}

위 코드만 봐도 누가 설명해주지 않아도 이해하기 쉬운 좋은 코드가 되었습니다.

🎊 Item 25 결론

  1. TypeScript에서 Promise관련 타입 추론을 제대로 해주기 때문에 적극적으로 사용하기 ( 콜백 방식 비추천 )
  2. 특수한 목적이 있지 않다면 async/await을 사용하기 ( Promise반환 함수라면 async 사용 권장 )

📌 Item 26 ( 타입 추론에 문맥이 어떻게 사용되는지 이해하기 )

0️⃣ 원시 값 타입 추론

(1)에서 string으로 추론되기 때문에 에러가 발생합니다.

1
2
3
4
5
6
7
8
9
10
type Position = "Top" | "Jug" | "Mid" | "Ad" | "Sup";

declare function func(position: Position): void;

func("Top");
// (1)
let position = "Top";

// Error: 'string' 형식의 인수는 'Position' 형식의 매개 변수에 할당될 수 없습니다.
func(position);

두 가지 방법으로 해결할 수 있습니다.

  1. 타입 선언 시 제한((2))
  2. const로 선언해서 타입 추론 좁히기 ((3))
1
2
3
4
5
// (2)
const position = "Top";

// (3)
let position: Position = "Top";

1️⃣ 튜플 타입 추론

배열에 const로는 단지 주솟값을 갖는 참조에 대한 변경 불가능이라는 의미밖에 부여하지 않습니다.
즉, tuple의 변경이 불가능하고 그 내부는 변경이 가능하다는 의미죠.
따라서 number[]로 추론되고 (4)에서 더 작은 범위에 더 큰 범위의 타입을 부여하기 때문에 오류가 나게 됩니다.

1
2
3
4
5
6
declare function panTo(where: [number, number]): void;

const tuple = [1, 2];

// (4) Error: 'number[]' 형식의 인수는 '[number, number]' 형식의 매개 변수에 할당될 수 없습니다.
panTo(tuple);

해결 방법은 두 가지 있습니다.

1. 타입 단언(as const) 사용

단, 이 방법을 사용하려면 함수 시그니처도 수정((5))해야합니다.

1
2
3
4
5
6
// (5)
declare function panTo(where: readonly [number, number]): void;

const tuple = [1, 2] as const;

panTo(tuple);

그리고 아래처럼 잘못 작성했다면 선언부분이 아닌 호출 부분에서 오류가 발생합니다.

1
2
3
4
5
6
declare function panTo(where: readonly [number, number]): void;

const tuple = [1, 2, 3] as const;

// 'readonly [1, 2, 3]' 형식의 인수는 'readonly [number, number]' 형식의 매개 변수에 할당될 수 없습니다.
panTo(tuple);

2. 타입 선언 제공

명시적으로 tuple이 어떤 타입이라고 타입 체커에게 알려주면 오류가 발생하지 않습니다.

1
2
3
4
5
declare function panTo(where: [number, number]): void;

const tuple: [number, number] = [1, 2];

panTo(tuple);

2️⃣ 객체 타입 추론

객체의 경우에도 튜플처럼 주솟값을 갖는 참조에 대한 변경 불가능이라는 의미를 갖기 때문에 내부의 property는 일반적으로 let으로 선언한 변수처럼 타입 부여((6))합니다.

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
26
27
28
29
30
31
32
33
const champion = {
  name: "Aatrox",
  speed: 345,
};
/**
 * (6)
 * {
 *   name: string;
 *   speed: number;
 * }
 */

const champion = {
  name: "Aatrox" as const,
  speed: 345,
};
/**
 * {
 *   name: "Aatrox";
 *   speed: number;
 * }
 */

const champion = {
  name: "Aatrox",
  speed: 345,
} as const;
/**
 * {
 *   readonly name: "Aatrox";
 *   readonly speed: 345;
 * }
 */

🎊 Item 26 결론

  1. 별도의 변수로 뽑아서 사용한다면 타입 선언을 추가하는 방법 고려하기
  2. 정말 상수라면 타입 단언(as const)을 사용하고, 타입 단언에서 문제가 있다면 사용한 곳에서 발생하는 것을 인지하기

📌 Item 27 ( 함수형 기법과 라이브러리로 타입 흐름 유지하기 )

TypeScript에서 어떤 작업을 할때는 절차형, 함수형, 라이브러리 사용 중에서는 라이브러리를 사용하는 것이 가장 좋은 방법입니다.

0️⃣ 배열 메서드 vs 라이브러리(lodash)

같은 작업을 TypeScript로 한다면 서드 파티 라이브러리를 사용하는 것이 무조건 좋다고 합니다.
( 제가 lodash를 거의 안 써봐서 그런지 괜히 더 찾아보고 이해하는 과정이 필요해서 (2)처럼 reduce를 쓰는 게 개인적으로는 더 좋아보이네요. )

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
26
27
28
29
30
31
32
33
34
35
36
37
38
// 데이터가 채워져 있다고 가정 ( 행("\n")과 열(",") )
const csvData = "";

// 행끼리 구분
const rawRows = csvData.split("\n");

// 헤더들 분리
const headers = rawRows[0].split(",");

// (1) 절차형: 헤더 제외한 모든 행 반복 ( forEach )
const rows1 = rawRows.slice(1).map((rowStr) => {
  // 각 행에 해당하는 열에 있는 데이터를 담을 객체
  const row = {};

  // 각 행의 각 열을 분리해서 반복
  rowStr.split(",").forEach((v, j) => {
    // 각 행의 각 열을 추가
    row[headers[j]] = v;
  });

  return row;
});

// (2) 함수형: 헤더 제외한 모든 행 반복 ( reduce )
const rows2 = rawRows
  .slice(1)
  .map((rowStr) =>
    rowStr.split(",").reduce((row, v, i) => {
      row[headers[i]] = v;
      return row;
    }, {})
  );

// (3) 라이브러리: 헤더 제외한 모든 행 반복 ( "lodash"의 "zip()" )
import _ from "lodash";
const rows3 = rawRows
  .slice(1)
  .map((rowStr) => _.zipObject(headers, rowStr.split(",")));

1️⃣ 배열 평탄화

TypeScript에서 여러 배열들을 결합해서 하나의 배열을 만드는 경우 Array.prototype.flat()을 사용((2))하는 것이 좋습니다.
flat()을 사용하지 않으면 코드도 길어지고, 타입 추론도 명시적으로 작성((1))해줘야하기 때문에 특별한 이유가 없다면 flat()이 좋네요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Player = {
  name: string;
  team: string;
  salary: number;
};
declare const rosters: { [team: string]: Player[] };

// (1)
let allPlayers1: Player[] = [];
for (const players of Object.values(rosters)) {
  allPlayers1 = allPlayers1.concat(players);
}

// (2) allPlayers2: Player[]
const allPlayers2 = Object.values(rosters).flat();

🎊 Item 27 결론

  1. 타입 흐름을 개선하고, 가독성을 높이고, 명시적 타입 구문을 줄이기 위해서는 라이브러리를 사용하는 것이 좋습니다.

라고 적혀있는데 아직 경험이 부족해서인지 굳이 라이브러리를 사용해야 하는지 의문이 드네요.
사용한다고 눈에 띄게 효율적인 것도 아닌 것 같고, 괜히 사용법을 더 익혀야하고 코드를 읽는 사람들도 사용법을 익혀야 하죠.
그리고 추론되지 않는 타입들은 명시적으로 작성하는 것도 코드를 읽는데 도움이 되지 않을까? 라는 생각을 해봤습니다.
아무튼 Item 27은 더 공부해보고 다시 읽어봐야겠네요. 😢

📮 레퍼런스

  1. « 이펙티브 타입스크립트 3장 » ( 댄 밴더캄 지음, 장원호 옮김, 인사이트, 2021 )
  2. 1-blue - 순수함수
  3. 1-blue - 비동기와 콜백/프로미스/async/await
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

배열 메서드 직접 구현

Framework & Library & API