이펙티브 타입스크립트 - 03 (1)

📋 Item 19. 추론 가능한 타입을 사용해 장황한 코드 방지하기

타입스크립트를 처음 접한 개발자가 자바스크립트 코드를 포팅할 때 가장먼저 하는 일은 타입구문을 넣는 것인데, 타입스크립트의 많은 타입 구문은 사실 불필요하다.

다음과 같이 코드의 모든 변수에 타입을 선언하는 것은 비생산적이며 형편없는 스타일이다.

// bad
let x: number = 12
 
// good
let x = 12

VSCode(편집기)에서 변수 x에 마우스를 올려보면 number타입을 명시해주지 않아도 타입이 number로 추론되어 있음을 확인할 수 있다.

이렇게 타입이 추론된다면 명시적 타입 구문은 필요하지 않고, 오히려 방해가 된다..

타입스크립트는 더 복잡한 객체도 추론할 수 있으며, 함수가 어떤 타입을 반환하는지도 정확히 추론할 수 있다.

function square(nums: number[]) {
  return nums.map((x) => x * x)
}
 
// squares타입은 number[]로 추론할 수 있음
const squares = square([1, 2, 3, 4])

또한 타입스크립트는 우리가 예상한 것보다 더 정확하게 추론하기도 한다.

// 타입은 string
const axis1: string = 'x'
 
// 타입은 'y'
const axis2 = 'y'

axis2 변수를 string으로 예상하기 쉽지만 타입스크립트가 추론한 y가 더 정확한 타입이다.

이상적인 타입스크립트 코드는 함수/메서드 시그니처에 타입구문을 포함하지만, 함수 내에서 생성된 지역변수에는 타입구문을 넣지 않는다.

타입구문을 생략하여 방해되는 것들을 최소화하고 구현 로직에 집중할 수 있게 하는 것이 바람직하다.

하지만, 객체 리터럴을 정의할 때는, 타입이 추론될 수 있음에도 타입을 명시해주는것이 좋다.

const elmo: Product = {
  name: 'Tickle Me Elmo',
  id: '048188 521352',
  price: 28.99,
}

이렇게 타입을 명시하면 잉여속성체크가 동작해서 타입의 오타같은 오류를 잡는데 효과적이다.

객체 리터럴을 정의할 때 타입구문을 제거하면 잉여 속성체크가 동작하지 않고 객체를 선언한 곳이 아니라 객체가 사용되는 곳에서 타입오류가 발생할 것이다.

마찬가지로 함수의 반환에도 타입을 명시하여 오류를 방지하는 것이 좋은데,

오류의 위치를 표시해 주는 점 이외에도, 반환 타입을 명시해야 하는 이유가 두 가지 더 있다.

  1. 반환 타입을 명시하면 함수에 대해 더욱 명확하게 알 수 있다.

  2. 명명된 타입을 사용하기 위해서 이다. 예를 들어 아래와 같은 함수에서는 반환타입을 명시하지 않는경우를 살펴보자.

interface Vector2D {
  x: number
  y: number
}
function add(a: Vector2D, b: Vector2D) {
  return { x: a.x + b.x, y: a.y + b.y }
}

이럴경우 타입스크립트는 반환타입을 { x: number; y: number; } 로 추론한다.

이런경우 Vector2D 와 호환되지만 입력이 Vector2D 인데 반해 출력은 Vectord2D 가 아니기 떄문에 사용자 입장에서 당황스러울 수 있다.

이렇게 반환타입을 명시하면 더욱 직관적인 표현이 되고, 더 자세한 설명이 가능해진다.

📕 요약

  • 타입스크립트가 타입을 추론할 수 있다면 타입 구문을 작성하지 않는게 좋다.

  • 이상적인 경우 함수/메서드의 시그니처에는 타입구문이 있지만, 함수 내의 지역변수에는 타입 구문이 없다.

  • 추론될 수 있는 경우라도 객체 리터럴과 함수 반환에는 타입 명시를 고려해보자.

📋 Item 20. 다른 타입에는 다른 변수 사용하기

자바스크립트에서는 아래와 같이 하나의 변수를 다른 목적을 가지는 다른 타입으로 재사용해도 된다.

// string으로 사용
let id = '12-34-56'
fetchProduct(id)
 
// number로 사용
id = 123456
fetchProductBySerialNumber(id)

하지만 타입스립트에서는 위와같이 작성하면 오류가 발생한다.

// string으로 사용
let id = '12-34-56'
fetchProduct(id)
 
// number로 사용
id = 123456 // ERROR: 123456형식은 string형식에 할당할 수 없습니다.
fetchProductBySerialNumber(id) // string 형식의 인수는 number형식의 매개변수에 할당될 수 없습니다.

먼저 타입스크립트는 "12-34-56"을 보고 id의 타입을 string 으로 추론 했지만 string타입의 변수에 number 타입의 값을 할당 하려고 하기 떄문에 오류가 난다.

오류는 아래와 같이 고칠 수도 있다.

// string또는 number로 사용
let id: string | number = '12-34-56'
fetchProduct(id)
id = 123456
fetchProductBySerialNumber(id)

id 의 타입을 stringnumber 모두 포함하도록 타입을 확장하면 된다.

하지만 이러한 유니온 타입을 사용하면 동작은 하겠지만 더 많은 문제를 일으킬 수 있다.

아래와 같이 별도의 변수를 도입하는게 더 좋은 방법니다.

let id = '12-34-56'
fetchProduct(id)
 
const serial = 123456
fetchProductBySerialNumber(id)

이렇게 다른 타입에는 별도의 변수를 사용하는게 바람직한 이유는 아래와 같다.

  • 서로 관련이 없는 두 개의 값을 분리한다.

  • 변수명을 더 구체적으로 지을 수 있다.

  • 타입 추론을 향상시키며, 타입구문이 불필요해진다.

  • 타입이 간결해진다.

이렇게 타입이 바뀌는 변수는 되도록 피해야 하며, 목적이 다른 곳에서는 별도의 변수명을 사용하도록 하자!!

📕 요약

  • 변수의 값은 바뀔 수 있지만 타입은 일반적으로 바뀌지 않는다.

  • 혼란을 막기 위해 타입이 다른값을 다룰 때에는 변수를 재사용하지 않도록 하자.

📋 Item 21. 타입 넓히기

타입스크립트는 코드를 체크하는 정적 분석 시점에, 변수는 가능한 값들의 집합인 타입을 가진다.

상수를 사용해서 변수를 초기화 할때 타입을 명시하지 않으면 타입체커는 타입을 결정해야 한다.

이말은 지정된 값을 가지고 할당 가능한 값들의 집합을 유추해야 한다는 뜻이다.

타입스크립트에서 이런 과정을 타입 넓히기라고 한다.

interface Vector3 {
  x: number
  y: number
  z: number
}
function getComponent(vector: Vector3, axis: 'x' | 'y' | 'z') {
  return vector[axis]
}
 
let x = 'x'
let vec = { x: 10, y: 20, z: 30 }
getComponent(vec, x)
// ERROR: string형식의 인수는 'x'|'y'|'z'형식의 매개변수에 할당될 수 없다.

위의 함수는 런타임에 오류 없이 실행되지만, 편집기에서는 오류가 난다.

변수 x의 타입은 할당 시점에 넓히기가 동작해서 string 으로 추론되었는데, string 타입은 'x'|'y'|'z' 타입에 할당 불가하므로 오류가 나타나는 것이다.

타입 넓히기가 진행될 때 주어진 값으로 추론 가능한 타입이 여러개이기 때문에 과정이 모호하다.. 아래 예제로 살펴보자

const mixed = ['x', 1]

변수 mixed 타입이 추론되는 과정을 살펴보려고 하는데, 아래는 mixed 의 타입이 될 수 있는 후보들이다.

  • ('x' | 1)[]

  • ['x', 1]

  • [string, number]

  • readonly [string, number]

  • (string | number)[]

  • readonly (string | number)[]

  • [any, any]

  • any[]

정보가 충분하지 않다면 어떠한 타입으로 추론되어야 하는지 알 수 없다. 그러므로 타입스크립트는 작성자의 의도를 추측해야하는데 위와 같은 경우에는 (string|number)[] 로 추측한다.

이렇기 때문에 추측한 답이 항상 옳다고 할 순 없다.

이러한 문제점 때문에 타입스크립트는 넓히기 과정을 제어할 수 있도록 몇가지 방법을 제공해주고 있다.

첫번째 방법은, const 사용이다.

let 대신에 const 로 변수를 선언하면 더 좁은 타입이 된다.

실제로 위의 예제에서 let -> const로 변경하면 오류가 해결된다.

interface Vector3 {
  x: number
  y: number
  z: number
}
function getComponent(vector: Vector3, axis: 'x' | 'y' | 'z') {
  return vector[axis]
}
 
const x = 'x'
let vec = { x: 10, y: 20, z: 30 }
getComponent(vec, x) // 정상

여기서 x는 재할당 될 수 없으므로 string타입보다 더 좁은 타입인 'x'로 추론할 수 있게된다.

하지만 const 의 경우 객체와 배열의 경우에는 여전히 문제가 발생한다.

아래 예를들어보자

const v = {
  x: 1,
}
v.x = 3
v.x = '3'
v.y = 4
v.name = 'Pythagoras'

위의 코드는 자바스크립트에서는 정상으로 인식할 것이다. 하지만, 타입스크립트에서는 아래와 같은 오류가 난다.

const v = {
  x: 1,
}
v.x = 3 // 정상
v.x = '3' // ERROR: '3'형식은 number형식에 할당할 수 없다.
v.y = 4 // {x: number;} 형식에 y 속성이 없다.
v.name = 'Pythagoras' // {x: number;} 형식에 name 속성이 없다.

이러한 객체나 배열을 추론할 때 타입추론의 강도를 제어하려면 타입스크립트의 기본동작을 재정의 해야 한다.

타입스크립트의 기본동작을 재정의 하는 방법은 아래 3가지 방법이 있다.

  1. 명시적 타입구문을 제공한다.
// 타입이 {x: 1|3|5}
const v: { x: 1 | 3 | 5 } = {
  x: 1,
}
  1. 타입체커에 추가적인 문제를 제공한다.(함수의 매개변수로 값을 전달)

    이 부분은 아이템 26에서 더 자세히 다뤄볼 예정이다.

  2. const 단언문을 사용

const단언문과 변수 선언에 쓰이는 let이나 const랑 혼동해서는 안된다.

const단언문을 사용한 예제를 통해서 각 변수에 추론된 타입의 차이점을 알아보자

// 타입: {x: number, y: number}
const v1 = {
  x: 1,
  y: 2,
}
// 타입: {x: 1, y: number}
const v1 = {
  x: 1 as const,
  y: 2,
}
// 타입: { readonly x: 1, readonly y: 2}
const v1 = {
  x: 1,
  y: 2,
} as const

값 뒤에 as const를 작성하면 타입스크립트는 최대한 좁은 타입으로 추론한다.

배열을 튜플 타입으로 추론할 때에도 아래와 같이 as const를 사용할 수 있다.

// 타입: number[]
const a1 = [1, 2, 3]
// 타입: readonly [1, 2, 3]
const a1 = [1, 2, 3] as const

타입 넓히기로 오류가 발생한다고 생각되면, 명시적 타입구문 또는 const단언문을 추가하는것을 고려해보자.

📕 요약

  • 타입스크립트가 넓히기를 통해 상수의 타입을 추론하는 법을 이해하자

  • 동작에 영향을 줄 수 있는 방법인 const, 타입 구문, 문맥, as const에 익숙해지자.

📋 Item 22. 타입 좁히기

타입 좁히기는 타입 넓히기의 반대이다.

타입좁히기는 타입스크립트가 넓은 타입으로부터 좁은 타입으로 진행되는 과정을 말한다.

여기서 가장일반적인 예시인 null체크를 예들들어보자

const el = document.getElementById('foo') // el 타입이 HTMLElement | null
if (el) {
  el.innerHTML = 'Party Time' // el 타입이 HTMLElement
} else {
  alert('No element') // el 타입이 null
}

타입체커는 일반적으로 이러한 조건문에서 타입좁히기를 하지만, 타입 별칭이 존재한다면 못할 수도 있다.

조건문이외에도 instanceof속성을 사용하거나 내장함수를 사용해서 타입을 좁힐 수도 있다.

잘못된 방법도 한번 살펴보자

function foo(x?: number | string | null) {
  if (!x) {
    x // 타입이 string | number | null | undefined
  }
}

여기서 빈문자열 ''0 모두 false가 되기 때문에, 타입은 전혀 좁혀지지 않았다.

타입을 좁히는 또 다른 일반적인방법은 명시적 태그를 사용하는 것이다

interface UploadEvent {
  type: 'upload'
  filename: string
  contents: string
}
interface DownloadEvent {
  type: 'download'
  filename: string
}
type AppEvent = UploadEvent | DownloadEvent
function handleEvent(e: AppEvent) {
  switch (e.type) {
    case 'download':
      e // 타입이 DownloadEvent
      break
    case 'upload':
      e // 타입이 UploadEvent
      break
  }
}

또한 사용자 정의 타입가드인 커스텀 함수를 도입할 수도 있다.

function isInputElement(el: HTMLElement): el is HTMLInputElement {
  return 'value' in el
}
 
function getElementContent(el: HTMLElement) {
  if (isInputElement(el)) {
    el // 타입이 HTMLInputElement
    return el.value
  }
  el // 타입이 HTMLElement
  return el.textContent
}

편집기에서 타입을 조사하는 습관을 가지면 타입 좁히기가 어떻게 동작하는지 자연스레 익힐 수 있다.

타입스크립트에서 타입이 어떻게 좁혀지는지 이해한다면 타입 추론에 대한 개념을 잡을 수 있고, 오류 발생의 원인을 알 수 있으며, 타입체커를 더 효율적으로 이용할 수 있다.

📕 요약

  • 분기문 외에도 여러 종류의 제어 흐름을 살펴보며 타입스크립트가 타입을 좁히는 과정을 이해하자

  • 구별된 유니온과 사용자 정의 타입가드를 사용하여 타입 좁히기 과정을 원할하게 할 수 있다.

profile

Park Cheol Hwi

Copyright ©2024 kcjfgnl9205 All rights reserved.