본문 바로가기

HTML ⁄ CSS ⁄ JS

[이펙티브 타입스크립트] 4장 타입 설계

 

이펙티브 타입스크립트 정주행 스터디 4주차

 

데이터 타입을 명확하게 알 수 있다면, 코드를 이해하기가 더욱 쉬워진다. 타입스크립트 타입의 실질적인 사항을 다루는 다른 챕터와 다르게 4장에서는 타입 자체의 설계에 대해서 살펴본다.

 

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

다음 두 예제를 비교해보자.

첫 번째 예제 코드에서는 가능하지 않은 시나리오까지 표현이 가능하다. 예를 들어 요청이 실패했으면서 로딩 중일 수는 없는데 일단 타입에서는 그렇게 표현이 가능하다. 이러한 타입 설계는 코드를 뒤죽박죽으로 만드는 원인이 된다.

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

두 번째 예제 코드에서는 유효한 상태만을 표현하고 있다. 유효한 상태만을 표현하려고 하다보니 코드의 길이는 조금 더 길어졌다. 그러나 모든 상태와 맞아떨어져 코드 가독성이 더욱 증가한다.

interface RequestPending {
  state: 'pending';
}

interface RequestError {
  state: 'error';
  error: string;
}

interface RequestSuccess {
  state: 'success';
  pageText: string;
}

type RequestState = RequestPending | RequestError | RequestSuccess;

interface State {
  currentPage: string;
  request: { [page: string]: RequestState }
}

 

 

29 사용은 너그럽게, 생성은 엄격하게

매개변수 타입의 범위는 넓게 설계해야 좋고, 반환 타입의 범위는 좁게 설계해야 좋다.

다음 예제를 보면 LngLatBounds 는 총 19가지 (9 + 9 + 1)형태를 지원하고 있다. 이렇게 19가지 경우의 수를 지원하는 것은 (심지어 다양한 타입을 지원해야하는 라이브러리 설계에서도) 좋은 설계가 아니다. 또, 한 함수의 반환 타입(CameraOptions)이 다른 함수의 매개변수 타입으로 사용되고 있다. 이렇게 되면 반환타입이 느슨해지거나, 매개변수 타입이 엄격해지는 문제가 생긴다.

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

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

type LngLatBounds = 
  { northeast: LngLat; southwest: LngLat; } |
  [ LngLat, LngLat ] |
  [ number, number, number, number ]

interface CameraOptions {
  center?: LngLat;
  zoom?: number;
  bearing?: number;
  pitch?: number;
}

 

위 예제에서는 반환값인 cameraOptions의 형태가 너무 자유롭기 때문에, 반환받은 값을 사용하다보면 다음과 같은 불편함이 생긴다.

const cameraOptions = viewportForBounds(bounds);
const { center: {lat, lan}, zoom} = cameraOptions;
// 에러,          ~~~      ... 형식에 'lat' 속성이 없습니다.
//                    ~~~ ... 형식에 'lan' 속성이 없습니다.

 

cameraOptions를 사용하기 편리한 반환값으로 개선하려면, 유니온 타입에 각각을 코드 상에서 분기 처리하는 것이 좋다. 분기 처리를 위한 방법 중에 하나로 Sth, SthLike 이렇게 유사 타입을 만들어볼 수 있다.

declare function setCamera(camera: CameraOptions): void; // 느슨한 매개변수 타입
declare function viewportForBounds(bounds: LngLatBounds): Camera; // 엄격한 반환값 타입

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 { // 느슨한 매개변수 타입
  center?: LngLatLike;
  zoom?: number;
  bearing;: number;
  pitch?: number;
}

 

 

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

코드는 변경되었는데 주석의 정보는 미처 업데이트하지 못하는 경우, 코드를 읽는 사람으로 하여금 혼란을 느끼게 한다. 코드의 내용과 주석의 정보가 서로 상이할 경우, 코드를 읽는 사람은 둘 다 믿을 수 없게 된다. 

문서는 코드 상에 무언가를 강제하지 않기 때문에 구현체와 불일치가 발생해도 조용히 넘어가게된다. 반면, 타입스크립트의 컴파일러는, 타입 정보와 어긋나는 경우 곧바로 타입체크를 통해 개발자에게 이를 알리고 타입이 되었든 코드가 되었든 동기화하도록 강제한다. 따라서 타입정보를 주석으로 쓰는 것보다 타입선언을 통해 타입을 관리하는 것이 훨씬 효율적이다.

다음은 지양해야할 주석의 예시이다.

// foreground는 문자열을 반환합니다.
// 0개 또는 1개의 매개변수를 받습니다.
// 매개변수가 없을 때 표준 전경색을 반환합니다.
// nums 를 변경하지 않습니다 => readonly 로 선언하여 TS 규칙을 강제할 수 있음

 

 

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

null 값은 어디에 어떻게 배치하면 좋을까? 타입스크립트가 null 값 사이의 관계를 컨텍스트를 통해 잘 이해할 수 있도록 돕는 관점에서 배치해야 한다.

A와 B의 null 여부가 서로에게 영향이 있는 경우를 생각해보자. 예를 들어 A가 null 일 때 B도 반드시 null이고, A가 null이 아닐 때 B도 반드시 null이 아니라면, 각각을 독립적인 타입으로 두어  ' | null ' 을 추가해주는 것은 좋은 방법이 아니다. A와 B를 묶어 큰 객체로 타입을 만들고 그 전체가 null 이거나 null이 아니게 만들어야 한다. 이렇게 만들면 각 타입을 다루기 훨씬 쉬워진다.

아래의 예제에서 min, max는 각각 number | undefined 이고 반환값도 (number | undefined)[] 이다. 객체에 undefined가 포함되는 것은 지양해야 한다.

// 개선 전
function extent1(nums: number[]) {
  let min, max;

  for (const num of nums) {
    if (!min) { // falsy 값일 때 문제 O
      min = num;
      max = num;
    } else { 
      min = Math.min(min, num);
      max = Math.max(max, num);
                   // ~~~ 'number | undefined' 타입을 number 타입에 할당할 수 없습니다.
    }
  }
}

// 개선 후
function extent2(nums: number[]) {
  let result: [number, number] | null = 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;
}

 

 

32 유니온의 인터페이스보다는 인터페이스의 유니온 쓰기

다음 Layer 인터페이스 예제를 살펴보자. Layer는 각 프로퍼티가 유니온으로 되어있는 인터페이스이다. 그런데 type 이 'fill'이면서 layout이 LineLayout, paint가 PointPaint인 조합이 가능하다. 이는 유효한 상태가 아니기 때문에 오류가 발생하기도 쉽다.

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

 

Layer를 유효한 상태만을 담은 인터페이스의 유니온으로 개선할 수 있다. 

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

type Layer = FillLayer | LineLayer | PointLayer;

 

 

33 string 보다 구체적인 타입 쓰기

string 이라는 타입은 매~우 넓은 타입이다. any를 쓰는 것과 비슷한 의미로, string 보다 구체적인 타입을 쓰는 것이 좋다.

stringly typed 라는 개발자가 변수에 과도하게 string으로 값을 사용할 때를 지칭하는 표현이 있을 정도이다.

아래의 예제는 타입스크립트에서 이렇게 stringly typed된 경우에 해당한다. releaseDate와 recordingType에 주석으로 타입 정보를 적어두었다는 것은 인터페이스가 잘못되었다는 힌트이기도 하다.

interface Album {
  artist: string;
  title: string;
  releaseDate: string;  // YYYY-MM-DD
  recordingType: string;  // "live" 또는 "studio"
}

 

releaseDate는 Date 객체로, recordingType은 가능한 값의 유니온으로 개선할 수 있다.

interface Album {
  artist: string;
  title: string;
  releaseDate: Date;
  recordingType: "live" | "studio";
}

 

 

34 부정확 보다는 미완성 타입 쓰기

타입 정보가 지나치게 정밀하게 작성하려고 하다보면, 틀린 타이핑이 되거나 에디터에서 표시해주는 에러 메세지가 더 부정확해질 수 있다. 틀린 타이핑보다는 차라리 없는게 낫다. 타입이 구체적으로 정제되는 정도에 비례하여 정확도가 올라가는 것은 아니기도 하다. 

타입을 정확하게 모델링하려고 노력하는 상황이라면, 정확성이 어떻게 개선되는지 눈 여겨봄과 동시에, 에러 메세지나 자동완성 어시스트 등의 개발경험이 어떻게 달라지는지도 신경쓰는 것이 좋다.

 

 

35 API와 명세를 보고 타입 만들기

Apollo는 GraphQL 쿼리를 타입스크립트로 바꿔주는 도구 중 하나이다. 다음 명령어는 api.github.com/graphql 로부터 스키마를 얻어 타입스크립트 타입을 생성해준다.

apollo client:codegen \
  --endpoint https://api.github.com/graphql \
  --includes license.graphql \
  --target typescript

 

만약 쿼리가 변경되거나 스키마가 변경되더라도 타입은 자동으로 연동된다. mock data를 보고 직접 타입을 만들면 변경사항에 따른 현행화도 수동으로 해야할 뿐더러 개발자의 순간적 판단에 따라 일부분이 누락된거나 예상치 못한 곳에서 실수하게 될 수 있다. 따라서 API와 명세를 통해 타입을 생성하는 것이, 데이터를 통해 직접 타입을 만드는 것보다 더 효율적이다. 

 

 

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

There are only two hard things in Computer Science: cache invalidation and naming things.
컴퓨터 과학에서 어려운 일은 두 가지 뿐이다: 캐시 무효화, 그리고 네이밍.

- 필 칼튼(Phil Karlton)

 

코드로 표현하려는 세상의 일들은 제각기 그 분야에 이미 사용되고 있는 용어가 존재한다.

다음 예제에는 여러가지 모호함이 담겨있다. 이 정보를 명확하게 하기 위해서는 해당 속성을 작성한 사람을 수소문해서 그 의도를 알아내야 한다.

// 개선 전
interface Animal {
  name: string;
  endandered: boolean;
  habitat: string;
}

const leopard: Animal = {
  name: 'Snow Leopard', // 학명인지 일반적인 이름인지 알 수 없음
  endandered: false,  // 멸종 위기가 아니라는 건지 멸종이 안되었다는 건지 알 수 없음
  habitat: 'tundra', // string 타입이라 범위가 너무 넓음
}

 

이를 다음과 같이 개선한다면, 더 정보가 필요하다면 기존의 작성자에 의존하지 않고도 정보를 구할 수 있게된다.

// 개선 후
interface Animal {
  commonName: string; // 일반적인 이름
  genus: string, // [생물] 종속과목강문계의 '속'
  species: string; // [생물] 종속과목강문계의 '종'
  status: ConservationStatus; // 동물보호 등급에 관한 IUCN 표준분류체계
}

type ConservationStatus = 'EX' | 'EW' | 'CR' | 'EN' | 'VU' | 'NT' | 'LC';

const snomLeopard: Animal = {
  commonName: 'Snow Leopard',
  genus: 'Panthera'
  species: 'Panthera';
  status: 'VU'
}

 

타입, 프로퍼티, 변수에 네이밍에 관한 다음 세 가지 규칙을 기억하자.

첫째, 같은 의미를 나타내는 거라면 같은 이름을 사용해야한다. 같은 상황에서 'show' 'display' 등을 번갈아 사용한다거나 'end' 'close' 등을 번갈아 사용해서는 안된다.

둘째, 모호한 이름은 지양해야 한다. data, entity, info, item, object, thing 등이 대표적인 예시이다.

셋째, 포함된 내용이나 계산하는 방식이 아니라 데이터 그 자체에 대한 이름을 붙여야 한다. 개념을 떠올리게 하는 Directory가 구현을 떠올리게 하는 INodeList보다 더 좋은 이름이다.

 

 

 

 

 

37 공식 명칭에는 brand 붙이기

구조적 타이핑을 따르는 타입스크립트에서 각 타입을 정교하게 구분하기 위해서 brand (상표)를 붙이는 방법을 사용할 수 있다. 

interface Vector2D {
  _brand: '2d';
  x: number;
  y: number;
}

function vec2D(x: number, y: number): Vector2D {
  return { x, y, _brand: '2d' };
}

function calculateNorm(point: Vector2D) {
  return Math.sqrt(point.x ** 2 + point.y ** 2);
}

const myVec2D = vec2D(3,4); // '2d' 브랜드 있음
const myVec3D = { x: 3, y: 4, z: 1 }; // '2d' 브랜드 없음

calculateNorm(myVec2D); // 정상
calculateNorm(myVec3D); // 에러, '_brand' 프로퍼티가 타입에 없습니다.

 

brand를 붙이는 것은 타입 시스템 상에서 일어나는 일이다. 따라서 프로퍼티를 추가할 수 있는 객체 뿐만 아니라, 자바스크립트의 원시타입 string, number 에서도 유용하게 사용할 수 있다.

type AbsolutePath = string & { _brand: 'absolutePath' };

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

function listAbsolutePath(path: AbsolutePath) {
  // ...
}

function fn(path: string) {
  if (!isAbsolutePath(path)) {
    // 예외 처리
  }
  listAbsolutePath(path);
}