스터디/이펙티브 타입스크립트

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

paran21 2024. 6. 1. 23:33

4장에서는 타입을 설계하는 것에 대해 다루고 있다. 이 내용은 타입스크립트 뿐만 아니라 다른 언어에도 적용할 수 있는 아이디어이다.

 

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

타입은 유효한 상태만 표현하도록 설계해야 한다.

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

 

State 타입은 isLoading이 true이면서 동시에 error 값이 설정될 수 있는데 이러한 상태는 존재할 수 없다(무효한 상태를 허용한다).

 

//isLoading이 true이고 error가 존재하면 로딩 중인지 오류가 발생한 것인지 알 수 없다.
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;  }
}

 

이 코드는 오류가 발생했을 때 isLoading을 false로 설정하는 로직이 빠져있고, state.error를 초기화하지 않았기 때문에 페이지 전환 중에 로딩 메시지 대신 과거의 오류 메시지를 보여주게 된다. 또, 페이지 로딩 중에 사용자가 페이지를 바꾸면 어떤 일이 벌어질지 예상하기 어렵다.

 

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

 

이렇게 유효한 상태(RequestPending, RequestError, RequestSuccess)만 존재하는 RequestState를 만들면, 현재 페이지에서 발생할 수 있는 모든 요청의 상태를 명시적으로 모델링할 수 있다.

 

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

 

함수가 명확해지고, 모든 요청이 하나의 상태로 일치한다.

 

interface CockpitControls {
  leftSideStick: number
  rightSideStick: number
}

 

비행기의 사이드 스틱이 분리되어 있어 좌우를 각각 속성으로 갖는 타입을 설계할 수 있다.

 

interface CockpitControls {
  stickAngle: number
}

 

그렇지만 대부분의 비행기는 두 개의 스틱이 기계적으로 연결되어 있기 때문에 singleAngle만으로 충분하다.

 

 

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

함수의 매개변수는 타입의 범위가 넓으면 사용하기 편리하지만, 반환 타입의 범위는 구체적인 것이 좋다.

declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;

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

 

이렇게 viewportForBuounds의 반환 타입이 다양하면, 유니온 타입의 각 요소별로 코드를 분기해야 한다.

 

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

 

매개변수와 반환타입의 재사용을 위해 기본 형태인 반환타입과 느슨한 형태의 매개변수 타입을 도입하는 것이 좋다.

 

 

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

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

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

 

특정 매개변수를 설명하고 싶다면 JSDoc의 @param 구문을 사용하면 된다.

 

/**
 * 애플리케이션 또는 특정 페이지의 전경색을 가져옵니다.
 * @param page 페이지 이름
 * @returns 전경색
 */
function getForegroundColor(page?: string): Color {
  // ...
}

 

 

값을 변경하지 않는다고 설명하는 주석 대신 readonly로 선언할 수 있다.

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

 

ageNum처럼 변수명에 타입을 명시하는 것보다 age로 하고 그 타입이 number임을 명시하는 게 좋다.

 

단위가 무엇인지 확실하지 않다면  timeMs처럼 변수명이나 속성 이름에 단위를 포함하는 것이 더 명확하다.

 

 

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

값이 전부 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)
      // ~~~ Argument of type 'number | undefined' is not
      //     assignable to parameter of type 'number'
    }
  }
  return [min, max]
}

 

undefined를 포함하는 객체는 다루기 어렵고 권장하지 않는다.

 

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
}

 

extent의 반환타입이 null이거나 null이 아닌 타입으로 선언하면 함수를 작성하기도 쉽고, 사용할 때도 null여부만 체크하면 된다.

 

const range = extent([0, 1, 2])
if (range) {
  const [min, max] = range
  const span = max - min
}

 

클래스에서도 null과 null이 아닌 값을 섞어서 사용하지 않는 것이 좋다.

 

class UserPosts {
  user: UserInfo | null;
  posts: Post[] | null;
  
  //...
  
  async init(userId: string) {
  return Promise.all([
    async () => this.user = await fetchUser(userId),
    async () => this.posts = await fetchPostsForUser(userId)
    ]);
  }
}

 

이렇게 null이거나 null이 아닌 값이 섞여있다면 null 체크가 많아지고 버그가 많아질 수 있다.

 

UserPosts를 null이 아닌 값으로 선언하고, 필요한 데이터가 모두 준비된 후에 클래스를 만드는 것이 낫다.

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

 

데이터가 부분적으로 준비되었을 때 작업을 시작해야 한다면, null과 null이 아닌 경우의 상태를 다루어야 한다.

 

 

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

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

interface Layer {
  layout: FillLayout | LineLayout | PointLayout
  paint: FillPaint | LinePaint | PointPaint
}

 

layout이 LineLayout이면서 paint가 FillPaint인 경우는 존재할 수 없다.

 

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가 사용되는지 체크할 수 있고, 타입의 범위를 좁힐 수 있다.

 

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

 

이렇게 주석을 사용하는 것보다 두 개의 객체를 하나로 모으는 것이 낫다.

 

interface Person {
  name: string
  birth?: {
    place: string
    date: Date
  }
}

 

타입의 구조에 손을 댈 수 없는 상황이라면, 인터페이스의 유니온을 사용해 속성 사이의 관계를 모델링할 수 있다.

 

interface Name {
  name: string
}

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

type Person = Name | PersonWithBirth