이펙티브 타입스크립트 | 5장 any 다루기

dev_hee·2023년 2월 9일
0

TypeScript

목록 보기
5/6
post-thumbnail

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


[아이템 38] any 타입은 가능한 좁은 범위에서만 사용하기

함수와 관련된 any 사용법

interface Foo {
  foo: string
}
interface Bar {
  bar: string
}

declare function expressionReturningFoo(): Foo
function processBar(b: Bar) {
  /* ... */
}

function f() {
  const x = expressionReturningFoo()
  processBar(x)
  //         ~ 'Foo' 형식의 인수는 'Bar' 형식의 매개변수에 할당될 수 없다
}

문맥상 x라는 변수가 동시에 Foo, Bar 타입에 할당 가능하다면 오류를 제거하는 방법은 두 가지다.

  • bad
function f1() {
  const x: any = expressionReturningFoo() // Don't do this
  processBar(x)
}
  • good
function f2() {
  const x = expressionReturningFoo()
  processBar(x as any) // Prefer this
}

x as any 형태가 더 낫다. any 타입이 processBar 함수의 매개변수에서만 사용된 표현식이므로 다른 코드에 영향을 미치지 않기 때문이다.

만약 f1 함수가 x를 반환하면 문제가 생긴다.

function f1() {
  const x: any = expressionReturningFoo()
  processBar(x)
  return x
}

function g() {
  const foo = f1() // Type is any
  foo.fooMethod() // 이 함수 호출은 타입 체크되지 않는다
}

함수가 any 를 반환하면 프로젝트 전반에 영향이 미치게 된다. 따라서 any 타입은 좁은 범위에서 사용해야 한다.

함수의 반환 타입은 명시하는 것이 좋다.

타입스크립트는 함수의 반환 타입을 추론할 수 있지만 반환 타입을 명시하면 any 타입이 함수 바깥으로 영향을 미치는 것을 방지할 수 있다.

@ts-ignore

@ts-ignore 를 사용하면 그 다음 줄의 오류가 무시된다. 그러나 근본적인 원인을 해결한 것은 아니기 때문에 다른 곳에서 문제를 일으킬 수 있으므로 근본 원인을 찾아 대처하는 것이 더 바람직하다. 예제

function f() {
  const x = expressionReturningFoo()
  // @ts-ignore
  processBar(x)
}

객체와 관련한 any 사용법

어떤 큰 객체 안의 한 개 속성이 타입 오류를 가지는 상황을 예로 들어보자.

interface Foo {
  foo: string
}
interface Bar {
  bar: string
}
interface Config {
  a: number
  b: number
  c: {
    key: Foo
  }
}
declare const value: Bar

const config: Config = {
  a: 1,
  b: 2,
  c: {
    key: value,
    // ~~~ 'foo'속성이 'Foo'타입에 필요하지만 'Bar'타입에는 없다
  },
}

단순히 생각하면 config 객체 전체를 as any로 선언해 오류를 제거할 수 있다.

  • bad

객체 전체를 any로 단언하면 다른 속성들 역시 타입 체크가 되지 않는다.

const config: Config = {
  a: 1,
  b: 2,
  c: {
    key: value,
  },
} as any // Don't do this!
  • good

최소한의 범위에만 any를 사용하는 것이 좋다.

const config: Config = {
  a: 1,
  b: 2, // 이 속성은 체크된다
  c: {
    key: value as any,
  },
}

요약

  • 의도치 않은 타입 안전성의 손실을 피하기 위해 any의 사용 범위를 최소한으로 좁혀야 한다.
  • 함수의 반환 타입이 any인 경우 타입 안정성이 나빠진다. 따라서 any타입을 반환하면 절대 안된다.
  • 강제로 타입 오류를 제거하려면 any 대신 @ts-ignore를 사용하는 것이 좋다.

[아이템 39] any를 구체적으로 변형해서 사용하기

any는 자바스크립트에서 표현할 수 있는 모든 값을 아우르는 매우 큰 범위의 타입이다. 일반적인 상황에선 any보다 더 구체적으로 표현할 수 있는 타입이 존재할 수 있기 때문에 구체적인 타입을 사용해 안전성을 높이도록 하자.

any 타입의 값을 그대로 정규식이나 함수에 넣는 것은 권장되지 않는다.

  • bad
function getLengthBad(array: any) {
  // Don't do this!
  return array.length
}
  • good
function getLength(array: any[]) {
  return array.length
}

두번째 예제가 더 좋은 이유는 다음과 같다.

  • 함수 내의 array.length타입이 체크된다.
  • 함수의 반환 타입이 any 대신 number로 추론된다.
  • 함수 호출될 때 매개변수가 배열인지 체크된다.

함수의 매개변수를 구체화할 때, 배열의 배열 형태라면 any[][]처럼 선언하면 된다.

매개변수가 객체이지만 값을 알 수 없다면 {[key: string]: any} 처럼 사용하면 된다. 또는 object 타입을 사용할 수도 있다. object 타입은 객체의 키를 열거할 수는 있지만 속성에 접근할 수 없다는 점에서 {[key: string]: any}(인덱스 시그니처) 와는 다르다.

function hasTwelveLetterKey(o: object) {
  for (const key in o) {
    if (key.length === 12) {
      console.log(key, o[key])
      //  ~~~~~~ '{}' 형식에 인덱스 시그니처가 없으므로 요소에 암시적으로 'any'형식이 있습니다.
      return true
    }
  }
  return false
}

객체지만 속성에 접근할 수 없어야 한다면 Unknown 타입이 필요한 상황일 수 있다. (아이템 42장)

함수의 타입에도 단순히 any를 사용해서는 안된다. 최소한으로 구체화할 수 있는 방법이 다음과 같다.

type Fn0 = () => any // any function callable with no params
type Fn1 = (arg: any) => any // 하나의 매개변수
type FnN = (...args: any[]) => any // 모든 개수의 매개변수
// "Function" type 과 동일하다

argsany로 선언해도 동작하지만 any[]로 선언하면 배열 형태라는 것을 알 수 있어 더 구체적이다.

const numArgsBad = (...args: any) => args.length // Returns any
const numArgsGood = (...args: any[]) => args.length // Returns number

요약

  • any를 사용할 때는 정말 모든 값이 허용되어야 하는지 검토해야한다.
  • any보다 더 정확하게 모델링할 수 있도록 구체적인 any형태를 사용해야한다.

[아이템 40] 함수 안으로 타입 단언문 감추기

함수의 내부 로직이 복잡해져서 안전한 타입으로 구현하기 어려운 경우가 있다. 이럴땐 함수 내부에는 타입 단언을 사용하고 함수 외부로 드러나는 타입 정의를 정확히 명시하는 것이 좋다.

예를 들어 어떤 함수가 자신의 마지막 호출을 캐시하도록 만든다고 가정해보자. 함수 캐싱은 리액트같은 프레임워크에서 실행 시간이 오래걸리는 함수 호출을 개선하는 일반적인 기법이다. (useMemo)

어떤 함수든 캐싱할 수 있도록 래퍼 함수 cacheLast를 만들어 보자.

선언은 쉽다.

declare function cacheLast<T extends Function>(fn: T): T

구현체는 다음과 같다. 예제

declare function shallowEqual(a: any, b: any): boolean // 두 개의 배열을 매개변수로 받아 비교함
function cacheLast<T extends Function>(fn: T): T {
  let lastArgs: any[] | null = null
  let lastResult: any
  return function (...args: any[]) {
    // ~~~~~~~~~~~~~~~~~~~~~~~~~~
    //  (...args: any[]) => any' 형식은 'T' 형식에 할당할 수 없다
    if (!lastArgs || !shallowEqual(lastArgs, args)) {
      lastResult = fn(...args)
      lastArgs = args
    }
    return lastResult
  }
}

타입스크립트는 반환문에 있는 함수와 원본 함수 T 타입이 어떤 관련이 있는지 알지 못하기 때문에 오류가 발생한다.
그러나 결과적으로 원본 함수 T 타입과 동일한 매개변수로 호출되고 반환값 억시 예상한 결과가 되기때문에 타입 단언문을 추가해서 오류를 제거해도 된다. (as unknown as T)

function cacheLast<T extends Function>(fn: T): T {
  let lastArgs: any[] | null = null
  let lastResult: any
  return function (...args: any[]) {
    if (!lastArgs || !shallowEqual(lastArgs, args)) {
      lastResult = fn(...args)
      lastArgs = args
    }
    return lastResult
  } as unknown as T
}

예제에 나온 shallowEqual은 두 배열을 비교하는 함수이므로 타입 정의와 구현이 간단하다.

하지만 객체를 매개변수로 하는 shallowObjectEqual은 타입 정의는 간단하지만 구현이 조금 복잡하다.

// 타입 정의
declare function shallowObjectEqual<T extends object>(a: T, b: T): boolean
function shallowObjectEqual<T extends object>(a: T, b: T): boolean {
  for (const [k, aVal] of Object.entries(a)) {
    if (!(k in b) || aVal !== b[k]) {
      // ~~~~ Element implicitly has an 'any' type
      //      because type '{}' has no index signature
      return false
    }
  }
  return Object.keys(a).length === Object.keys(b).length
}

if 구문에서 k in b 체크로 b 객체에 k 속성이 있다는 것을 확인했지만 b[k] 에서 오류가 발생하는것은 이상하다. (타입스크립트 문맥 활용 능력이 부족해서) 어쨌든 실제 오류가 아니므로 any로 단언한다.

function shallowObjectEqual<T extends object>(a: T, b: T): boolean {
  for (const [k, aVal] of Object.entries(a)) {
    if (!(k in b) || aVal !== b[k]) {
      // ~~~~ Element implicitly has an 'any' type
      //      because type '{}' has no index signature
      return false
    }
  }
  return Object.keys(a).length === Object.keys(b).length
}

요약

타입 단언문은 일반적으로 타입을 우험하게 만들지만 상황에 따라 필요하기도 하고 현실적인 해결책이 되기도 한다. 불가피 하게 사용해야한다면 정확한 정의를 가지는 함수 안에 숨기도록 하자.

[아이템 41] any의 진화를 이해하기

타입스크립트에서 변수의 타입은 변수를 선언할 때 결정된다.
그 후에 정제될 수 있지만 (null 체크), 새로운 값이 추가되도록 확장할 수는 없다.
그러나 any 타입은 예외 경우가 존재한다.

일정 범위의 숫자를 생성하는 함수를 예로 들자.

function range(start: number, limit: number) {
  const out = [] // any[]
  for (let i = start; i < limit; i++) {
    out.push(i) // out 타입은 any[]
  }
  return out // Return type inferred as number[]
}

outany타입인 배열 []로 초기화 되었는데, 마지막엔 number[] 로 추론된다.

out의 타입은 any[]로 선언되었지만 number타입의 값을 넣는 순간부터 타입이 number[]로 진화한다.

타입의 진화는 타입 좁히기와 다르다. 배열에 다양한 타입의 요소를 넣으면 배열의 타입이 확장되며 진화한다.

const result = [] // Type is any[]
result.push('a')
result // Type is string[]
result.push(1)
result // Type is (string | number)[]

조건문에서 분기에 따라 타입이 변할 수도 있다.

let val // Type is any
if (Math.random() < 0.5) {
  val = /hello/
  val // Type is RegExp
} else {
  val = 12
  val // Type is number
}
val // Type is number | RegExp

변수의 초기값이 Null인 경우도 any의 진화가 발생한다.
보통 try/catch 블록 안에서 변수를 할당하는 경우에 나타난다.

function somethingDangerous() {}
let val = null // Type is any
try {
  somethingDangerous()
  val = 12
  val // Type is number
} catch (e) {
  console.warn('alas!')
}
val // Type is number | null

any의 진화는 noImplicitAny가 설정된 상태에서 변수의 타입이 any인 경우에만 발생한다.
하지만 명시적으로 any를 선언하면 타입이 그대로 유지된다.

let val: any // Type is any
if (Math.random() < 0.5) {
  val = /hello/
  val // Type is any
} else {
  val = 12
  val // Type is any
}
val // Type is any

암시적 any 상태인 변수에 어떠한 할당도 하지 않고 사용하려고하면 암시적인 any 오류가 발생한다.

function range(start: number, limit: number) {
  const out = []
  //    ~~~ Variable 'out' implicitly has type 'any[]' in some
  //        locations where its type cannot be determined
  if (start === limit) {
    return out
    //     ~~~ Variable 'out' implicitly has an 'any[]' type
  }
  for (let i = start; i < limit; i++) {
    out.push(i)
  }
  return out
}
  • any타입의 진화는 암시적 any타입에 어떤 값을 할당할 때만 발생한다.
  • 어떤 변수가 암시적 any 상태일 때 값을 읽으려고 하면 오류가 발생한다.
  • 암시적 any 타입은 함수 호출을 거쳐도 진화하지 않는다.
function makeSquares(start: number, limit: number) {
  const out = []
  // ~~~ 'out' 변수는 일부 위치에서 암시적으로 any[] 형식이다
  range(start, limit).forEach(i => {
    out.push(i * i)
  })
  return out
  // ~~~ 'out' 변수는 암시적으로 any[] 형식이 포함된다.
}

any 타입을 진화시키는 것 보다 명시적 타입 구문을 사용하는 것이 안전한 타입을 유지하는 방법이다.


[아이템 42] 모르는 타입의 값에는 any 대신 unknown을 사용하기

함수의 반환값과 관련된 형태

YAML 파서인 parseYAML 함수를 작성한다고 가정해보자.

함수의 반환 타입이 any 인것은 좋지 않은 설계다.
대신 parseYAML를 호출한 곳에서 반환값을 원하는 타입으로 할당하는 것이 이상적이다.

function parseYAML(yaml: string): any {
  // ...
}
interface Book {
  name: string
  author: string
}
const book: Book = parseYAML(`
  name: Wuthering Heights
  author: Emily Brontë
`)

하지만 함수의 반환값에 타입 선언을 강제할 수 없기 때문에, 호출한 곳에서 타입 선언을 생략하게 되면 book은 암시적 any타입이고, 사용되는 곳마다 타입 오류가 발생하게 된다.

const book = parseYAML(`
  name: Jane Eyre
  author: Charlotte Brontë
`)
alert(book.title) // No error, alerts "undefined" at runtime
book('read') // No error, throws "TypeError: book is not a function" at runtime

parseYAML를 unknown타입을 반환하도록 만드는 것이 더 안전하다.

function safeParseYAML(yaml: string): unknown {
  return parseYAML(yaml)
}
const book = safeParseYAML(`
  name: The Tenant of Wildfell Hall
  author: Anne Brontë
`)
alert(book.title)
// ~~~~ Object is of type 'unknown'
book('read')
// ~~~~~~~~~~ Object is of type 'unknown'

unknown 타입은 이해하기 위해 할당 가능성의 관점에서 any를 생각해야한다.
any가 강력하면서도 위험한 이유는 다음 두가지 때문이다.

  • 어떤 타입이든 any 타입에 할당 가능하다.
  • any 타입은 어떤 타입으로도 할당 가능하다.

따라서 any를 사용하면 타입 체커가 무용지물이 된다는 것을 주의해야한다.

unknown은 any 대신 쓸 수 있는 타입 시스템에 부합하는 타입이다.

  • 어떤 타입이든 unknown은 타입에 할당 가능하다.
  • unknown은 타입은 오직 unknown과 any에만 할당 가능하다.

never타입은 unknown과 정반대이다.

  • 어떤 타입이도 never에 할당할 수 없다.
  • never은 타입은 어떤 타입으로도 할당 가능하다.

unknown 타입인 채로 값을 사용하면 오류가 발생한다. unknown 상태로 사용하려고 하면 오류가 발생하기 때문에, 적절한 타입으로 변하도록 강제할 수 있다.

const book = safeParseYAML(`
  name: Villette
  author: Charlotte Brontë
`) as Book
alert(book.title)
// ~~~~~ Property 'title' does not exist on type 'Book'
book('read')
// ~~~~~~~~~ this expression is not callable

변수 선언과 관련된 형태

어떤 값이 있지만 그 타입을 모르는 경우에 unknown을 사용한다.

GeoJSON사양에 Feature의 properties속성은 JSON직렬화가 가능한 모든 것을 담는 잡동사니 주머니 같은 존재이다. 그래서 타입을 예상할 수 없어서 unknown을 사용한다.

interface Geometry {}
interface Feature {
  id?: string | number
  geometry: Geometry
  properties: unknown
}

타입 단언문말고도 instanceof 로 체크해서 unknown에서 원하는 타입으로 변환할 수 있다.

function processValue(val: unknown) {
  if (val instanceof Date) {
    val // Type is Date
  }
}

또한 사용자 정의 타입 가드로도 타입을 변환할 수 있다.

function isBook(val: unknown): val is Book {
  return typeof val === 'object' && val !== null && 'name' in val && 'author' in val
}
function processValue(val: unknown) {
  if (isBook(val)) {
    val // Type is Book
  }
}

unknown대신 제너릭 매개변수를 사용하는 경우는 타입스크립트에서 좋지 않은 스타일이다.

function safeParseYAML<T>(yaml: string): T {
  return parseYAML(yaml)
}

제너릭을 사용한 스타일은 타입 단언문과 달라보이지만 기능적으로 동일하다. 제너릭 보다는 unknown을 사용하는 것이 좋다.

단언문과 관련된 형태

interface Foo {
  foo: string
}
interface Bar {
  bar: string
}

declare const foo: Foo
let barAny = foo as any as Bar
let barUnk = foo as unknown as Bar

barAny, barUnk는 기능적으로 동일하지만 나중에 두 개의 단언문을 분리하는 리팩터링을 한다면 unknown형태가 더 안전하다.

any는 분리되는 순간 그 영향력이 전염병처럼 퍼지게 된다.
그러나 unknown의 경우 분리되는 즉시 오류를 발생하므로 더 안전하다.

object, {}

unknown과 유사한 방식으로 object, {}를 사용하는 코드들이 존재한다.
object, {}를 사용하는 방법 역시 unknown처럼 범위가 넓은 타입이지만, unknown보다는 살짝 범위가 좁다.

  • {} 타입은 null, undefined를 제외한 모든 값을 포함한다
  • object 타입은 비기본형(non-primitive)타입으로 이루어진다. 불리언, 숫자, 문자열은 포함되지 않고 객체와 배열이 포함된다.

[아이템 43] 몽키 패치보다는 안전한 타입을 사용하기

자바스크립트는 객체와 클래스에 임의의 속성을 추가할 수 있을 만큼 유연하다는 것이다. window, document에 값을 할당하여 전역 변수를 만들수도 있고 DOM 엘리먼트에 데이터를 추가할 수도 있다.

document.monkey = 'Tamarin'

하지만 전역변수를 사용하면 프로그램 내에 서로 관련 없는 부분끼리의 의존성을 만들게 된다. 그러면 함수를 호출할 때마다 side effect를 고려해야 한다.

타입스크립트에서 타입체커는 Document와 HTMLElement의 내장 속성에 대해 알고 있지만, 임의로 추가한 속성에 대해선 알지 못한다. 이 오류를 any 단언문으로 해결할 수 있다.

(document as any).monkey = 'Tamarin' // OK

any 를 사용해서 타입 안전성을 상실하고, 언어 서비스를 사용할 수 없게 되었다.

;(document as any).monky = 'Tamarin' // Also OK, misspelled
;(document as any).monkey = /Tamarin/ // Also OK, wrong type

해결책은 다음과 같다.

최선 - document 또는 DOM 으로부터 데이터를 분리한다.

하지만 그러지 못하는 경우가 있을 수 있다.

interface의 보강(augmentation)을 사용

interface Document {
  /** Genus or species of monkey patch */
  monkey: string
}

document.monkey = 'Tamarin' // OK

장점

  • 타입이 더 안전하다. 오타나 잘못된 타입의 할당을 오류로 잡아낸다
  • 속성에 주석을 붙일 수 있다.
  • 속성에 자동완성을 사용할 수 있다.
  • 몽키 패치가 어떤 부분에 적용되었는지 정확한 기록이 남는다.

모듈 관점(import/export)에서 제대로 동작하게 하려면 global 선언을 추가해야한다.

export {}
declare global {
  interface Document {
    /** Genus or species of monkey patch */
    monkey: string
  }
}
document.monkey = 'Tamarin' // OK

보강의 주의점

  • 보강은 전역적으로 적용되기 때문에, 코드의 다른 부분이나 라이브러리로부터 분리할 수 없다.
  • 애플리케이션이 실행되는 동안 속성을 할당하면 실행 시점에서 보강을 적용할 방법이 없다. (보강을 못한다.)

더 구체적인 타입 단언문을 사용

interface MonkeyDocument extends Document {
  /** Genus or species of monkey patch */
  monkey: string
}

;(document as MonkeyDocument).monkey = 'Macaque'

MonkeyDocument는 Document를 확장하기 때문에 타입 단언문은 정상이고 할당문의 타입은 안전하다.
또한 Document 타입을 건드리지 않아서 모듈 영역 문제도 해결할 수 있다.
몽키 패치된 속성을 참조하는 경우에만 단언문을 사용하거나 새로운 변수를 도입하면 된다.

하지만 몽키패치는 남용해서는 안되고 더 잘 설계된 구조로 리팩터링 하는 것이 좋다.


[아이템 44] 타입 커버리지를 추적하여 타입 안전성 유지하기

noImplicitAny 를 설정해 모든 암시적 any 대신 명시적 타입 구문을 추가하더라도 any 타입과 관련된 문제로부터 안전하다고 할 수 없다.

  • 명시적 any 타입
  • 서드파티 타입 선언

any 개수 추적

type-coverage 패키지를 사용해 any 를 추적할 수 있다.

$ npx type-coverage
9985 / 10117 98.69%

10117개 심벌 중 9985개가 any가 아니거나, any의 별칭이 아닌 타입을 가지고있다.

$ npx type-coverage --detail

--detail 플래그는 any 타입이 있는 곳을 모두 출력해준다.

서드파티 타입 선언

서드파티 라이브러리로부터 비롯되는 any 타입은 몇 가지 형태로 등장할 수 있지만, 극단적인 예로는 전체 모듈에 any 타입을 부여하는 것이다.

declare module 'my-module';

위의 선언으로 인해 my-module에서 임포트한 모든 심벌이 any 타입이다. 타입 정보가 모드 제거되어 버린다.

import {someMethod, someSymbol} from 'my-module'

const pt1 = { x : 1, y: 2 } // 타입 { x: number, y: number}
const pt2 = someMethod(pt1, someSymbol) // pt2 타입 any
profile
🎨그림을 좋아하는 FE 개발자👩🏻‍💻

0개의 댓글