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

dev_hee·2023년 1월 26일
0

TypeScript

목록 보기
4/6

본 문은 이펙티브 타입스크립트를 읽고 알게된 내용을 정리한 글입니다.


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

효과적으로 타입을 설계하려면 유효한 상태만 표현할 수 있는 타입을 만드는게 중요하다.

웹 애플리케이션을 만든다고 가정해보자.
페이지를 선택하면, 페이지의 내용을 로드하고 화면에 표시한다. 페이지 상태는 다음과 같다.

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

페이지를 그리는 renderPage 함수를 작성할 때는 상태 객체의 필드를 전부 고려해서 상태 표시를 분기해야 한다.

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

위 코드는 분기 조건이 명확히 분리되어 있지 않다.
isLoading === true 임과 동시에 error 값이 존재하면 로딩 중인 상태인지 오류가 발생한 상태인지 명확히 구분할 수 없다. 필요한 정보가 부족하기 때문이다.

한편 페이지를 전환하는 changePage 함수는 다음과 같다.

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

changePage는 많은 문제점이 있다.

  • 오류가 발생했을 때 state.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 }
}

네트워크 요청 과정 각각의 상태를 명시적으로 모델링하는 태그된 유니온이 사용되었다.
코드의 길이는 길어졌지만, 무효한 상태를 허용하지 않도록 크게 개선되었다. 이를 기반으로 개선된 renderPage, changePage 함수는 다음과 같다.

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

이젠 모호함이 완전이 사라졌다. 모든 요청은 정확히 하나의 상태로 맞아 떨어지며, 요청이 진행중일 때 사용자가 페이지를 변경하여도 문제가 없다.


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


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


[아이템 31] 타입 주변에 Null 값 배치하기

값이 전부 null이거나 전부 null이 아닌 경우로 분명히 구분된다면, 값이 섞여 있을 때 보다 다루기 쉽다.

타입에 null을 추가하는 방식으로 이런 경우를 모델링 할 수 있다.

// tsConfig: {"strictNullChecks":false}

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)
    }
  }
  return [min, max]
}

위 코드는 타입 체커를 통과(strictNullChecks가 false일 때)하고, 반환 타입은 number[] 로 추론된다.
하지만 여기에 버그와 함께 설계적 결함이 존재한다.

  • 최소값이나 최대값이 0인 경우, 값이 덧씌워져 버린다. 예를들어 extent([0, 1, 2])의 결과는 [0,1] 가 아니라 [1,2] 이다.

  • nums 배열이 비어있다면 함수는 [undefined, undefined] 를 반환한다. undefined 를 포함하는 객체는 다루기 어렵고 권장되지 않는다.

strictNullChecks 설정을 켜면 앞의 두 가지 문제점이 드러난다.

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

extent의 반환값이 (number | undefined)[]로 추론되어 설계적 결함이 분명해 졌다.
이제 extent를 호출하는 곳마다 타입 오류가 발생한다.

const [min, max] = extent([0, 1, 2])
const span = max - min
// ~~~   ~~~ Object is possibly 'undefined'

extent 함수의 오류는 undefined를 min에서만 제외했고 max에서는 제외하지 않았기 때문에 발생한 오류다.

두 개의 변수는 동시에 초기화되지만, 이런 정보는 타입 시스템에서 표현할 수 없다. max에 대한 체크를 추가해 오류를 해결할 수도 있지만 버그가 더 늘어날것이다.

더 나은 해법을 찾아보자. 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
}

이젠 반환 타입이 [number, number] | null 이어서 사용하기 더 수월해졌다.
null 아님 단언(!)을 사용하면 minmax를 얻을 수 있다.

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

또는 if 구문으로 체크할 수도 있다.

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

extent의 결과값을 단일 객체를 사용함으로써 설계를 개선했고, 타입스크립트가 null 값 사이의 관계를 이해할 수 있도록 했으며 버그도 제거했다.

null과 null이 아닌 값을 섞어서 사용하면 클래스에서도 문제가 생긴다.

예를 들어, 사용자와 그 사용자의 포럼 게시글을 나타내는 클래스를 가정해보자.

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() {
    // ...?
  }
}

const userPosts = new UserPosts()

userPosts.getUserName() // null

// 초기화 해주어야함
const [user, posts] = await userPosts.init(userId)
userPosts.user = user
userPosts.posts = posts

userPosts.getUserName() // ok

두 번의 네트워크 요청이 로드되는 동안 user와 posts의 속성은 null 상태이다. 어떤 시점에는 둘 다 null
이거나, 둘 중 하나만 null이거나, 둘 다 null이 아닐것이다. 총 네 가지 경우가 존재한다.

속성값의 불확실성이 클래스의 모든 메서드에 나쁜 영향을 미친다. 결국 null체크가 난무하고 버그를 양산하게 된다.

설계를 개선해보자. 필요한 데이터가 모두 준비된 후에 클래스를 만들도록 바꾼다.

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

const userPosts = UserPosts.init(userId)
userPosts.getUserName() // ok

이제 UserPosts 클래스는 완전히 null이 아니게 되었고, 메서드를 작성하기 쉬워졌다. 물론 이 경우에도 데이터가 부분적으로 준비되었을 때 작업을 시작해아한다면, null과 null이 아닌 경우의 상태를 다뤄야한다.

요약

  • 한 값의 null 여부가 다른 값의 null 여부에 암시적으로 관련되도록 설계하면 안된다.
  • API 작성 시에는 반환 타입을 큰 객체로 만들고, 반환 타입 전체가 null이거나 null이 아니게 만들어야한다.
  • 클래스를 만들 때는 필요한 모든 값이 준비되었을 때 생성하여 null이 존재하지 않도록 하는 것이 좋다.
  • strictNullChecks 를 설정하면 코드에 많은 오류가 표시되지만, null과 관련된 문제점을 찾아낼 수 있기 때문에 반드시 필요하다.

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

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

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

interface Layer {
  layout: FillLayout | LineLayout | PointLayout
  paint: FillPaint | LinePaint | PointPaint
}
  • layout 속성은 모양이 그려지는 방법과 위치를 제어
  • paint 속성은 스타일(파란선, 굵은선,...)을 제어

layout 이 LineLayout 타입이면서 paint 속성이 FillPaint 타입인 것은 말이 되지 않는다.
이런 조합을 허용한다면 라이브러리에서는 오류가 발생하기 십상이고 인터페이스를 다루기도 어려울 것이다.

더 나은 방법은 각각의 타입의 계층을 분리된 인터페이스로 둬야한다.

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

이런 형태로 Layer를 정의하면 layout과 paint속성이 잘못된 조합으로 섞이는 경우를 방지할 수 있다.
이 코드에서는 아이템 28의 조언에 따라 유효한 상태만을 표현하도록 타입을 정의했다.

이런 패턴의 가장 일반적인 예시는 태그된 유니온이다.
Layer의 경우 속성 중의 하나는 문자열 리터럴 타입의 유니온이 된다.

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

type 속성은 '태그'이며 런타임에 어떤 타입의 Layer가 사용되는지 판단하는데 쓰인다.
타입스크립트는 태그를 참고하여 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
  }
}

각 타입의 속성들 간의 관계를 제대로 모델링하면 타입스크립트가 코드의 확장성을 체크하는데 도움이 된다. 다만 타입 분기 후 layer가 포함된 동일한 코드가 반복되는 것이 어수선해 보인다.

다음 예제를 보자

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

타입 정보를 담고 있는 주석은 문제가 될 소지가 높다.
placeOfBirth, dateOfBirth 필드는 실제로 관련되어 있지만, 타입 정보에는 어떤 관계도 표현되어 있지 않다.

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

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

이젠 정상적으로 place만 있고 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',
  },
}

Person 객체를 매개변수로 받는 함수는 birth속성 하나만 체크하면 된다.

function eulogize(p: Person) {
  console.log(p.name)
  const { birth } = p
  if (birth) {
    console.log(`was born on ${birth.date} in ${birth.place}.`)
  }
}

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

interface Name {
  name: string
}

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

type Person = Name | PersonWithBirth

이제 중첩된 객체에서도 동일한 효과를 볼 수 있다.

type Person = Name | PersonWithBirth
function eulogize(p: Person) {
  if ('placeOfBirth' in p) {
    p // Type is PersonWithBirth
    const { dateOfBirth } = p // OK, type is Date
  }
}

요약

  • 유니온 타입의 속성을 여러 개 가지는 인터페이스에서는 속성 간의 관계가 분명하지 않기 때문에 주의해야 한다.
  • 유니온 인터페이스보다 인터페이스의 유니온이 더 정확하다.
  • 타입스크립트가 제어 흐름을 분석할 수 있도록 타입에 태그를 넣는 것이 좋다. 태그된 유니온은 타입스크립트와 매우 잘 맞는 패턴이기 때문이다.

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

string 타입의 범위는 매우 넓다. string 타입으로 변수를 선언하려 할때, 혹시 그보다 더 좁은 타입이 적절하지 않을지 검토해야한다.

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

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

string 타입이 남발된 모습이다. 주석으로 타입 정보를 적어두는 것은 잘못된 설계이다.
다음 예시처럼 Album 타입에 엉뚱한 값을 설정할 수 있다.

const kindOfBlue: Album = {
  artist: 'Miles Davis',
  title: 'Kind of Blue',
  releaseDate: 'August 17th, 1959', // Oops!
  recordingType: 'Studio', // Oops!
} // OK

releaseDate, recordingType 모두 잘못 되었지만 문자열 타입이므로 타입 체커를 통과한다.
또한 string 타입의 범위가 넓기 때문에 제대로된 Album객체를 사용하더라도 매개변수 순서가 잘몬된 것이 오류로 드러나지 않는다.

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

이렇듯 string 타입이 남용된 코드를 "문자열을 남발하여 선언되었다(stringly typed)"고 표현한다.

타입의 범위를 좁혀보자.

type RecordingType = 'studio' | 'live'

![](https://velog.velcdn.com/images/heelieben/post/d069f77a-8d59-4dcd-9690-5552ca3ff0e6/image.png)

RecordingType 대신에 enum을 사용할 수도 있지만 일반적으로 추천하지는 않는다.(아이템 53)

이렇게 수정하면 오류를 더 세밀하게 체크할 수 있다.

const kindOfBlue: Album = {
  artist: 'Miles Davis',
  title: 'Kind of Blue',
  releaseDate: new Date('1959-08-17'),
  recordingType: 'Studio',
  // ~~~~~~~~~~~~ Type '"Studio"' is not assignable to type 'RecordingType'
}

string 보다 더 좁은 타입을 사용하는 것에 대한 이점

1. 타입을 명시적으로 정의함으로써 다른 곳으로 값이 전달되어도 타입 정보가 유지된다.

특정 레코딩 타입의 앨범을 찾는 함수를 작성한다면 다음처럼 정의할 수 있다.

function getAlbumsOfType(recordingType: string): Album[] {
  // ...
}

recordingType이 string이기 때문에 이 함수를 사용할 때 어떤 값을 넘겨주어야 하는지 알 수 없다.

따라서 아래 처럼 string 보다 구체적인 타입을 설정해야한다.

function getAlbumsOfType(recordingType: RecordingType): Album[] {
  // ...
}

2. 타입을 명시적으로 정의하고 해당 타입의 의미를 설명하는 주석을 붙여 넣을 수 있다.

/** What type of environment was this recording made in?  */
type RecordingType = 'live' | 'studio'

이렇게 함수를 사용하는 곳에서 주석 설명을 볼 수 있기 때문에 편리하다.

3. keyof 연산자로 더욱 세밀하게 객체의 속성 체크가 가능해진다.

함수의 매개변수에서 string을 잘못 사용하는 일은 흔하다.
어떤 배열에서 한 필드의 값만 추출하는 함수를 작성해보자.
언더스코어 라이브러리에 pluck 이라는 함수가 있다.

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

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

function pluck<T>(record: T[], key: string): any[] {
  return record.map(r => r[key])
  // ~~~~~~ Element implicitly has an 'any' type
  //        because type '{}' has no index signature
}

위는 제너릭 타입으로 타입을 개선한 코드이다.
key 타입이 string 이기 때문에 범위가 너무 넓다는 오류를 발생시킨다.

key는 네 개의 값("artist" | "title" | "releaseDate" | "recordingType")만 유효하다.
keyof 를 사용해서 키값을 유니온 타입으로 뽑아낼 수 있다.

type K = keyof Album
// "artist" | "title" | "releaseDate" | "recordingType"

이제 정상적으로 타입 체커를 통과하며, 반환 타입을 추론할 수 있게 되었다. 예제

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

하지만 반환타입의 범위가 너무 넓어져서 적절한 타입이라고 보기 어렵다.

const releaseDates = pluck(albums, 'releaseDate'); // (string | Date)[]

뒤의 반환 타입은 (string | Date)[] 가 아니라 Date[] 이여야 한다.

범위를 더 좁히기 위해 keyof T 의 부분집합으로 두 번째 제너릭 매개변수를 도입해야 한다. 예제

extends 키워드를 사용해 타입 시그니처를 작성하면 다음과 같다. 자동완성도 제공된다.

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

const releaseDates = pluck(albums, 'releaseDate'); // Date[]

요약

  • 문자열을 남발하여 선언된 코드를 피하자. 조금 더 구체적인 타입을 사용해야 한다.
  • 변수의 범위를 보다 정확하게 표현하고 싶다면 string 타입보다는 문자열 리터럴 타입의 유니온을 사용하자.
  • 객체의 속성 이름을 함수 매개변수로 받을 때는 string 보단 keyof T 를 사용하자.

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

타입 선언의 정밀도를 높이는 일에는 주의해야한다. 실수가 발생하기 쉽고 잘못된 타입은 차라리 타입이 없는 것보다 못할 수 있기 때문이다.

아이템 31에 보았던 GeoJSON 형식의 타입 선언을 작성한다고 가정해보자.
GeoJSON 정보는 각각 다른 형태의 좌표 배열을 가지는 몇 가지 타입 중 하나가 될 수 있다.

interface Point {
  type: 'Point'
  coordinates: number[]
}
interface LineString {
  type: 'LineString'
  coordinates: number[][]
}
interface Polygon {
  type: 'Polygon'
  coordinates: number[][][]
}
type Geometry = Point | LineString | Polygon // 다른 것도 추가될 수 있다.

좌표의 number[] 가 좀 추상적이다. 여기선 튜플 타입으로 선언하는 것이 더 좋다.

type GeoPosition = [number, number]
interface Point {
  type: 'Point'
  coordinates: GeoPosition
}
// ...

타입을 구체화 했기 때문에 더 나은 코드가 되었을까?
타입에 위도와 경도만 존재하고 GeoJSON에 세 번째 요소인 고도와 같이 다른 정보가 있을 수도 있기 때문에 좋지 않은 코드가 되었다.

결과적으로 타입 선언을 세밀하게 하고자 했지만 너무 과했고 오히려 타입이 부정확해졌다.

이번에는 JSON으로 정의된 Lisp와 비슷한 언어의 타입 선언을 작성해보자.

// 맵박스 라이브러리
10
'red'
['+', 1, 2] // 3
['/', 20, 2] // 10
['case', ['>', 20, 10], 'red', 'blue'] // "red"

이런 동작을 모델링 하면 입력값 전체 종류가 다음과 같다.

  1. 모두 허용
  2. 문자열, 숫자, 배열 허용
  3. 문자열, 숫자, 알려진 함수 이름으로 시작하는 배열 허용
  4. 각 함수가 받는 매개변수의 개수가 정확한지 확인
  5. 각 함수가 받는 매개변수의 타입이 정확한지 확인

처음 두 개의 옵션은 간단하다.

type Expression1 = any
type Expression2 = number | string | any[]

또한 표현식의 유효성을 체크하는 테스트 세트를 도입해보자. 타입을 구체적으로 만들수록 정밀도가 손상되는 것을 방지하는데 도움이 된다.(아이템 52)

type Expression2 = number | string | any[]

const tests: Expression2[] = [
  10,
  'red',
  true,
  // ~~~ Type 'true' is not assignable to type 'Expression2'
  ['+', 10, 5],
  ['case', ['>', 20, 10], 'red', 'blue', 'green'], // Too many values
  ['**', 2, 31], // Should be an error: no "**" function
  ['rgb', 255, 128, 64],
  ['rgb', 255, 0, 127, 0], // Too many values
]

정밀도를 끌어올리기 위해 튜플의 첫 번째 요소에 문자열 리터럴 타입의 유니온을 사용해보자. 예제

type FnName = '+' | '-' | '*' | '/' | '>' | '<' | 'case' | 'rgb'
type CallExpression = [FnName, ...any[]]
type Expression3 = number | string | CallExpression

const tests: Expression3[] = [
  10,
  'red',
  true,
  // ~~~ Type 'true' is not assignable to type 'Expression3'
  ['+', 10, 5],
  ['case', ['>', 20, 10], 'red', 'blue', 'green'],
  ['**', 2, 31],
  // ~~~~~~~~~~~ Type '"**"' is not assignable to type 'FnName'
  ['rgb', 255, 128, 64],
]

정밀도를 유지하면서 오류를 하나 더 잡았다. (Type '"**"' is not assignable to type 'FnName')

각 함수의 매개변수 개수가 정확한지 확인하기 위해 모든 함수 호출을 확인할 수도 있지만 재귀적으로 동작하기 때문에 좋은 방법은 아니다.

타입스크립트 3.6에선 함수의 매개변수 개수를 알아내기 위해 최소한 하나의 인터페이스를 추가해야 한다. 여러 인터페이스를 호출 표현식으로 한번에 묶을 수는 없어서 각 인터페이스를 나열해서 호출 표현식을 작성한다.

고정 길이 배열(유사 배열 객체)은 튜플 타입으로 가장 간단히 표현할 수 있기 때문에, 어색해 보일 수는 있지만 다음 코드처럼 구현할 수 있다.

본문 코드 예제는 3.6버전의 에러 메시지를 보여준다.

// 3.6
type CallExpression = MathCall | CaseCall | RGBCall
type Expression4 = number | string | CallExpression

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

이제 무효한 표현식에서 전부 오류가 발생하지만, 오류가 나면 엉뚱한 메시지를 출력하는데 "**"에 대한 오류는 이전 버전보다 메시지가 더 부정확해진다. (Type '["**", number, number]' is not assignable to type 'string)

타입 정보가 정밀해졌지만 이전 버전보다 개선되었다고 보기 어렵다.
예를 들어 + 와 *가 더 많은 매개변수를 받을 수 있고, 입력을 음수로 바꿔주는 -는 한 개의 매개변수만 필요로 한다.
Expression4는 이런 경우에 잘못된 오류를 표시하게 된다.

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

이렇듯 매우 추상적인 타입 any를 사용하는 것은 좋지 않지만, 그렇다고 너무 구체적으로 정제된다고 해서 정확도가 올라가는 것은 아니다.

요약

  • 타입이 없는 것보단 잘못된 것이 더 나쁘다.
  • 정확하게 타입을 모델링할 수 없다면, 부정확하게 모델링하지 말아야 한다. 또한 anyunknown을 구별해서 사용해야 한다.
  • 타입 정보를 구체적으로 만들수록 오류 메시지와 자동 완성 기능에 주의를 기울여야 한다.

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

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

핵심은 예시 데이터가 아니라 명세를 참고해 타입을 생성한다는 것이다.

예시 데이터는 눈 앞의 데이터만 고려하게 되어 예기치 않은 오류를 발생할 수 있다. 명세는 사용자의 실수를 줄여준다.

아이템 29에서 경계 상자를 계산하는 (책에선 아이템31이라고 하는데.. 29인듯) calculateBoundingBox를 사용했다. 실제 구현은 다음과 같은 모습이다.

interface BoundingBox {
  lat: [number, number]
  lng: [number, number]
}

function calculateBoundingBox(f: Feature): BoundingBox | null {
  let box: BoundingBox | null = null

  const helper = (coords: any[]) => {
    // ...
  }

  const { geometry } = f
  if (geometry) {
    helper(geometry.coordinates)
  }

  return box
}

Feature 타입은 데이터 예제를 사용해 작성해볼 수 있지만, 공식 GeoJSON 명세에 나와있는 타입 선언을 가져와 추가해 사용할 수 있다. 예제

npm i --save-dev @types/geojson
// requires node modules: @types/geojson

interface BoundingBox {
  lat: [number, number]
  lng: [number, number]
}
import { Feature } from 'geojson'

function calculateBoundingBox(f: Feature): BoundingBox | null {
  let box: BoundingBox | null = null

  const helper = (coords: any[]) => {
    // ...
  }

  const { geometry } = f
  if (geometry) {
    helper(geometry.coordinates)
    // ~~~~~~~~~~~
    // Property 'coordinates' does not exist on type 'Geometry'
    //   Property 'coordinates' does not exist on type
    //   'GeometryCollection'
  }

  return box
}

geometry에 coordinates가 있을것이라 가정한 것이 문제다. GeoJSON은 다양한 도형의 모음(List)인 GeometryCollection일 수도 있다. GeometryCollection에는 coordinates가 없다.

이 오류를 고치기 위해서 GeometryCollection를 명시적으로 차단할 수 있다. 예제

// requires node modules: @types/geojson

import { Feature } from 'geojson'
declare let f: Feature

function helper(coordinates: any[]) {}

const { geometry } = f

if (geometry) {
  if (geometry.type === 'GeometryCollection') {
    throw new Error('GeometryCollections are not supported.')
  }
  helper(geometry.coordinates) // OK
}

하지만 GeometryCollection 을 차단하는 것 보다는 리스트를 순회하며 helper를 호출 하는 것이 모든 타입을 지원하기 때문에 더 좋은 방법이다. 예제

// requires node modules: @types/geojson

import { Feature, Geometry } from 'geojson'

declare let f: Feature

function helper(coordinates: any[]) {}

const geometryHelper = (g: Geometry) => {
  if (g.type === 'GeometryCollection') {
    g.geometries.forEach(geometryHelper)
  } else {
    helper(g.coordinates) // OK
  }
}

const { geometry } = f
if (geometry) {
  geometryHelper(geometry)
}

책에서 예제가 잘못된 것 같다. geometryHelper에서 전역 변수 geometry를 사용하는게 아니라 매개변수(지역변수) g를 사용해야한다.

api 호출에도 비슷한 상황이 적용된다. api 명세로부터 타입을 생성할 수 있다면 그렇게 하는 것이 좋다.

특히 GraphQL처럼 자체적으로 타입이 정의된 API에서 잘 동작한다.
GraphQL 쿼리를 타입스크립트 타입으로 변환해주는 많은 도구가 존재하는데 그중 Apollo 를 사용하면 좋다.

요약

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

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

이름 짓기는 타입 설계에서 중요하다.
엄선된 타입, 속성, 변수 이름은 의도를 명확히하고 코드와 타입의 추상화 수준을 높여준다.
잘못 선택한 타입 이름은 코드의 이름을 왜곡하고 잘못된 개념을 심어준다.

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

interface Animal {
  name: string
  endangered: boolean
  habitat: string
}

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

위는 네 가지 문제점이 있다.

  • name 은 매우 일반적인 용어이다. 동물의 학명인지 일반적인 명칭인지 알 수 없다.
  • endangered 속성이 멸종 위기를 표현하기 위해 불리언 타입을 사용한 것이 이상하다.
  • habitat 은 서식지를 나타내는데, 범위가 너무 넓은 string 타입이고, 서식지라는 뜻 자체도 불분명하다.
  • 객체의 변수명이 leopard 이지만 name 속성값은 Snow Leopard 이다. 객체 이름과 name 속성이 다른 의도로 사용된건지 불분명하다.

하지만 다음 코드의 타입 선언은 의미가 분명하다.

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'], // 고산대 또는 아고산대
}

이 코드는 다음 세 가지를 개선하였다.

  • namecommonName, genus, species 로 더 구체적인 용어로 대체했다.
  • endangered는 동물 보호 등급에 대한 IUCN의 표준 분류 체계인 ConservationStatus 타입의 status로 변경되었다.
  • habitat은 기후를 뜻하는 climates로 변경되었으며, 쾨펜 기후 분류를 사용한다.

코드로 표현하고자 하는 모든 분야에는 주제를 설명하기 위한 전문 용어들이 있다. 자체적으로 용어를 만들어내려 하지말고 이미 존재하는 용어를 사용하여 명확성을 높이자.

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

타입, 속성, 변수에 이름을 붙일 때 명심해야하는 규칙이 있다.

1. 동일한 의미를 나타낼 때는 같은 용어를 사용해야 한다.

의미적으로 구분되어야 하는 경우에만 다른 용어를 사용하자.

2. 모호하고 의미없는 이름은 피해야한다.

data, info, thing, item, object, entity 같은 모호한 이름은 피해야한다. 만약 그 용어가 해당 분야에서 특별한 의미를 가지면 괜찮지만, 귀찮다고 의미없는 이름을 붙이면 안된다.

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

예를 들어, INodeList 보다는 Directory가 더 의미있는 이름이다. 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

calculateNorm는 2차원 벡터만을 사용해야 이치에 맞다.

상표 기법 (nominal typing)

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

공식 명칭/상표 (_barnd)를 사용해서 calculateNorm 함수가 Vector2D타입만 받는 것을 보장합니다. 하지만 vec3D값에 _barnd: '2d' 라고 추가하는 것 같은 악의적인 사용을 막을 수 없다.

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

예를 들어 절대 경로를 사용해 파일 시스템에 접근하는 함수를 가정해보자. 런타임에는 절대 경로('/')로 시작하는지 체크하기 쉽지만, 타입 시스템에서는 절대 경로를 판단하기 어렵기 때문에 상표 기법을 사용한다.

type AbsolutePath = string & { _brand: 'abs' } // string 타입 이면서 _brand 속성을 가지는 객체
function listAbsolutePath(path: AbsolutePath) {
  // ...
}
function isAbsolutePath(path: string): path is AbsolutePath {
  return path.startsWith('/')
}

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

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

function f(path: string) {
  if (isAbsolutePath(path)) { // 타입 가드
    listAbsolutePath(path)
  }
  listAbsolutePath(path)
  // ~~~~ Argument of type 'string' is not assignable
  //      to parameter of type 'AbsolutePath'
}

상표 기법은 타입 시스템 내에서 표현할 수 없는 수 많은 속성들을 모델링하는 데 사용되기도 한다.

예를 들어, 목록에서 한 요소를 찾기 위해 이진 검색을 하는 경우를 보자.

function binarySearch<T>(xs: T[], x: T): boolean {
  let low = 0,
    high = xs.length - 1
  while (high >= low) {
    const mid = low + Math.floor((high - low) / 2)
    const v = xs[mid]
    if (v === x) return true
    ;[low, high] = x > v ? [mid + 1, high] : [low, mid - 1]
  }
  return false
}

이진 검색은 이미 정렬된 상태에서 가정하기 때문에, 목록이 정렬되어 있다면 문제가 되지 않지만, 정렬되어 있지 않다면 잘못된 결과가 나온다. 타입스크립트 타입 시스템에서는 목록이 정렬되어 있다는 의도를 표현하기 어렵다.

따라서 다음 예제처럼 상표 기법을 사용해보자.

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 {
  // ...
}

binarySearch를 호출하려면 정렬되었다는 상표가 붙은 SortedList 타입 값을 사용하거나, isSorted를 호출해 정렬되었음을 증명해야한다.

isSorted 로 목록 전체를 순회하는 것은 효율적인 방법은 아니지만 안전성을 확보할 수 있다. (근데 이 예제에서 이진 탐색을 하는 의미가 없어지는 듯...)

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) // Type is Meters
const oneMin = seconds(60) // Type is Seconds

number 타입에 상표를 붙여도 산술 연산 후에는 상표가 없어지기 때문에 실제로 사용하기에는 무리가 있다. 예제

const tenKm = oneKm * 10 // Type is number
const v = oneKm / oneMin // Type is number

하지만 단위를 문서화 하는 경우엔 괜찮은 방법일 수 있다.

요약

  • 구조적 타이핑 때문에 값을 세밀하게 구분하지 못하는 경우가 있다.
  • 상표 기법은 타입 시스템에서 동작하지만 런타임에 상표를 검사하는 것과 동일한 효과를 얻을 수 있다.
profile
🎨그림을 좋아하는 FE 개발자👩🏻‍💻

0개의 댓글