4장 타입 설계

2024.10.27

유효한 상태만 표현하는 타입을 지향하기

  • 유효한 상태와 무효한 상태를 둘 다 표현하는 타입은 혼란을 초래하기 쉽고 오류를 유발하게 됩니다.
function renderPage(state: State) {
  if (state.error) {
    return 'Error! Unable to load ${currentPage}: ${state.error}';
  } else if (state.isLoading) {
    return 'Loading ${currentPage}.••';
  }
  return '<h1>${currentPage}</h1>\n${state.pageText}';
}
  • 유효한 상태만 표현하는 타입을 지향해야 합니다. 코드가 길어지거나 표현하기 어렵지만 결국은 시간을 절약하고 고통을 줄일 수 있습니다.
function renderPage(state: State) {
  const { currentPage } = state;
  const requeststate = state.requests[currentPage];
  switch (requeststate.state) {
    case 'pending':
      return 'Loading ${currentPage}.//';
    case 'error':
      return 'Error! Unable to load ${currentPage}: ${requeststate.error}';
    case 'ok':
      return '<h1>${currentPage}</h1>\n${requeststate.pageText}';
  }
}

사용할 때는 너그럽게, 생성할 때는 엄격하게

  • 수많은 선택적 속성을 가지는 반환 타입과 유니온 타입은 코드를 사용하기 어렵게 만듭니다.
  • 매개변수 타입의 범위가 넓으면 사용하기 편리하지만, 반환 타입의 범위가 넓으면 불편합니다.
  • 보통 매개변수 타입은 반환 타입에 비해 범위가 넓은 경향이 있습니다. 선택적 속성과 유니온 타입은 반환 타입보다 매개변수 타입에 더 일반적입니다.
  • 매개변수와 반환 타입의 재사용을 위해서 기본 형태(반환 타입)와 느슨한 형태(매개변수 타입)를 도입하는 것이 좋습니다.

문서에 타입 정보를 쓰지 않기

  • 주석과변수명에타입정보를적는것은피해야합니다. 타입선언이중복되는 것으로 끝나면 다행이지만 최악의 경우는 타입 정보에 모순이 발생하게 됩니다.
  • 타입이 명확하지 않은 경우는 변수명에 단위 정보를 포함하는 것을 고려하는 것이 좋습니다 ( 예를 들어 timeMstime 보다 훨씬 명확합니다. )

타입 주변에 null 값 배치하기

  • 한 값의 null 여부가 다른 값의 null 여부에 암시적으로 관련되도록 설계하면 안 됩니다.
  • API 작성 시에는 반환 타입을 큰 객체로 만들고 반환 타입 전체가 null이거나 null이 아니게 만들어야 합니다. 사람과 타입 체커 모두에게 명료한 코드가 될 것입니다.
// 결함 버전
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);
      // ~~~ 'number | undefined' 형식의 인수는
      // 'number' 형식의 매개변수에 할당될 수 없습니다.
    }
  }
  return [min, max];
}

// 수정 버전
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;
}
  • 클래스를 만들 때는 필요한 모든 값이 준비되었을 때 생성하여 null이 존재하지 않도록 하는 것이 좋습니다.
  • strictNullChecks를 설정하면 코드에 많은 오류가 표시되겠지만, null 값과 관련된 문제점을 찾아낼 수 있기 때문에 반드시 필요합니다.

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

  • 유니온 타입의 속성을 여러 개 가지는 인터페이스에서는 속성 간의 관계가 분명하지 않기 때문에 실수가 자주 발생하므로 주의해야 합니다.
  • 유니온의 인터페이스보다 인터페이스의 유니온이 더 정확하고 타입스크립트가 이해하기도 좋습니다.
  • 타입스크립트가 제어 흐름을 분석할 수 있도록 타입에 태그를 넣는 것을 고려해야 합니다. 태그된 유니온은 타입스크립트와 매우 잘 맞기 때문에 자주 볼 수 있는 패턴입니다.
// 유니온 타입의 속성을 여러개 가지는 인터페이스
interface Layer {
  type: 'fill' | 'line' | 'point1';
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
}

// 타입에 태그를 넣어 태그된 유니온 타입으로 인터페이스를 활용하도록 변경
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;

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

string 타입보다 더 구체적인 타입 사용하기

  • ‘문자열을 남발하여 선언된’ 코드를 피합시다. 모든 문자열을 할당할 수 있는 string 타입보다는 더 구체적인 타입을 사용하는 것이 좋습니다.
  • 변수의 범위를 보다 정확하게 표현하고 싶다면 string 타입보다는 문자열 리터럴 타입의 유니온을 사용하면 됩니다. 타입 체크를더 엄격히 할수 있고 생산성을 향상시킬 수 있습니다.
// string 타입을 남발하여 사용한 코드
interface Album {
  artist: string;
  title: string;
  releaseDate: string; // YYYY-MM-DD
  recordingType: string; // 예를 들어, "live" 또는 "studio"
}

// 문자열 리터럴 타입의 유니온 사용한 코드
type RecordingType = 'studio' | 'live';

interface Album {
  artist: string;
  title: string;
  releaseDate: Date;
  recordingType: RecordingType;
}
  • 객체의 속성 이름을 함수 매개변수로 받을 때는 string보다 keyof T를 사용하는 것이 좋습니다.

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

  • 타입 안전성에서 불쾌한 골짜기는 피해야 합니다. 타입이 없는 것보다 잘못된 게 더 나쁩니다.
  • 부정확함을 바로잡는 방법을 쓰는 대신, 테스트 세트를 추가하여 놓친 부분이 없는지 확인해도 됩니다. 일반적으로 복잡한 코드는 더 많은 테스트가 필요하고 타입의 관점에서도 마찬가지입니다.
  • 정확하게 타입을 모델링할 수 없다면, 부정확하게 모델링하지 말아야 합니다. 또한 anyunknown를 구별해서 사용해야 합니다.

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

  • 코드의 구석구석까지 타입 안전성을 얻기 위해 API 또는 데이터 형식에 대한 타입 생성을 고려해야 합니다.
  • 데이터에 드러나지 않는 예외적인 경우들이 문제가 될수 있기 때문에 데이터보다는 명세로부터 코드를 생성하는 것이 좋습니다.

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

  • 가독성을 높이고, 추상화 수준을 올리기 위해서 해당 분야의 용어를 사용해야 합니다.
// 잘못 선택한 타입의 코드
interface Animal {
  name: string; // 동물의 학명인지 일반적인 명칭인지 알 수 없음
  endangered: boolean; // 멸종 위기를 표현하기 위해 boolean 타입을 사용한 것이 이상함. 이미 멸종된 동물을 true로 해야 하는지 판단할 수 없음
  habitat: string; // 서식지라는 뜻 자체도 불분명하기 때문에 다른 속성들보다도 훨씬 모호함
}
const leopard: Animal = {
  name: 'Snow Leopard',
  endangered: false,
  habitat: 'tundra',
};

// 개선된 타입의 코드
interface Animal {
  commonName: string;
  genus: string;
  species: string;
  status: Conservationstatus;
  climates: KoppenClimate[];
}
/**
- name은 commonName, genus, species 등 더 구체적인 용어로 대체함
- status는 동물 보호 등급에 대한 IUCN의 표준 분류 체계인 Conservation Status 사용함
- 기후를 뜻하는 climates를 사용하고, 쾨펜 기후 분류(Kdppen climate classification)를 사용함
 */

const snowLeopard: Animal = {
  commonName: 'Snow Leopard',
  genus: 'Panthera',
  species: 'Uncia',
  status: 'VU', // 취약종(vulnerable)
  climates: ['ET', 'EF', 'Dfd'], // 고산대(alpine) 또는 아고산대(subalpine)
};
  • 같은 의미에 다른 이름을 붙이면 안 됩니다. 특별한 의미가 있을 때만 용어를 구분해야 합니다.

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

  • 타입스크립트는 구조적 타이핑(덕 타이핑)을 사용하기 때문에, 값을 세밀하게 구분하지 못하는 경우가 있습니다. 값을 구분하기 위해 공식 명칭이 필요하다면 상표를 붙이는 것을 고려해야 합니다.
// 구조적 타이핑 관점에서 문제는 없지만, 논리적으로 3차원 벡터를 사용할 수 있는 상태
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 }); // 정상, 결과는 5
const vec3D = { x: 3, y: 4, z: 1 };
calculateNorm(vec3D); // 정상! 결과는 동일하게 5

// 상표(brand)를 붙여 공식 명칭(nominal typing) 개념을 흉내낸 상태
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); // 기존과 동일합니다.
}
calculateNorm(vec2D(3, 4)); // 정상, 5를 반환합니다.
const vec3D = { x: 3, y: 4, z: 1 };
calculateNorm(vec3D); // 오류 발생 ~~~ 'brand’ 속성이 ... 형식에 없습니다.
  • 상표 기법은 타입 시스템에서 동작하지만 런타임에 상표를 검사하는 것과 동일한 효과를 얻을 수 있습니다.
// 런타임에는 절대 경로(‘/')로 시작하는지 체크하기 쉽지만,
// 타입 시스템에서는 절대 경로를 판단하기 어렵기 때문에 상표 기법을 사용한 예제
type AbsolutePath = string & { _brand: 'abs' };
function ListAbsolutePath(path: AbsolutePath) {
  // ...
}

function isAbsolutePath(path: string): path is AbsolutePath {
  return path.startsWith('/');
}

// number 타입에도 상표를 붙이는 예시
// 다만, 산술 연산 후에는 상표가 없어짐
type Meters = number & { _brand: 'meters' };
type Seconds = number & { _brand: 'seconds' };

const meters = (m: number) => m as Meters;
const seconds = (s: number) => s as Seconds;

const oneKm = meters(1000); // 타입이 Meters
const oneMin = seconds(60); // 타입이 Seconds