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

0

4장 타입 설계

타입 자체의 설계에 대해 다룹니다

어떻게 타입 설계를 잘 할 것이냐..

어떻게 타입 이름을 잘 지을 것이냐


아이템28 유효한 상태만 표현하는 타입을 사용하기

interface State {
  pageText: string;
  isLoading: boolean;
  error?: string;
}

위와 같은 상태값의 문제가 무엇일까?

isLoadingtrue이면서 동시에 error값이 설정되는 무효한 상태를 허용한다는 것이다. 무효한 상태가 존재한다면 제대로 된 구현을 할 수 없다.

그러면 위 예시에서 어떻게 무효한 상태를 없앨 수 있을까?

interface RequestPending {
  state: 'pending';
}
interface RequestError {
  state: 'error';
  error: string;
}
interface RequestSuccess {
  state: 'ok';
  pageText: string;
}
type RequestState = RequestPending | RequestError | RequestSuccess;

요청 상태를 더 명시적으로 모델링하는 태그된 유니온이 사용되었다.

타입 코드의 길이가 길어지긴 했지만 무효한 상태를 허용하지 않도록 개선된 모습이다.

결론

타입을 설계할 때 어떤 값들을 포함하고 제외할지 신중히 생각하자

유효한 상태만 표현한다면 코드를 작성하기 쉬워지고 타입체크가 쉬워진다


아이템29 사용할 때는 너그럽게, 생성할 때는 엄격하게

당신의 작업은 엄격하게 하고, 다른 사람의 작업은 너그럽게 받아들여야 한다.

함수 시그니처에도 비슷한 규칙을 적용하자.

함수 매개변수는 타입의 범위가 넓어도 되지만, 반환 타입은 구체적이어야 한다.

interface 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];

declare function setCamera(camera: CameraOptions): void;

declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;

type Feature = any;

declare function calculateBoundingBox(
  f: Feature
): [number, number, number, number];

function focusOnFeature(f: Feature) {
  const bounds = calculateBoundingBox(f);
  const camera = viewportForBounds(bounds);
  setCamera(camera);
  const {
    center: { lat, lng },
    //        ~~~      Property 'lat' does not exist on type ...
    //             ~~~ Property 'lng' does not exist on type ...
    zoom,
  } = camera;
  zoom; // Type is number | undefined
  window.location.search = `?v=@${lat},${lng}z${zoom}`;
}

위 예시는 viewportForBounds 함수의 반환타입이 너무 자유로워 사용하기 어려워진 모습이다.

매개변수 타입의 범위가 넓으면 사용하기 편리하지만 반환타입의 범위가 넓으면 불편하다.

따라서 사용하기 편한 API일수록 반환타입이 엄격해야 한다.

위 예시를 어떻게 해결할 수 있을까?

좌표를 위한 기본 형식을 분리하는 방법이 있다.

LngLatLngLatLike 로 분리해보자

interface LngLat {
  lng: number;
  lat: number;
}
type LngLatLike = LngLat | { lon: number; lat: number } | [number, number];

interface Camera {
  center: LngLat;
  zoom: number;
  bearing: number;
  pitch: number;
}
interface CameraOptions extends Omit<Partial<Camera>, 'center'> {
  center?: LngLatLike;
}
type LngLatBounds =
  | { northeast: LngLatLike; southwest: LngLatLike }
  | [LngLatLike, LngLatLike]
  | [number, number, number, number];

declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): Camera;

CameraOptionscenter속성의 타입을 LngLatLike로 바꿔서 setCamera 함수의 매개변수 타입의 범위를 넓혀준 모습이다.

viewportForBounds 함수의 반환 타입이 엄격해져 사용하기 편리해졌다.

이 예시에서는 매개변수 타입의 형태를 19가지나 혀용한다. 매개변수 치고도 너무 넓은 설계일 수 있다.

하지만 반환 타입이 19가지인 것은 명백히 나쁜 설계이다.


아이템30 문서에 타입 정보를 쓰지 않기

주석은 코드와 동기화되지 않는다.

함수의 입출력의 타입을 코드로 표현하는 것이 주석보다 나은 방법이다.

또한 타입 구문은 컴파일러가 체크해 주기 때문에 구현체와 정합성이 어긋나지 않는다.

값을 변경하지 않는다는 주석도 사용하지 않는 것이 좋다.

readonly로 선언해 타입스크립트가 강제하게 할 수 있다.

변수명에도 타입 정보를 넣지 말자.(단위가 있는 경우는 예외임)


아이템31 타입 주변에 null값 배치하기

숫자들의 최솟값, 최댓값을 계산하는 extent함수

function extent(nums: number[]) {
  let min, max;
  for (const num of nums) {
    if (!min) {
      min = num;
      max = num;
    } else {
      min = Math.min(min, num);
      max = Math.max(max, num);
      //             ~~~ Argument of type 'number | undefined' is not
      //                 assignable to parameter of type 'number'
    }
  }
  return [min, max];
}

반환 타입이 (number | undefined)[]가 되는 설계적 결함이 있다.

해법은?

min, max를 한 객체 안에 넣고 null이거나 null이 아니게 하면 된다.

function extent(nums: number[]) {
  let result: [number, number] | null = null;
  for (const num of nums) {
    if (!result) {
      result = [num, num];
    } else {
      result = [Math.min(num, result[0]), Math.max(num, result[1])];
    }
  }
  return result;
}

const [min, max] = extent([0, 1, 2])!;
const span = max - min;

반환 타입이 [number, number] | null이 되어 사용하기 편해졌다.

클래스에서 null과 null이 아닌 값을 사용했을 때의 문제점

interface UserInfo {
  name: string;
}
interface Post {
  post: string;
}

declare function fetchUser(userId: string): Promise<UserInfo>;
declare function fetchPostsForUser(userId: string): Promise<Post[]>;

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

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

  async init(userId: string) {
    return Promise.all([
      async () => (this.user = await fetchUser(userId)),
      async () => (this.posts = await fetchPostsForUser(userId)),
      // 속성값의 불확실성: user, posts 가 각각 따로 null 상태일 수 있다.
      // 총 4가지 경우가 존재
      // 이런 불확실성은 클래스의 모든 메서드에 나쁜 영향(null 체크 난무, 버그 양산)을 주게 된다.
    ]);
  }

  getUserName() {
    // ...?
  }
}

다음과 같이 필요한 데이터가 모두 준비된 뒤에 클래스를 만들도록 하여 개선할 수 있다.

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

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

  static async init(userId: string): Promise<UserPosts> {
    const [user, posts] = await Promise.all([
      fetchUser(userId),
      fetchPostsForUser(userId),
    ]);
    return new UserPosts(user, posts);
  }

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

null인 경우가 필요한 속성은 프로미스로 바꾸면 안 된다.

프로미스는 데이터를 로드하는 코드를 단순하게 만들지만, 데이터를 사용하는 클래스에서는 코드가 복잡해지기도 한다.

이 부분이 뭔가 와닿지가 않네요.

요약

  • 한 값의 null 여부가 다른 값에 암시적으로 관련되도록 설계하지 말자.
    • strictNullChecks 설정하면 오류가 표시될 것. 따라서 이 설정을 꼭 해두자
  • API 작성 시 반환 타입을 큰 객체로 만들고 반환 타입 전체가 null이거나 null이 아니게 만들어야 한다.
  • 클래스를 만들 때 모든 속성값이 준비됐을 때 생성하여 null이 존재하지 않는 것이 좋다.

아이템32 유니온의 인터페이스보다는 인터페이스의 유니온을 사용하기

유니온의 인터페이스 예시

interface Layer {
  type: 'fill' | 'line' | 'point';
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
}

type: 'fill'과 함께 LineLayoutPointPaint이 쓰이는 것은 말이 되지 않는다. (무효한 상태)

유효한 상태만을 표현하기 위해 인터페이스의 유니온으로 바꾸기

interface FillLayer {
  type: 'fill';
  layout: FillLayout;
  paint: FillPaint;
}
interface LineLayer {
  type: 'line';
  layout: LineLayout;
  paint: LinePaint;
}
interface PointLayer {
  type: 'paint';
  layout: PointLayout;
  paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;

Layer 타입의 범위를 좁혔고, 태그된 유니온을 사용해 타입의 범위를 더 좁히기도 쉬워졌다.

속성들간의 관계를 제대로 모델링해 타입스크립트가 코드의 정확성을 체크하는 데 도움이 된다.

function drawLayer(layer: Layer) {
  if (layer.type === 'fill') {
    const { paint } = layer; // Type is FillPaint
    const { layout } = layer; // Type is FillLayout
  } else if (layer.type === 'line') {
    const { paint } = layer; // Type is LinePaint
    const { layout } = layer; // Type is LineLayout
  } else {
    const { paint } = layer; // Type is PointPaint
    const { layout } = layer; // Type is PointLayout
  }
}

여러 개의 선택적 필드가 동시에 값이 있거나 동시에 undefined일 때

// BAD
interface Person {
  name: string;
  // 다음은 둘 다 동시에 있거나 동시에 없다.
  placeOfBirth?: string;
  dateOfBirth?: Date;
}

// GOOD
interface Person {
  name: string;
  // 하나의 객체로 모아주기!
  birth?: {
    place: string;
    date: Date;
  };
}

같은 상황이지만 타입의 구조를 손댈 수 없다면(ex. API결과)

하나의 객체로 모아주는 것이 불가능하다는 얘기...

그러면 인터페이스의 유니온으로 모델링 해보자!

interface Name {
  name: string;
}

interface PersonWithBirth extends Name {
  placeOfBirth: string;
  dateOfBirth: Date;
}

type Person = Name | PersonWithBirth;

결론

속성간의 관계를 명확히하는 타입을 설계하자

그 방법으로 인터페이스의 유니온, 하나의 객체로 모으기 등이 있다.

가장 중요한 것은 속성간의 관계를 명확히 하는 것


아이템33 string 타입보다 더 구체적인 타입 사용하기

문자열을 남발하여 선언된 타입(stringy typed)의 문제점

interface Album {
  artist: string;
  title: string;
  releaseDate: string; // YYYY-MM-DD
  recordingType: string; // E.g., "live" or "studio"
}

const kindOfBlue: Album = {
  artist: 'Miles Davis',
  title: 'Kind of Blue',
  releaseDate: 'August 17th, 1959',
  recordingType: 'Studio',
};
// 잘못된 형식이지만 타입 체커는 오류를 잡지 못한다.(당연하다. 타입 설계의 오류니까..)

function recordRelease(title: string, date: string) {
  /* ... */
}
recordRelease(kindOfBlue.releaseDate, kindOfBlue.title);

// 개발자의 실수로 런타임 에러가 나겠지만, 타입 체커는 통과한다.

속성의 형식을 제한해 해결해보자.

type RecordingType = 'studio' | 'live';

interface Album {
  artist: string;
  title: string;
  releaseDate: Date;
  recordingType: RecordingType;
}
// 속성을 명확히 표현한 타입이다. (좋은 타입 설계다)

이렇게 했을 때 장점은??

  • 명시적 타입을 통해 다른 곳에서도 타입 정보가 유지된다.
  • 타입을 설명하는 주석을 써둘 경우 사용하는 곳에서 주석정보를 확인할 수 있다.
  • keyof 연산자로 세밀한 객체의 속성 체크가 가능하다.

어떤 객체의 배열에서 한 필드의 값만 추출하는 pluck함수 타이핑하기

// BAD
function pluck(record: any[], key: string): any[] {
  return record.map((r) => r[key]);
}

// any 반환 타입을 쓸모가 없다.
// 제너릭을 도입해보자

// BAD
function pluck<T>(record: T[], key: keyof T) {
  return record.map((r) => r[key]);
}

pluck(albums, 'releaseDate'); // Type is (string | Date)[]

// 속성들의 타입이 string | Date 로 뭉뚱그려진 모습..
// 적절하지 않다. 우리는 string을 남발하지 않기로 했었다.
// 타입의 범위를 더 좁혀야 한다. 두 번째 제너릭을 도입하자.

// GOOD
function pluck<T, K extends keyof T>(record: T[], key: K): T[K][] {
  return record.map((r) => r[key]);
}

pluck(albums, 'releaseDate'); // Type is Date[]
pluck(albums, 'artist'); // Type is string[]
pluck(albums, 'recordingType'); // Type is RecordingType[]

// 반환 타입의 범위가 명확해졌다. 설계된 타입과 잘 들어맞는 모습이다.

요약

  • stringany와 비슷하다. 더 구체적인 타입을 사용하자.
    • 문자열 리터럴의 유니온을 사용해보자
  • 객체의 속성 이름을 매개변수로 받을 때는 string보다는 keyof T를 사용하자.

아이템34 부정확한 타입보다는 미완성 타입을 사용하기

부정확한 타입의 예시

type Expression1 = any;
type Expression2 = number | string | any[];
type Expression4 = number | string | CallExpression;

type CallExpression = MathCall | CaseCall | RGBCall;

interface MathCall {
  0: '+' | '-' | '/' | '*' | '>' | '<';
  1: Expression4;
  2: Expression4;
  length: 3;
}

interface CaseCall {
  0: 'case';
  1: Expression4;
  2: Expression4;
  3: Expression4;
  length: 4 | 6 | 8 | 10 | 12 | 14 | 16; // etc.
}

interface RGBCall {
  0: 'rgb';
  1: Expression4;
  2: Expression4;
  3: Expression4;
  length: 4;
}

const tests: Expression4[] = [
  10,
  'red',
  true,
  // ~~~ Type 'true' is not assignable to type 'Expression4'
  ['+', 10, 5],
  ['case', ['>', 20, 10], 'red', 'blue', 'green'],
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  //  Type '["case", [">", ...], ...]' is not assignable to type 'string'

  ['**', 2, 31],
  // ~~~~~~~~~~~~ Type '["**", number, number]' is not assignable to type 'string
  ['rgb', 255, 128, 64],
  ['rgb', 255, 128, 64, 73],
  // ~~~~~~~~~~~~~~~~~~~~~~~~ Type '["rgb", number, number, number, number]'
  //                          is not assignable to type 'string'
];
const okExpressions: Expression4[] = [
  ['-', 12],
  // ~~~~~~~~~ Type '["-", number]' is not assignable to type 'string'
  ['+', 1, 2, 3],
  // ~~~~~~~~~~~~~~ Type '["+", number, ...]' is not assignable to type 'string'
  ['*', 2, 3, 4],
  // ~~~~~~~~~~~~~~ Type '["*", number, ...]' is not assignable to type 'string'
];

잘못 사용된 코드에서 오류가 발생했지만, 오류메세지가 굉장히 이상한 모습..

언어서비스는 타입스크립트 경험에서 매우 중요한 부분이다.

위와 같은 방식은 개발 경험을 해치게 된다.(알아들기 힘든 오류 메세지, 자동 완성 방해)

사실 부정확한 타입이었다?

표현식이 여러개의 매개변수를 받을 수 있다.

예를 들면, ["+", 1, 2, 3, 4] 등과 같이..

하지만 타입 설계가 잘못되어 오류를 표시하게 된다.

결론

매우 추상적인 타입은 정제가 필요. 하지만 구체적일수록 정확도가 올라가는건 아니다.

정확한 타입을 모델링할 수 없다면 부정확하게 하기보다는 미완성으로 가자.(그리고 anyunknown을 구별해서 쓰자)


아이템35 데이터가 아닌 API와 명세를 보고 타입 만들기

예시 데이터가 아니라 명세를 참고해 실수 없는 타입을 생성하자

예시 데이터를 이용해 만든 타입은 눈앞에 있는 데이터만 고려하므로 예기치 않은 오류가 발생할 수 있다.

자동 생성 타입 사용하기

  • DefinitlyTyped: 이미 작성된 타입 선언
  • GraphQL codegen: 쿼리를 타입스크립트 타입으로 변환해주는 도구
  • DOM API: 브라우저의 DOM API에 대한 미리 선언된 타입들

결론

명세로부터 타입스크립트 코드를 생성하는 것이 좋다.

이미 만들어진 것들이 많으니 잘 가져다 쓰자.


아이템36 해당 분야의 용어로 타입 이름 짓기

타입의 이름 짓기는 타입 설계의 중요한 부분이다

잘못된 타입 이름은 코드를 왜곡하고 잘못된 개념을 심는다.

자체적으로 용어를 만들어 내려고 하지 말고 해당 분야의 이미 존재하는 용어를 사용해야 한다.

하지만 전문 분야의 용어는 정확하게 사용해야 한다. 잘못 쓰게 되면 직접 만들어낸 용어보다 더 혼란을 줄 수 있다.

// BAD
interface Animal {
  name: string;
  endangered: boolean;
  habitat: string;
}

const leopard: Animal = {
  name: 'Snow Leopard',
  endangered: false,
  habitat: 'tundra',
};
// GOOD
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'; // 길어서 생략

const snowLeopard: Animal = {
  commonName: 'Snow Leopard',
  genus: 'Panthera',
  species: 'Uncia',
  status: 'VU', // 취약종
  climates: ['ET', 'EF', 'Dfd'], // 고산대 or 아고산대
};

이름을 지을 때 명심해야 할 3가지 규칙

  • 동일한 의미는 같은 이름을
    • 정말로 의미적으로 구분되어야 하는 경우에만 다른 용어 사용하기
  • data, info, thing, item, object, entity 같은 의미 없는 이름은 피하기
  • 이름을 지을 때는 포함된 내용이나 계산 방식이 아니라 데이터 자체가 무엇인지 고려하기
    • 구현의 측면(포한된 내용)에서 지은 이름: INodeList
    • 개념적 측면에서 지은 이름: Directory (추상화 수준을 높임 👍)

아이템37 공식 명칭에는 상표를 붙이기

타입스크립트의 구조적 타이핑의 한계

interface Vector2D {
  x: number;
  y: number;
}
function calculateNorm(p: Vector2D) {
  return Math.sqrt(p.x * p.x + p.y * p.y);
}

calculateNorm({ x: 3, y: 4 }); // OK, result is 5
const vec3D = { x: 3, y: 4, z: 1 };
calculateNorm(vec3D); // OK! result is also 5
// 의도와 맞지 않는 모습

위와 같은 경우 상표를 이용해서 calculateNormVector2D만 받는 것을 보장할 수 있다.

interface Vector2D {
  _brand: '2d'; // 상표 붙이기
  x: number;
  y: number;
}
function vec2D(x: number, y: number): Vector2D {
  return { x, y, _brand: '2d' };
}
function calculateNorm(p: Vector2D) {
  return Math.sqrt(p.x * p.x + p.y * p.y); // Same as before
}

calculateNorm(vec2D(3, 4)); // OK, returns 5
const vec3D = { x: 3, y: 4, z: 1 };
calculateNorm(vec3D);
// ~~~~~ Property '_brand' is missing in type...

상표 기법은 타입 시스템에서 동작하지만 런타임에서 검사하는 것과 동일한 효과를 얻는다.

따라서 런타임 오버헤드를 없앨 수 있다.

string 타입에서의 상표 기법을 알아보자

type AbsolutePath = string & { _brand: 'abs' };
function listAbsolutePath(path: AbsolutePath) {
  // ...
}
function isAbsolutePath(path: string): path is AbsolutePath {
  return path.startsWith('/');
}

// 타입가드가 잘 동작하는 것을 알 수 있다.
function f(path: string) {
  if (isAbsolutePath(path)) {
    listAbsolutePath(path);
  }
  listAbsolutePath(path);
  // ~~~~ Argument of type 'string' is not assignable
  //      to parameter of type 'AbsolutePath'
}

실제 string이면서 _brand속성을 가지는 값은 없다. 온전히 타입 시스템에서만 가능

타입 체커를 유용하게 사용하는 일반적인 패턴 알아보기

type SortedList<T> = T[] & { _brand: 'sorted' };

// 런타임에 동작하는 타입 가드라 효율적이진 않음. 하지만 안전성 보장
function isSorted<T>(xs: T[]): xs is SortedList<T> {
  for (let i = 1; i < xs.length; i++) {
    if (xs[i] > xs[i - 1]) {
      return false;
    }
  }
  return true;
}

function binarySearch<T>(xs: SortedList<T>, x: T): boolean {
  // ...
}

number 타입에도 상표를 붙일 수 있지만 연산 후에는 없어져 사용에 무리가 있다고 한다.

string에 붙인 상표는 항상 유지되는 걸까?

©2024 dlwl98
github
PostsAbout