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

이펙티브 타입스크립트 북스터디 4장 아이템 28-37

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

효과적으로 타입을 설계하려면 유효한 상태만 표현할 수 있는 타입을 만들어 내는 것이 가장 중요하다. 그렇게 하기 위해서는 어떠한 값들을 포함하고, 어떠한 값들을 제외할지 신중하게 생각해야 한다. 유효한 상태를 표현하는 값만 허용한다면 코드를 작성하기 쉬워지고 타입 체크가 용이해 진다.

예시

애플리케이션에서 페이지를 선택하면, 페이지의 내용을 로드하고 화면헤 표시하는 함수를 예시로 보자

Bad

분기 조건이 명확히 분리되어 있지 않음 renderPage 함수의 경우 isLoading이 true이고 error 값이 존재하면 로딩 중인 상태인지 오류가 발생한 상태인지 명확히 구분할 수 없다.

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

function getUrlForPage(p: string) { return ''; }

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}`;
}


async function changePage(state: State, newPage: string) {
  state.isLoading = true;
  try {
    const response = await fetch(getUrlForPage(newPage));
    if (!response.ok) {
      throw new Error(`Unable to load ${newPage}: ${response.statusText}`);
    }
    const text = await response.text();
    state.isLoading = false;
    state.pageText = text;
  } catch (e) {
    state.error = '' + e;
  }

Good

state유효한 값은 ‘pending’, ‘error’, ‘ok’ 이 세 가지로 정의되었다. 그리고 그 상태에서 포함해야 하는 속성(error, pageText)을 정의하였다.

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

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

함수에 적용하면 아래와 같다.

function getUrlForPage(p: string) { return ''; }

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}`;
  }
}

async function changePage(state: State, newPage: string) {
  state.requests[newPage] = {state: 'pending'};
  state.currentPage = newPage;
  try {
    const response = await fetch(getUrlForPage(newPage));
    if (!response.ok) {
      throw new Error(`Unable to load ${newPage}: ${response.statusText}`);
    }
    const pageText = await response.text();
    state.requests[newPage] = {state: 'ok', pageText};
  } catch (e) {
    state.requests[newPage] = {state: 'error', error: '' + e};
  }
}

Better?

책의 예시를 보고 보완할 부분을 고려해 보았다.

우선 RequestStateType 이라는 타입을 만들어 state에 할당될 수 있는 값을 정의하였다. 그리고 각각의 상태에 해당하는 interface의 공통부를 정의하여 상속하는 방식으로 변경하였다.

type RequestStateType = 'pending' | 'error' | 'ok';
interface RequestBasic {
  state: RequestStateType;
}

interface RequestPending extends RequestBasic {
  state: 'pending';
}

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

interface RequestSuccess extends RequestBasic {
  state: 'ok';
  pageText: string;
}

type RequestState = RequestPending | RequestError | RequestSuccess;

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



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

함수의 매개변수는 타입의 범위가 넓으면 사용하기 편리하지만반환 타입은 범위가 넒으면 사용하기 불편하다. 즉, 사용하기 쉬운 API일수록 반환 타입이 엄격하다.

예시

카메라의 위치를 지정하고, 경게 박스의 뷰포트를 계산하는 3D 매핑 API를 예시로 보자

참고

  • longtitude (경도) = lon, lng
  • latitude (위도) = lat

Bad

일부 값은 건드리지 않으면서 동시에 다른 값을 설정할 수 있어야 하므로 인터페이스 CameraOptions의 필드는 모두 선택적 타입 LngLat도 다양한 형식의 타입을 받을 수 있도록 함 타입 LngLatBounds 또한 다양한 형식을 지원

LngLatBounds를 매개변수의 타입으로 사용하는 viewportForBounds는 다양한 형식을 지원하기 때문에 경우에 맞게 API를 쉽게 호출 가능하다. 위도와 경도를 key/value 구조로 받기도 하고 배열로도 호출 가능하다.

하지만 viewportForBounds는 사용하기에 불편하다. 수많은 선택적 속성을 가지는 반환 타입(CameraOptions)과 유니온 타입(LngLat)은 함수를 사용하기 어렵게 만들기 때문.

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}, zoom} = camera;
               // ~~~      Property 'lat' does not exist on type ...
               //      ~~~ Property 'lng' does not exist on type ...
  zoom;  // Type is number | undefined
  window.location.search = `?v=@${lat},${lng}z${zoom}`;
}

Good

함수 viewportForBounds의 반환 타입으로 사용하기 위해 인터페이스 Camera를 정의하였다. Camera는 선택적인 값이 없고, 유니온 타입을 사용하지 않고 명확하게 정의 되어 있어(LngLat) 사용하기 편리하다.

CameraOptions는 타입 Camera를 Partial를 통해 선택적으로 만들고 Omit을 사용한 후 재정의 하였다. 여전히 함수를 호출하기에 편리하다.

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

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;

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

Better?

참고: How to declare a Fixed length Array in TypeScript - stackoverflow

[number, number, number, number]와 같이 길이가 고정된 배열 타입을 커스텀 타입으로 교체하였다.

type ArrayLengthMutationKeys = 'splice' | 'push' | 'pop' | 'shift' |  'unshift'
type FixedLengthArray<T, L extends number, TObj = [T, ...Array<T>]> =
  Pick<TObj, Exclude<keyof TObj, ArrayLengthMutationKeys>>
  & {
    readonly length: L 
    [ I : number ] : T
    [Symbol.iterator]: () => IterableIterator<T>   
  }

declare function calculateBoundingBox(f: Feature): FixedLengthArray<number, 4>;

type LngLatLike = LngLat | { lon: number; lat: number; } | FixedLengthArray<number, 2>;



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

누군가 강제하지 않는 이상 주석은 코드와 동기화 되지 않는다. 함수의 입력과 출력의 타입을 코드로 표현하는 것이 주석보다 더 나은 방법이다.

예시

Bad

코드와 주석의 정보가 맞지 않음 함수가 문자열을 반환한다고 적혀 있지만 실제로는 { r, g, b } 객체를 반환 불필요하게 장황함. 함수 선언과 구현체 보다 주석이 더 길다.

/**
 * 전경색(foreground) 문자열을 반환
 * 0개 또는 1 개의  매개변수를 받는다.
 * 매개변수가 없을 때는 표준 전경색을 반환
 * 매개변수가 있을 때는 특정 페이지의 전경색을 반환
 */
function getForegroundColor(page?: string) {
  return page === 'login' ? {r: 127, g: 127, b: 127} : {r: 0, g: 0, b: 0};
}

Good

반환 타입을 정의하여 지정 불필요한 주석은 제거하고 코드로 파악할 수 있도록 함

type Color = { r: number; g: number; b: number };

/** 애플리케이션 또는 특정 페이지의 전경색을 가져옵니다.. */
function getForegroundColor(page?: string): Color {
  return page === 'login' ? {r: 127, g: 127, b: 127} : {r: 0, g: 0, b: 0};
}

값을 변경하지 않는다고 설명하는 주석보다는 readonly로 선언

function sort(nums: readonly number[]) { /* ... */ }

변수명에 타입 정보를 넣지 않으나 (ex. ageNum > age) 단위는 넣는 것이 좋다 (ex. time > timeMs)



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

만약 B가 A로부터 비롯되는 값이라면

  • A가 null이면 B가 null이 될 수 있고
  • 반대의 경우는 B는 null이 될 수 없다.

이러한 관계들은 겉으로 드러나지 않기 때문에 사람과 타입 체커 모두에게 혼란스럽다.

값이 전부 null이거나 전부 null이 아닌 경우로 분명히 구분된다면 값이 섞여 있을 때보다 다루기 쉽다. 타입에 null을 추가하는 방식으로 모델링한다.

예시 1

숫자들의 최솟값과 최댓값을 계산하는 extent 함수를 예시로 보면

Bad

값에 undefined를 포함하는 객체는 다루기 어렵고 권장하지 않는다. 매개변수 nums가 빈 배열이면 [undefined, undefined] 가 반환 될 것이고, 배열의 최솟값이 0이라면 0이 아닌 값을 최솟값으로 반환할 것이다.

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];
}
const [min, max] = extent([0, 1, 2]);
const span = max - min;
          // ~~~   ~~~ Object is possibly 'undefined'

Good

min과 max를 한 객체 안에 넣고 null이거나 null이 아니게 하면 된다. 매개변수 nums가 빈 배열이면 null이 반환 될 것이고 이는 if문으로 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 range = extent([0, 1, 2]);
if (range) {
  const [min, max] = range;
  const span = max - min;  // OK
}

Better?

함수 extent의 매개변수의 타입을 최소 1개 이상의 요소를 가지는 number array로 변경하였다. 함수의 선언도 간단해지고 사용시 if 문으로 null 여부를 체크할 필요가 없다.

function extent(nums: [number, ...Array<number>]) {
  let result: [number, number] = [nums[0], nums[0]];
  for (const num of nums) {
    result = [Math.min(num, result[0]), Math.max(num, result[1])];
  }
  return result;
}

const range = extent([0]); // OK
const range = extent([]);
                  // ~~~ Argument of type '[]' is not assignable to parameter of type '[number, ...number[]]'.

예시 2

null과 null이 아닌 값을 섞어서 사용하면 클래스에서도 문제가 생긴다. 사용자와 그 사용자의 포럼 게시글을 나타내는 클래스를 가정해 보자.

Bad

함수 init을 호출할 경우 user와 posts 속성은 둘 다 null이거나, 둘 중 하나만 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)
    ]);
  }

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

Good

클래스르 만들 때는 필요한 모든 값이 준비되었을 때 생성하여 null이 존재하지 않도록 하는 것이 좋다. 설계를 개선하기 위해 필요한 데이터가 모두 준비된 후에 클래스를 만들도록 변경. 함수 init를 호출하면 모든 데이터가 로드된 후 객체가 생성된다.

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;
  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;
  }
}



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

유니온 타입의 속성을 가지는 인터페이스를 작성 중이라면, 혹시 인터페이스의 유니온 타입을 사용하는 게 더 알맞지는 않을지 검토해 봐야 한다.

예시 1

벡터를 그리는 프로그램을 작성 중이고, 특정한 기하학적 타입을 가지는 계층의 인터페이스를 정의한다고 가정한다.

Bad

layout은 모양이 그려지는 방법과 위치를 제어 paint는 스타일을 제어

만약 layout이 직선(LineLayout)인데 paint가 채우기(FillPaint)라면 오류가 발생할 것이다.

type FillPaint = unknown;
type LinePaint = unknown;
type PointPaint = unknown;
type FillLayout = unknown;
type LineLayout = unknown;
type PointLayout = unknown;
interface Layer {
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
}

Good

각각의 타입의 계층을 분리된 인터페이스로 둔다. 이런 형태로 Layer를 정의하면 layout과 paint 속성이 잘못됨 조합으로 섞이는 경우를 방지할 수 있다. 아이템 28의 유효한 상태만 표현할 수 있는 값만 허용하는 것도 적용된다.

type 속성은 ‘태그’이며 런타임에 어떤 타입의 Layer가 사용되는지 판단하는데 쓰인다.

type FillPaint = unknown;
type LinePaint = unknown;
type PointPaint = unknown;
type FillLayout = unknown;
type LineLayout = unknown;
type PointLayout = unknown;
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;  // 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
  }
}

예시 2

여러 개의 선택적 필드가 동시에 값이 있거나 동시에 undefined인 경우 태그된 유니온 패턴이 잘 맞는다.

Bad

placeOfBirth, dateOfBirth 필드는 실제로 관련이 있지만, 타입 정보에는 어떠한 관계도 표현되지 않았다.

interface Person {
  name: string;
  // These will either both be present or not be present
  placeOfBirth?: string;
  dateOfBirth?: Date;
}

Good

두 개의 속성을 하나의 객체로 모으는 것이 더 나은 설게이다.

interface Person {
  name: string;
  birth?: {
    place: string;
    date: Date;
  }
}
const alanT: Person = {
  name: 'Alan Turing',
  birth: {
// ~~~~ Property 'date' is missing in type
//      '{ place: string; }' but required in type
//      '{ place: string; date: Date; }'
    place: 'London'
  }
}

만약 타입구조를 손 댈 수 없는 상황(ex. API의 결과)이면 인터페이스의 유니온을 사용하여 속성 사이의 관계를 모델링 할 수 있다.

interface Name {
  name: string;
}

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

type Person = Name | PersonWithBirth;



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

string 타입의 범위는 매우 넒다. 모든 문자열을 할당할 수 있는 string 타입보다는 더 구체적인 타입을 사용하는 것이 좋다.

예시 1

음악 컬렉션을 만들기 위해 앨범의 타입을 정의한다고 가정해 보자

Bad

string 타입이 남발되어 해당 변수에 할당될 수 있는 값이 모호하다. 그리고 주석으로 타입을 설명하는 것은 바람직하지 않은 방법이다. (아이템 30 참고)

결과적으로 해당 인터페이스를 사용하였을 때 엉뚱한 값이 할당되어도 타입체커를 통과한다.

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',  // Oops!
  recordingType: 'Studio',  // Oops!
};  // OK

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

Good

변수의 범위를 보다 정확하게 표현하고 싶다면 string 타입보다는 문자열 리터럴 타입의 유니온을 사용하면 된다.

releaseDate 필드는 Date 객체를 사용하여 날짜 형식으로만 제한 recordingType 필드는 “live”와 “studio” 단 두 개의 값으로 유니온 타입을 정의

type RecordingType = 'studio' | 'live';

interface Album {
  artist: string;
  title: string;
  releaseDate: Date;
  recordingType: RecordingType;
}
function getAlbumsOfType(recordingType: RecordingType): Album[] {
  // COMPRESS
  return [];
  // END
}

예시 2

어떤 배열에서 한 필드의 값만 추출하는 함수를 작성한다고 가정해 보자.

Bad

타입 체크가 되긴 하지만 any 타입이 있어 정밀하지 못하다. 특히 반환 값에 any를 사용하는 것은 매우 좋지 않은 설계

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

Good

제네릭을 도입하여 아래와 같이 매개변수의 타입과 반환 타입을 좁힐 수 있다. 객체의 속성 이름을 함수 매개변수로 받을 때는 string보다 keyof T를 사용하는 것이 좋다.

타입 T는 매개변수인 배열의 요소의 타입 타입 K는 타입 T의 key를 뽐아낸 타입을 상속하는 타입. 추출하고자 하는 필드의 타입으로 사용

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



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

일반적으로 타입이 구체적일 수록 버그를 더 많이 잡고 타입스크립트가 제공하는 도구를 활용할 수 있게 된다. 하지만 부정확한 타입 설계는 없느니만 못한 결과를 낳는다. 그리고 타입정보를 구체적으로 만들 수록 오류 메시지와 자동 완성 기능에 주의를 기울여야 한다.

예시

JSON으로 정의된 Lisp와 비슷한 언어의 타입 선언을 작성한다고 생각해보자

참고 Lisp: 리스프(Lisp, LISP) 혹은 리습은 프로그래밍 언어의 하나로, 오랜 역사와 독특하게 괄호를 사용하는 문법으로 유명하다. Lisp은 “LISt Processing”(리스트 프로세싱)의 줄임말.

["+", 1, 2]                             // 3
["/", 20, 2]                            // 10
["case", [">", 20, 10], "red", "blue"]  // "red"
["rgb", 255, 0, 127]                    // "#FF007F"

Bad

MathCall, CaseCall, RGBCall로 고정 길이 배열을 튜플로 표현하였다. 그리고 튜플의 첫 번째 요소에 문자열 리터럴 타입을 사용하였다.

이렇게 정의된 타입들을 태그된 유니온 방법으로 CallExpression 타입을 정의하였다. 이렇게 설계한 타입으로 무효한 표현식을 체크하도록 하였다.

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

하지만 오류 메시지가 부정확해지는 결과를 낳았다. CaseCall 타입이 사용된 요소에서는 배열의 길이가 초과하여 오류가 발생하였는데 오류로 표현되어야 할 green이 아닌 MathCall 타입의 [”>”, 20, 10] 요소가 오류로 지목되었다.

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

또한 입력을 음수로 바꿔 주는 ’-‘는 한 개의 매개변수만 필요로 하는데 이를 위한 타입인 MathCall은 배열의 길이가 3으로 고정되어있기 때문에 사용하고자 하면 오류가 발생한다.

Good

코드를 더 정밀하게 만들려던 시도가 너무 과했고 그로 인해 코드가 오히려 더 부정확해졌다. 이렇게 부정확함을 바로잡는 방법을 쓰는 대신, 테스트 세트를 추가하여 놓친 부분이 없는지 확인하는 것이 나은 방법일 수 있다.



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

파일 형식, API, 명세(specification) 등 우리가 다루는 타입 중 최소한 몇 개는 프로젝트 외부에서 비롯된 것 이러한 경우 타입을 직접하지 작성하지 않고 자동으로 생성할 수 있다.

여기서 핵심은 예시 데이터가 아니라 명세를 참고해 타입을 생성한다는 것 예시 데이터를 참고해 타입을 생성하면 눈앞에 있는 데이터들만 고려하게 되므로 예기치 않은 곳에서 오류가 발생할 수 있으나 명세를 이용해 타입을 생성하면 타입스크립트는 사용자가 실수를 줄일 수 있게 도와준다.

예시

GraphQL API는 타입스크립트와 비슷한 타입 시스템을 사용하여, 가능한 모든 쿼리와 인터페이스를 명세하는 스키마로 이루어 진다. 우리는 이러한 인터페이스를 사용해서 특정 필드를 요청하는 쿼리를 작성한다.

GraphQL의 장점은 특정 쿼리에 대해 타입스크립트 타입을 생성할 수 있다는 것이다. GeoJSON 예제와 마찬가지로 GraphQL을 사용한 방법도 타입에 null이 가능한지 여부를 정확하게 모델링할 수 있다.

다음 예제는 GitHub 저장소에서 오픈 소스 라이선스를 조회하는 쿼리

query getLicense($owner: String!, $name:String!) {
  repository(owner:$owner, name:$name) {
    description
    licenseInfo {
      spdxId
      name
    }
  }
}

$owner와 $name은 타입이 정의된 GraphQL의 변수이다. String은 GraphQL의 타입으로 타입스크립트의 string에 대응 타입 뒤의 ’!’는 null이 아님을 명시

Apollo는 GraphQL 쿼리를 타입스크립트 타입으로 변환해 주는 도구 중 하나 쿼리에서 타입을 생성하려면 GraphQL 스키마가 필요하다. Apollo는 api.github.com/graphql로 부터 스키마를 얻는다.

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

실행결과는 다움과 같다.

export interface getLicense_repository_licenseInfo {
  __typename: "License";
  /** Short identifier specified by <https://spdx.org/licenses> */
  spdxId: string | null;
  /** The license full name specified by <https://spdx.org/licenses> */
  name: string;
}

export interface getLicense_repository {
  __typename: "Repository";
  /** The description of the repository. */
  description: string | null;
  /** The license associated with the repository */
  licenseInfo: getLicense_repository_licenseInfo | null;
}

export interface getLicense {
  /** Lookup a given repository by the owner and repository name. */
  repository: getLicense_repository | null;
}

export interface getLicenseVariables {
  owner: string;
  name: string;
}

주목할 만한 점은 다음과 같다.

  • 쿼리 매개변수(getLicenseVariables)와 응답(getLicense) 모두 인터페이스가 생성괴었다.
  • null 가능 여부는 스키마로부터 응답 인터페이스로 변환되었다.
  • 편집기에 확인할 수 있도록 주석은 JSDoc으로 변환되었다(아이템 48), 이 주석들은 GraphQL 스키마로부터 생성되었다.



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

엄선된 타입, 속성, 변수의 이름은 의도를 명학히 하고 코드와 타입의 추상화 수준을 높여 준다. 잘못 선택한 타입 이름은 코드의 의도를 왜곡하고 잘못된 개념을 심어주게 된다.

코드로 표현하고자 하는 모든 분야에는 주제를 설명하기 위한 전문 용어들이 있다. 이런 용어들을 사용하면 소통에 유리하고 타입의 명확성을 올릴 수 있다.

주의할 점 3가지

  • 동일한 의미를 나타낼 때는 같은 용어를 사용한다.
  • data, info, thing, item, object, entity 같은 모호하고 의미없는 이름은 피한다.

만약 entity라는 용어가 해당 분야에서 특별한 의미를 가진다면 괜찮디.

  • 이름을 지을 때는 포함된 내용이나 계산 방식이 아니라 데이터 자체가 무엇인지를 고려해야 한다.

예를 들어, INodeList 보다는 Directory가 더 의미 있는 이름. Directory는 구현의 측면이 아닌 개념적인 측면에서 보는 것

예시

동물들의 데이터베이스를 구축한다고 가정해 보자

Bad

  • name: 매우 일반적인 용어, 동물의 학명인지 일반적인 명칭인지 알 수 없음
  • endangered: 멸종 위기를 표현하기 위해 boolean 타입을 사용한 것이 맞지 않음
  • habitat: 너무 넓은 범위의 string 타입이며, 서식지를 뜻하는 지도 불분명
interface Animal {
  name: string;
  endangered: boolean;
  habitat: string;
}

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

Good

  • name을 commonName, genus, species 등 더 구체적인 용어로 대체
  • endangered를 status로 바꾸고, 동물 보호 등급에 대한 표준 체계를 사용하였다.
  • habitat을 climates으로 바꾸고 쾨펜 기후 분류를 사용하였다.
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
};



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

타입스크립트는 구조적 타이핑을 사용하기 때문에, 값을 세밀하게 구분하지 못하는 경우가 있다. 값을 구분하기 위해 공식 명칭이 필요하다면 상표를 붙이는 것을 고려해야 한다.

상표 기법은 타입 시스템에서 동작하지만 런타임에서 상표를 검사하는 것과 동일한 효과를 얻을 수 있다. 타입 시스템이기 때문에 런타임 오버헤드를 없앨 수 있고 추가 속성을 붙일 수 없는 string이나 number 같은 내장 타입도 상표화 할 수 있다.

구조적 타이핑: 실제 구조와 정의에 의해 결정되는 타입 시스템

예시 1

아이템 4에서 나왔던 벡터의 길이를 계산하는 함수로 예를 들어 보자

Bad

calculateNorm 함수는 x, y 키를 가진 Vector2D 타입의 객체를 매개변수로 받고자 했지만 구조적 타이핑 특성 상 x, y 키를 가지고 있다면 다른 타입의 객체도 매개변수로 사용하여 호출 할 수 있다.

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

Good

calculateNorm 함수가 3차원 벡터를 허용하지 않게 하려면 공식 명칭(nominal-typing)을 사용하면 된다. 공식 명칭을 사용하는 것은, 타입이 아니라 값의 관점에서 Vector2D라고 말하는 것

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...

Better?

참고: Forcing excess-property checking on variable passed to TypeScript function - stackoverflow

상표를 사용하지 않고 잉여속성을 체크하는 타입을 만들어 적용. 원하는 곳에서 에러가 발생하나 에러메시지가 명확하지 않음

type StrictPropertyCheck<T, TExpected> = Exclude<keyof T, keyof TExpected> extends never ? T & TExpected : never;

interface Vector2D {
  x: number;
  y: number;
}

function calculateNorm<T>(p: StrictPropertyCheck<T, Vector2D>) {
  return Math.sqrt(p.x * p.x + p.y * p.y);
}

calculateNorm(vec3D);
           // ~~~~~ Argument of type '{ x: number; y: number; z: number; }' is not assignable to parameter of type 'never'.

예시 2

절대 경로를 사용해 파일 시스템에 접근하는 함수를 가정해 보자

Good

string 타입이면서 _brand 속성을 가지는 객체를 만들 수는 없다. 타입 AbsolutePath는 온전히 타입 시스템의 영역

만약 path 값이 절대 경로와 상대 경로 둘 다 될 수 있다면, 타입을 정제해 주는 타입 가드를 사용해서 오류를 방지할 수 있다.

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'
}

디지엠유닛원 주식회사

  • 대표이사 권혁태
  • 개인정보보호책임자 정경영
  • 사업자등록번호 252-86-01619
  • 주소
    서울특별시 금천구 가산디지털1로 83, 6층 601호(가산동, 파트너스타워)
  • 이메일 unit1@dgmit.com