이펙티브 타입스크립트 4장 ( Item 28 ~ 37 )
포스트
취소

이펙티브 타입스크립트 4장 ( Item 28 ~ 37 )

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

📖 4장 타입 설계

📌 Item 28 ( 유효한 상태만 표현하는 타입을 지향하기 )

0️⃣ 유효한 상태와 무효한 상태를 같이 처리하는 타입

웹 애플리케이션을 만든다고 가정하고 아래 타입은 웹 애플리케이션의 페이지 로딩에 대한 타입입니다.

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
// (1)
type State = {
  pageText: string;
  isLoading: boolean;
  error: string;
};

const changePage = async (state: State, newPage: string) => {
  state.isLoading = true;

  try {
    const response = await fetch(newPage);

    if (!response.ok) {
      throw new Error("failed fetch");
    }

    const text = await response.text();
    state.pageText = text;
  } catch (error) {
    state.error = "" + error;
  } finally {
    state.isLoading = false;
  }
};

(1)의 타입은 몇 가지 문제점이 있습니다.

  1. error가 발생한 이후 다음 실행부터는 페이지 로딩 시 에러를 보여줌 ( 즉, error 초기화 로직 없음 )
  2. 페이지를 이동하던 중에 다른 페이지로 이동하면 결과가 이상할 수 있음

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
27
28
29
30
31
32
33
34
type RequestPending = {
  state: "pending";
};
type RequestSuccess = {
  state: "success";
  pageText: string;
};
type RequestFailure = {
  state: "failure";
  error: string;
};
type RequestState = RequestPending | RequestSuccess | RequestFailure;
type State = {
  currentPage: string;
  request: { [page: string]: RequestState };
};

const chagePage = async (state: State, newPage: string) => {
  state.currentPage = newPage;
  state.request[newPage] = { state: "pending" };

  try {
    const response = await fetch(newPage);

    if (!response.ok) {
      throw new Error("failed fetch");
    }

    const pageText = await response.text();
    state.request[newPage] = { state: "success", pageText };
  } catch (error) {
    state.request[newPage] = { state: "failure", error: "" + error };
  }
};

만약 사용자가 페이지 로딩중에 다른 페이지로 이동하더라도 state.currentPage가 마지막으로 이동한 페이지의 값을 가리키기 때문에 어떤 페이지가 렌더링될지에 대한 모호함도 없습니다.

🎊 Item 28 결론

  1. 유효한 상태와 무효한 상태를 둘 다 표현하는 타입 지양하기 ( 코드는 짧을 수 있으나 혼란을 초래할 가능성이 높음 )
  2. 유요한 상태와 무효한 상태에 대한 타입을 나눠서 작성하기 ( 코드가 길어도 명확한 코드 )

📌 Item 29 ( 사용할 때는 너그럽게, 생성할 때는 엄격하게 )

카메라 위치를 지정하고 경계 박스의 뷰포트를 계산하는 방법에 대한 코드 예시입니다.
( 교재의 예시를 그대로 가져와 사용했습니다. )

0️⃣ 매개변수의 타입은 넓게, 반환 타입도 넓게

대부분의 타입을 옵셔널로 만들어서 유연하게 동작하도록 만들었습니다.

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
declare function calculateBoundingBox(
  f: Feature
): [number, number, number, number];
declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;
declare function setCamera(camera: CameraOptions): void;

type Feature = any;
type CameraOptions = {
  center?: LngLat;
  zoom?: number;
  bearing?: number;
  pitch?: number;
};
type LngLat =
  | { lng: number; lat: number }
  | { lon: number; lat: number }
  | [number, number];
type LngLatBounds =
  | { northeast: LngLat; southwest: LngLat }
  | [LngLat, LngLat]
  | [number, number, number, number];

function focusOnFeature(f: Feature) {
  const bounds = calculateBoundingBox(f);
  const camera = viewportForBounds(bounds);
  setCamera(camera);
  // (1) Error: 'LngLat | undefined' 형식에 'lat' 속성이 없습니다.
  const { center: { lat, lng }, zoom } = camera;
  zoom; // number | undefined
  window.location.search = `?v=@${lat},${lng}z${zoom}`;
}

반환 타입도 유연하게 선언해줬기 때문에 (1)에서 타입을 확정지을 수 없게 됩니다.

코드가 조금 길어지고 복잡해보일 수 있어도 엄격한 타입과 유연한 타입을 만드는 것이 좋습니다.
( 매개변수를 위한 타입 / 반환 값을 위한 타입 )

1️⃣ 매개변수의 타입은 넓게, 반환 타입은 좁게

가능하다면 제목처럼 매개변수의 타입은 알아서, 반환 타입은 좁게하는 것이 좋습니다.
반환 타입을 넓게 잡아버리면 (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
27
28
29
30
31
declare function calculateBoundingBox(
  f: Feature
): [number, number, number, number];
// (3)
declare function viewportForBounds(bounds: LngLatBounds): Camera;
declare function setCamera(camera: CameraOptions): void;

type Feature = any;
// (2)
type LngLat = { lng: number; lat: number }
type LngLatLike = LngLat | { lon: number; lat: number } | [number, number];

// (2)
type Camera = { center: LngLat; zoom: number; bearing: number; pitch: number }
type CameraOptions = Omit<Partial<Camera>, "center"> & {
  center?: LngLatLike;
};
type LngLatBounds =
  | { northeast: LngLatLike; southwest: LngLatLike }
  | [LngLatLike, LngLatLike]
  | [number, number, number, number];

function focusOnFeature(f: Feature) {
  const bounds = calculateBoundingBox(f);
  const camera = viewportForBounds(bounds);
  setCamera(camera);
  // (4)
  const { center: { lat, lng }, zoom } = camera;
  zoom; // number
  window.location.search = `?v=@${lat},${lng}z${zoom}`;
}

(2)에서 기본 타입을 만들고 가능하다면 유틸리티를 사용하고 아니라면 직접적으로 옵셔널 타입을 따로 만들었습니다.
타입의 정의가 늘어나는 단점이 있지만 타입은 읽기 어렵지 않기 때문에 타입이 늘어나는 것은 크게 문제가 되지 않습니다.

(3)에서는 확실한 타입을 리턴하게 해서 (4)에서 받아서 그대로 사용할 수 있게 됩니다.

🎊 Item 29 결론

  1. 매개변수 타입은 넓게, 반환 타입은 좁게
  2. 매개변수와 반환 타입을 위해 기본 타입을 만들고 가공해서 유연한 타입을 만드는 것이 좋음 ( 위 예시 (2) )

📌 Item 30 ( 문서에 타입 정보를 쓰지 않기 )

0️⃣ 주석으로 타입에 대한 설명 쓰지 않기

아래 코드는 함수에 대한 내용을 주석을 이용해서 설명한 코드입니다.

1
2
3
4
5
6
7
/**
 * (1) 배경색을 문자열로 반환
 * (2) 0 | 1개의 매개변수를 받음
 * (3) 매개변수가 없으면 표준 배경색을 반환
 * (4) 매개변수가 있으면 특정 페이지의 배경색을 반환
 */
const getBackground = (page?: string) => page === "login" ? { r: 100, g: 100, b: 100 } : { r: 0, g: 0, b: 0 };
  • 문제점
    1. 잘못된 내용
    2. 필요 없는 내용

(1), (2) 같은 내용은 함수 시그니처를 통해서 충분히 해석할 수 있는 내용인데도 주석으로 설명을 붙였습니다.
지금은 상관 없을 수 있으나 만약 getBackground()가 수정될 경우 주석을 수동으로 바꿔주는 행동을 잊을 수 있습니다.
그렇기 때문에 코드로 충분히 해석 가능한 내용을 주석으로 작성할 필요는 없습니다.

(3), (4) 또한 코드로 충분히 해석이 가능하기 때문에 주석을 작성하는 것은 좋지 않습니다.
혹시 함수, 매개변수, 리턴 값에 대한 설명이 필요하다면 JSDoc을 이용하는 것이 좋습니다.

1️⃣ 변수명 규칙

변수명에는 정해진 규칙이 있습니다.
여기서는 그런 정해진 규칙외에 사람과 사람의 소통을 위한 규칙을 설명하고자 합니다.

1
2
3
4
5
6
// (5)
const ageNum = 10;

// (6)
const timeMs = 1_000;
const temperatureC = 36.5;

(5)처럼 변수의 타입에 대한 내용을 변수명으로 붙이는 방법은 좋지 않습니다.
( typescript라서 명확하게 어떤 타입인지 확인할 수 있기 때문이죠. )

(6)처럼 단위같이 작성하지 않으면 확실하게 알 수 없는 것은 붙이면 좋습니다.
( time이라고하면 작성된 값을 보고 ms라고 추측을 하겠지만, 명확하지 않습니다. )

🎊 Item 30 결론

  1. 주석과 변수명에 타입 정보를 넣지 않기 ( 모순이 발생할 수 있음 )
  2. 타입이 명확하지 않다면 변수명에 단위 정보를 포함하는 것은 좋음

📌 Item 31 ( 타입 주변에 null값 배치하기 )

0️⃣ 전체에 명확한 타입 지정하기

아래 예시는 최댓값과 최솟값을 구하는 두 가지 예시가 있습니다.

extent1()는 부분적으로만((1)) / extent2()는 전체적으로((3)) falsy값을 체크했습니다.
그래서 (2)(4) 같은 결과가 나왔습니다.

(2)의 경우는 배열에 진짜와 가짜의 값이 섞여있기 때문에 사용하기 매우 불편합니다.
배열을 순회할 때마다 값이 존재하는지 여부를 체크하고 확정된 상태에서만 사용할 수 있습니다.
하지만 (4)는 배열 자체가 있거나 없거나이기 때문에 배열만 존재하는지 판단하면 내부의 값은 무조건 존재한다고 확정할 수 있습니다.

( AB로부터 비롯된 값이라면 Anull이 될 수 있다면 Bnull이 될 수 있고,
Anull이 될 수 없다면 Bnull이 될 수 없음 )

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
const extent1 = (numbers: number[]) => {
  let min;
  let max;

  for (const number of numbers) {
    // (1) "min"만 "falsy" 값 제외 ( "max"에 대한 힌트는 타입 체커에게 주지 않음 )
    if (!min) {
      min = number;
      max = number;
    } else {
      min = Math.min(min, number);
      // (2) max: number | undefined
      max = Math.max(number, max);
    }
  }

  // (2) (number | undefined)[]
  return [min, max];
};

const extent2 = (numbers: number[]) => {
  let result: [number, number] | null = null;

  for (const number of numbers) {
    // (3) 전체를 제외 혹은 전체에 값 할당
    if (!result) {
      result = [number, number];
    } else {
      result = [Math.min(result[0], number), Math.max(result[1], number)];
    }
  }

  // (4) [number, number] | null
  return result;
};

1️⃣ 클래스에는 확실한 값만 작성하기

아래처럼 클래스를 작성하면 클래스의 필드 값들인 userpostsnull인 경우가 존재하고 그 상황에 값을 사용할 수 있습니다.
즉, (5)를 통해서 초기화해줬지만 그 전에 이미 user라는 인스턴스는 존재하고, 인스턴스의 내부에 값들이 존재하지 않아도 마음대로 사용할 수 있죠.

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
type UserInfo = { name: string };
type Post = {};

declare function fetchUser(url: string): Promise<UserInfo>;
declare function fetchPosts(url: string): Promise<Post[]>;

class UserPosts {
  private user: UserInfo | null;
  private posts: Post[] | null;

  constructor() {
    this.user = null;
    this.posts = null;
  }

  async init(userId: string) {
    return Promise.all([
      async () => (this.user = await fetchUser("...특정 유저의 패치하는 URL")),
      async () => (this.posts = await fetchPosts("...게시글들을 패치하는 URL")),
    ]);
  }

  getName() {
    // (6)
    return this.user?.name;
  }
}

const user = new UserPosts();
// (5) 메서드를 실행하는 동안은 "user", "posts"가 존재하지 않음 즉, 불확실한 값
user.init("1");

// (7)
user.getName(); // undefined or 패치한 유저의 이름 ( 어떤 값인지 확정지을 수 없음 )

아래 예시의 (8)처럼 만들면 데이터가 존재하는 상황에서 인스턴스를 만들 수 있습니다.
따라서 클래스를 사용할 때((9)) 값의 존재에 대한 모호함이 사라져서 명확하게 사용할 수 있게 됩니다.

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
type UserInfo = { name: string };
type Post = {};

declare function fetchUser(url: string): Promise<UserInfo>;
declare function fetchPosts(url: string): Promise<Post[]>;

class UserPosts {
  private user: UserInfo;
  private posts: Post[];

  constructor(user: UserInfo, posts: Post[]) {
    this.user = user;
    this.posts = posts;
  }

  // 정적 메서드
  static async init(userId: string) {
    return Promise.all([
      fetchUser("...특정 유저의 패치하는 URL"),
      fetchPosts("...게시글들을 패치하는 URL"),
    ]);
  }

  getName() {
    return this.user.name;
  }
}

(async () => {
  // (8) 인스턴스 생성 전에 값부터 얻기
  const [myUser, myPosts] = await UserPosts.init("1");
  const user = new UserPosts(myUser, myPosts);

  // (9)
  user.getName();
})();

TMI: 이전에 TS로 간단한 마리오 웹 게임을 만들었는데 그때는 이런 내용을 몰라서 가끔 null이 포함된 필드가 있습니다… ( 나중에 수정해야겠네요… 😢 )

🎊 Item 31 결론

  1. null/undefined가 혼용되는 값을 만들지 않도록 노력하기
  2. 만약 null/undefined가 필연적으로 들어간다면 부분적으로 보다는 전체가 존재하느냐 아니냐로 구성하기
  3. 클래스를 만들 때는 필요한 값을 모두 생성한 채로 인스턴스를 만들기
  4. strictNullChecks는 그냥 필수로 켜두기

📌 Item 32 ( 유니온의 인터페이스보다는 인터페이스의 유니온 사용하기 )

Item 32의 예시에는 아래 6가지 타입이 있다고 가정하고 설명하겠습니다.

1
2
3
4
5
6
7
type FillLayout = {};
type LineLayout = {};
type PointLayout = {};

type FillPaint = {};
type LinePaint = {};
type PointPaint = {};

0️⃣ 유니온의 인터페이스

저희가 원하는 조합은 FillLayoutFillPaint같은 정해진 조합이 있는데 아래처럼 작성하면 조합이 마구잡이로 섞여도 typescript에서 아무런 조치를 취하지 않습니다.

1
2
3
4
5
// (1) 안 좋은 방법 ( 유니온의 인터페이스 ( 아무렇게나 조합됨 ) )
type BadLayer = {
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
};

1️⃣ 인터페이스의 유니온

위 방법을 해결하기 위한 방법입니다.
코드는 길어졌지만, 원하는 대로 명확하게 동작하기 때문에 더 좋은 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// (2) 타입 분리
type FillLayer = {
  layout: FillLayer;
  paint: FillPaint;
};
type LineLayer = {
  layout: LineLayer;
  paint: LinePaint;
};
type PointLayer = {
  layout: PointLayer;
  paint: PointPaint;
};

// (3) 좋은 방법 ( 인터페이스의 유니온 )
type GoodLayer = FillLayer | LineLayer | PointLayer;

2️⃣ 태그된 유니온과 조합

인터페이스의 유니온과 태그된 유니온 방법을 사용해서 명확하게 타입을 좁혀서 사용할 수 있습니다.

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
// (4) 타입 분리
type FillLayer = {
  type: "fill";
  layout: FillLayer;
  paint: FillPaint;
};
type LineLayer = {
  type: "line";
  layout: LineLayer;
  paint: LinePaint;
};
type PointLayer = {
  type: "point";
  layout: PointLayer;
  paint: PointPaint;
};

// (5) 좋은 방법 ( 인터페이스의 유니온 )
type TaggedLayer = FillLayer | LineLayer | PointLayer;

// (6) 태그된 유니온을 이용한 타입 좁히기
const drawLayer = (layer: TaggedLayer) => {
  switch (layer.type) {
    case "fill":
      // "FillLayer" 타입 확정
      break;
    case "line":
      // "LineLayer" 타입 확정
      break;
    case "point":
      // "PointLayer" 타입 확정
      break;
  }
};

3️⃣ 특정 속성 분리

만약 어떤 값들이 동시에 존재하거나 존재하지 않아야 하는 경우에는 하나의 객체로 묶어서 작성하는 것이 좋습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// (7) "HP"와 "MP"가 동시에 존재해야 한다고 가정

type BadChampion = {
  name: string;
  // (8) 각자가 독립적임
  HP?: number;
  MP?: number;
};

type GoodChampion = {
  name: string;
  // (9) 각자가 의존적
  stat: { HP?: number; MP?: number };
};

🎊 Item 32 결론

  1. 유니온의 인터페이스 보다는 인터페이스의 유니온 사용하기
  2. 제어 흐름을 위한 태그된 유니온 패턴은 가능하다면 활용하기

📌 Item 33 ( string 타입보다 더 구체적인 타입 사용하기 )

타입에는 최대한 구체적인(좁은) 타입을 사용하는 것이 좋습니다.

아래와 같은 타입이 있을 때는 releaseDatarecordingType는 더 구체적인 타입으로 정할 수 있습니다.

1
2
3
4
5
6
type Album = {
  artist: string;
  title: string;
  releaseData: string;
  recordingType: string;
};

0️⃣ 구체적인 타입

아래와 같이 사용하면 더 구체적인 타입을 타입체커에게 전달할 수 있습니다.

1
2
3
4
5
6
7
8
9
/** (1) 녹음 환경 */
type RecordingType = "sudio" | "live";

type Album = {
  artist: string;
  title: string;
  releaseData: Date;
  recordingType: RecordingType;
};
  1. RecordingType의 분리로 인해 다른 곳에서 활용 가능
  2. 분리된 타입에 주석으로 설명 가능 (1)
  3. keyof를 이용한 세밀한 속성 체크 가능

1️⃣ 함수의 매개변수 타입 제대로 지정하기

아래 예시는 매개변수 타입을 구체적으로 지정하는 방법에 대한 코드입니다.

교재를 읽기 전에는 (1)처럼 코드를 구성하고 (3)의 결과를 보고 어떻게 수정해야 하는지 모르고 넘어갔을 것 같습니다.
(1)(2)는 다른 타입을 추론하는지 알아보겠습니다.

(1)의 두 번째 인자는 keyof T의 타입을 갖습니다.
따라서 Tkey중에 어떤 것도 가능하기 때문에 타입 체커는 T[key]로 만들 수 있는 모든 타입에 대한 배열로 추론합니다.
하지만 (2)Tkey중에 하나의 타입을 제네릭으로 정했기 때문에 T의 특정 key에 해당하는 타입에 대한 배열로 추론합니다.

따라서 더 구체적인 타입으로 추론하게 됩니다.
( 근데 왜 (1)에서 RecordingType는 빠지는지 의문이네요.. )

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
const pluck1 = (records: any[], key: string) => {
  return records.map((r) => r[key]);
};

const pluck2 = <T>(records: T[], key: string) => {
  return records.map((r) => r[key]);
};

// (1) <T>(records: T[], key: keyof T) => T[keyof T][]
const pluck3 = <T>(records: T[], key: keyof T) => {
  return records.map((r) => r[key]);
};

// (2) <T, K extends keyof T>(records: T[], key: K) => T[K][]
const pluck4 = <T, K extends keyof T>(records: T[], key: K) => {
  return records.map((r) => r[key]);
};

declare const albums: Album[];

// any[]
const releaseDatas1 = pluck1(albums, "releaseData");

// any[]
const releaseDatas2 = pluck2(albums, "releaseData");

// (3) (string | Date)[]
const releaseDatas3 = pluck3(albums, "releaseData");

// (4) Date[]
const releaseDatas4 = pluck4(albums, "releaseData");

// ======
// (3) (string | Date)[]
const recordingType3 = pluck3(albums, "recordingType");
// (4) RecordingType[]
const recordingType4 = pluck4(albums, "recordingType");

2️⃣ 배열과 keyof의 타입 추론

string이나 number에 포함되는 타입이 있는 경우 덮어쓰고, 그게 아니면 구체적인 타입으로 지정됩니다.
그리고 (5)의 경우는 구체적인 타입(Gender, FootSize)이 있는데 왜 (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
type Gender = "T" | "F";
type FootSize = 250 | 255 | 260 | 265 | 270 | 275 | 280;

type Person1 = {
  name: string;
  age: number;
  gender: Gender;
  footSize: FootSize;
};
type Person2 = {
  // name: string;
  age: number;
  gender: Gender;
  footSize: FootSize;
};
type Person3 = {
  name: string;
  // age: number;
  gender: Gender;
  footSize: FootSize;
};
type Person4 = {
  // name: string;
  // age: number;
  gender: Gender;
  footSize: FootSize;
};

// number | string
type MyType1 = Person1[keyof Person1];
// (5) number | "T" | "F"
type MyType2 = Person2[keyof Person2];
// (5) string | 250 | 255 | 260 | 265 | 270 | 275 | 280
type MyType3 = Person3[keyof Person3];
// (6) Gender | FootSize
type MyType4 = Person4[keyof Person4];

🎊 Item 33 결론

  1. string보다는 더 구체적인 타입 지정 ( 문자열 리터럴에 유니온으로 타입 생성 )
  2. 객체의 속성 이름을 매개변수로 받는 경우 keyof T 혹은 템플릿으로 K extends keyof T와 같은 형식을 사용하면 더 구체적인 타입으로 추론됨

📌 Item 34 ( 부정확한 타입보다는 미완성 타입 만들기 )

TypeScript를 사용할 때는 타입 선언의 정밀도를 높이는 일에 주의를 기울여야 합니다.

좌표를 의미할 때는 (1)보다는 (2)를 사용해서 더 명확하게 표현하는 것이 좋습니다.
하지만 의도에 따라서 3개 이상의 좌표를 갖는 타입일 수 있고, 그렇다면 (1)처럼 사용하는 것이 더 맞는 선언이 되는 경우일 수 있습니다.

1
2
3
4
5
// (1)
type Coord1 = number[];

// (2)
type Coord2 = [number, number];

0️⃣ 의도와 다른 명확한 타입 작성

아래와 같은 타입을 작성했다고 가정해보겠습니다.
저라면 코드를 보고 Coord를 수정하고 싶다고 생각할 것 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Coord = number[];

type Point = {
  type: "Point";
  coordinates: Coord;
};
type LineString = {
  type: "LineString";
  coordinates: Coord[];
};
type Polygon = {
  type: "Point";
  coordinates: Coord[][];
};

type Geometry = Point | LineString | Polygon;

좌표는 튜플([number, number])로 선언하는 게 더 구체적이라고 생각해서 아래와 같이 변경할 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Coord = [number, number];

type Point = {
  type: "Point";
  coordinates: Coord;
};
type LineString = {
  type: "LineString";
  coordinates: Coord[];
};
type Polygon = {
  type: "Point";
  coordinates: Coord[][];
};

type Geometry = Point | LineString | Polygon;

하지만 이렇게 하면 문제가 발생할 수 있습니다.
앞에서 말했듯이 Coord가 두 개의 좌표만 사용할 것이라는 확신이 없습니다.
처음 작성의 의도는 그랬을지 몰라도 사용하면서 추가로 좌표가 필요해서 타입을 보고 더 넣어서 사용했을 수 있습니다.
그러면 코드를 수정하는 순간 기존에 사용하던 코드에 오류가 발생할 가능성이 높습니다.

1️⃣ 의도와 다른 상세한 타입과 부정확한 타입

Lisp라는 함수형 프로그래밍 언어에서 사용하는 방식을 TypeScript의 타입으로 만들어보는 예시입니다.

(3)에서는 값이 하나 더 들어갔기 때문에 오류가 발생해야 하고, (4)에서는 하지만 TypeScript에서는 오류를 찾지 못합니다.
다음으로 가면서 타입을 더 구체적으로 작성해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
type Expression1 = any;
type Expression2 = number | string | any[];

const tests: Expression2[] = [
  10,
  "red",
  true, // Error: 'boolean' 형식은 'Expression2' 형식에 할당할 수 없습니다.
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"], // (3) 정상 동작
  ["**", 2, 31], // (4) 정상 동작
  ["rgb", 255, 255, 255],
  ["rgb", 255, 255, 255, 0.5], // (3) 정상 동작
];

아래 예시는 조금 더 구체적으로 타입을 구현해서 문제를 하나 더 잡았지만 (5)의 문제를 바로잡지 못합니다.
( 물론 잘못된 동작은 모두 잡았고, 더 유연하게 동작하는 부분((5))이 문제입니다. )
다음으로 더 구체적으로 타입을 작성해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type FnName = "+" | "-" | "*" | "/" | ">" | "<" | "case" | "rgb";
type CallExpression = [FnName, ...any[]];
type Expression3 = number | string | CallExpression;

const tests2: Expression3[] = [
  10,
  "red",
  true, // Error: 'boolean' 형식은 'Expression3' 형식에 할당할 수 없습니다.
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"], // (5) 정상 동작
  ["**", 2, 31], // Error: '"**"' 형식은 'FnName' 형식에 할당할 수 없습니다.
  ["rgb", 255, 255, 255],
  ["rgb", 255, 255, 255, 0.5], // (5) 정상 동작
];

타입을 매우 구체적으로 지정해서 (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
type Expression4 = number | string | CallExpression2;
type CallExpression2 = MathCall | CaseCall | RGBCall;
type MathCall = {
  0: "+" | "-" | "*" | "/" | ">" | "<";
  1: Expression4;
  2: Expression4;
  length: 3;
};
type CaseCall = {
  0: "case";
  1: Expression4;
  2: Expression4;
  3: Expression4;
  length: 4 | 6 | 8 | 10 | 12 | 14 | 16;
};
type RGBCall = {
  0: "rgb";
  1: Expression4;
  2: Expression4;
  3: Expression4;
  length: 4;
};

const tests3: Expression4[] = [
  10,
  "red",
  true, // Error: 'boolean' 형식은 'Expression4' 형식에 할당할 수 없습니다.
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"], // (6) Error: '"case"' 형식은 '"rgb"' 형식에 할당할 수 없습니다.
  ["**", 2, 31], // Error: '"**"' 형식은 'FnName' 형식에 할당할 수 없습니다.
  ["rgb", 255, 255, 255],
  ["rgb", 255, 255, 255, 0.5], // (6) Error
];

더 구체적이고, 에러를 잘 잡아내지만 에러가 났을 때 의도와 다른 메세지를 표시해주기 때문에 개발 경험을 해치게 됩니다.
따라서 이것을 통해 하고 싶은 말은 너무 정확하게 만들려다가 오히려 부정확한 타입이 될 수 있기 때문에 차라리 미완성의 타입으로 남겨두는 것이 더 좋을 때가 있다는 의도를 전달하고 싶은 것 같습니다.

🎊 Item 34 결론

  1. 타입이 없는 것보다 잘못된 타입이 더 나쁨
  2. 정확한 타입 모델링이 어렵다면 미완성 타입으로 놔두는 것이 더 좋음

📌 Item 35 ( 데이터가 아닌, API와 명세를 보고 타입 만들기 )

주고 받는 데이터만으로 타입을 만들면 정확하지 않은 타입을 만들 가능성이 높습니다.
해당 타입이 옵셔널인지 어떤 상황에만 존재하는지 같은 상세한 내용은 알 수 없습니다.
따라서 데이터만 보고 만들기 보다는 명세를 보고 타입을 만드는 것이 가장 명확합니다.

또한 타입을 만들어서 제공해주는 라이브러리들을 사용한다면 제공해주는 타입을 활용하는 것이 가장 좋습니다.
사람의 실수도 없고, 타입이 변경된다면 그에 맞게 자동으로 업데이트되기 때문입니다.

0️⃣ prisma가 제공해주는 타입 사용하기

prismaORM으로 간단하게 DB와 연결하고 JS(TS)를 SQL로 바꿔주고 실행하는 역할을 합니다.

아래 예시는 개인 프로젝트에서 사용했던 코드입니다. ( 여기 참고 )
아래처럼 작성하고 prisma 명령어를 실행시켜주면 해당 모델에 맞는 DB의 테이블과 타입을 생성해서 제공해줍니다.

model Post {
  id Int @id @unique @default(autoincrement())
  idx String @db.VarChar(20)
  title String @db.VarChar(50)
  category PostCategory
  speech String @db.VarChar(300)
  like Int @default(0)
  hate Int @default(0)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  time String? @db.VarChar(12)
  episode Int?
  page Int?
  thumbnail String @db.VarChar(300)
}

아래와 같은 타입을 자동으로 생성해주고 그대로 사용하기만 하면 됩니다.
직접 테이블 명세를 보고 타입을 작성할 필요 없이 사용이 가능합니다. ( 실수할 위험도 없음 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export type Post = {
  id: number
  idx: string
  title: string
  category: PostCategory
  speech: string
  like: number
  hate: number
  createdAt: Date
  updatedAt: Date
  time: string | null
  episode: number | null
  page: number | null
  thumbnail: string
}

만약 타입을 조금 바꿔서 사용하고 싶다면 TypeScript Utility를 사용하면 기존 타입에 의존하면서 새로운 타입을 쉽게 만들 수 있습니다.
아래와 같이 기존 타입에 의존하게 되면 기존 타입이 변경되면 자동으로 SimplePost에도 그 변경사항이 적용되기 때문에 두 번 작업할 필요가 없어집니다.

1
2
3
4
5
6
7
8
9
10
11
12
type SimplePost = Omit<Post,"like" | "hate" | "createdAt" | "time" | "episode" | "page">;

/**
 * type SimplePost = {
 *   id: number;
 *   idx: string;
 *   title: string;
 *   speech: string;
 *   updatedAt: Date;
 *   thumbnail: string;
 * }
 */

🎊 Item 35 결론

  1. 데이터만으로는 모든 예외적인 경우를 파악할 수 없기 때문에 API 명세를 보고 타입을 만드는 것이 좋음
  2. 타입을 제공해준다면 기존 타입에 의존적이게 만드는 것이 좋음 ( 개인적인 생각 )

📌 Item 36 ( 해당 분야의 용어로 타입 이름 짓기 )

교재에서 제공해준 예시를 그대로 복사 붙여넣기 했습니다… 🙃

타입의 이름은 해당 분야에서 이미 존재하고 많이 사용해서 다듬어진 용어를 사용해야합니다.
또한 같은(유사한) 의미를 갖는 다른 이름을 사용하는 것을 지양해야합니다. ( 더 혼란에 빠질 수 있음 )

0️⃣ 잘못된 이름 짓기

1
2
3
4
5
6
7
8
9
10
11
interface Animal {
  name: string;
  endangered: boolean;
  habitat: string;
}

const leopard: Animal = {
  name: 'Snow Leopard',
  endangered: false,
  habitat: 'tundra',
};

1️⃣ 구체적인 이름 짓기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
interface Animal {
  commonName: string;
  genus: string;
  species: string;
  status: ConservationStatus;
  climates: KoppenClimate[];
}
type ConservationStatus = 'EX' | 'EW' | 'CR' | 'EN' | 'VU' | 'NT' | 'LC';
type KoppenClimate = |
  'Af' | 'Am' | 'As' | 'Aw' |
  'BSh' | 'BSk' | 'BWh' | 'BWk' |
  'Cfa' | 'Cfb' | 'Cfc' | 'Csa' | 'Csb' | 'Csc' | 'Cwa' | 'Cwb' | 'Cwc' |
  'Dfa' | 'Dfb' | 'Dfc' | 'Dfd' |
  'Dsa' | 'Dsb' | 'Dsc' | 'Dwa' | 'Dwb' | 'Dwc' | 'Dwd' |
  'EF' | 'ET';
const snowLeopard: Animal = {
  commonName: 'Snow Leopard',
  genus: 'Panthera',
  species: 'Uncia',
  status: 'VU',  // vulnerable
  climates: ['ET', 'EF', 'Dfd'],  // alpine or subalpine
};

여태까지 이름을 지을 때는 0️⃣처럼 간단하게 작성했었습니다.
같이 사용해본 적이 없고 혼자서 사용해서 스스로 모호함을 느껴본적이 없었습니다.

근데 교재를 읽어보고 두 가지의 예시를 비교하면서 생각보다 구체적으로 이름을 작성해야 한다는 것을 느꼈습니다.
솔직히 바로 저에게 변화가 있을 것 같지는 않지만, 이런 내용을 인지하고 있으면 앞으로 타입을 작성할 때도 유의하면서 조금씩 바뀌지 않을까 생각합니다.
( 그래도 string을 함부로 사용하진 않았고 필요하다면 특정 값만 들어가도록 타입을 나눠서 작성하긴 했습니다. 🙂 )

🎊 Item 36 결론

  1. 가독성을 높이고, 추상화 수준을 올리기 위해서 해당 분야의 용어를 사용하기
  2. 같은(유사한) 의미를 갖는 다른 이름을 사용하는 것을 지양하기

📌 Item 37 ( 공식 명칭에는 상표를 붙이기 )

구조적 타이핑에 의해서 가끔은 원치 않은 결과가 나오거나 코드가 실행될 수 있습니다.
( 간단하게 구조적 타이핑이란 타입의 일부분만 일치하면 통과하는 것을 의미합니다. )

아래 예시에서 f()Vector2D 타입을 매개변수로 받지만 (2)에서 타입 오류를 발생하지 않습니다.
TypeScript에서 구조적 타이핑(덕 타이핑)을 따르기 때문입니다.
어떻게 보면 유연하게 동작해서 이점만 있다고 생각할 수 있지만, 깊이 따지고 보면 타입이 맞지 않아서 오류가 나는 것이 정상적인 동작이라고 생각할 수 있습니다.
이런 구조적 타이핑에 의해서 발생할 수 있는 문제를 해결하기 위한 방법인 상표 기법에 대해서 알아보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
type Vector2D = { x: number; y: number };
type Vector3D = Vector2D & { z: number };

const f = (vector: Vector2D) => {};

const v2: Vector2D = { x: 0, y: 0 };
const v3: Vector3D = { x: 0, y: 0, z: 0 };

// (1)
f(v2);
// (2)
f(v3);

0️⃣ 상표 기법

상표 기법은 아래와 같이 사용하면 됩니다.

1
type Vector2D = { _brand: "2d", x: number; y: number };

속성이 하나가 늘어났지만 아래처럼 사용하면 타입을 구분할 수 있습니다.
이렇게만 보면 타입 좁히기의 태그/구별된 유니온과 유사한 방법이라고 생각합니다.

1
2
3
4
5
6
7
8
9
10
11
12
type Vector2D = { _brand: "2d"; x: number; y: number };
type Vector3D = Omit<Vector2D, "_brand"> & { _brand: "3d"; z: number };

const f = (vector: Vector2D) => {};

const v2: Vector2D = { _brand: "2d", x: 0, y: 0 };
const v3: Vector3D = { _brand: "3d", x: 0, y: 0, z: 0 };

// (1)
f(v2);
// (2)
f(v3);

1️⃣ 상표 기법과 사용자 정의 타입 가드 활용하기

아래 예시는 타입 체커에게 절대 경로를 판단하는 방법을 알려주는 예시입니다.
런타임에서는 절대 경로를 판단하기 쉽지만 타입 체커를 런타임 이전에만 실행되기 때문에 실행 경로를 알기 힘듭니다.
따라서 타입 체커가 활동하는 시점에 절대 경로인지 판단하는 방법에 대한 예시입니다.

먼저 (3)처럼 타입을 선언해서 상표를 붙입니다.
( 여기서 주의할 점은 (3)은 실제로는 존재할 수 없습니다. )

(4)를 이용해서 즉, 사용자 정의 타입 가드를 활용해서 존재하지 않는 타입이지만 타입 체커에게 존재한다고 명시적으로 알려줍니다.

따라서 (5)처럼 사용하면 정상적으로 동작하고 / (6)처럼 사용하면 타입 에러가 발생합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// (3) 구체적인 값이 존재할 수 없는 타입
type AbsolutePath = string & { _brand: "abs" };

// (4)
const isAbsolutePath = (path: string): path is AbsolutePath => path.startsWith("/");

const listAbsolutePath = (path: AbsolutePath) => {};

// const로 하면 안됨
let path = "";

// (5)
if (isAbsolutePath(path)) {
  listAbsolutePath(path);
}
// (6) Error: 'string' 형식의 인수는 'AbsolutePath' 형식의 매개 변수에 할당될 수 없습니다.
listAbsolutePath(path);

2️⃣ 상표 기법과 사용자 정의 타입 가드 활용하기 2

개념은 위와 같지만, 조금 다른 예시입니다.
( 위에서 설명했기 때문에 자세한 설명은 생략하겠습니다. )

정렬이 된 상태에서만 binarySearch가 가능합니다.
따라서 타입 체커에게 정렬이 되었는지 여부를 알려주기 위해 상표 기법을 사용한 예시입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type SortedList<T> = T[] & { _brand: "sorted" };

// 원
const isSorted = <T>(list: T[]): list is SortedList<T> => false;

const binarySearch = <T>(list: SortedList<T>, target: T) => {
  // ...
};

// const로 하면 안됨
let arr = [1, 2, 3, 4, 5];

if (isSorted(arr)) {
  // 정상 동작
  binarySearch(arr, 3);
}

// Error: '_brand' 속성이 'number[]' 형식에 없지만 '{ _brand: "sorted"; }' 형식에서 필수입니다.
binarySearch(arr, 3);

🎊 Item 37 결론

  1. 구조적 타이핑에 의해 값을 구분하지 못한다면 상표 기법을 사용하는 것을 고려하기
  2. 상표 기법은 타입 시스템에서 존재하지만 런타임에서 검사하는 것과 동일한 효과를 얻을 수 있음

📮 레퍼런스

  1. « 이펙티브 타입스크립트 4장 » ( 댄 밴더캄 지음, 장원호 옮김, 인사이트, 2021 )
  2. 1-blue - 이펙티브 타입스크립트 3장( 태그된 유니온 )
  3. 1-blue - JSDoc
  4. 1-blue - prisma
  5. GitHub - blequotes의 schema.prisma
  6. 1-blue - 구조적 타이핑
  7. 1-blue - 타입 좁히기의 태그/구별된 유니온
  8. 1-blue - 사용자 정의 타입 가드
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.