avatar
Published on

타입스크립트에서 조심해야할 습관

Author
  • avatar
    Name
    yceffort

Table of Contents

any

타입스크립트에서 얼마나 타입을 잘 지키는지는, any를 얼마나 사용하느냐에 달려 있다고 봐도 될 것 같다. 그러나 불가피하게 any가 사용되는 경우도 있다. JSON.parse가 그 중 하나다. 이 메소드는, 리턴타입을 추론 할 수 없기 때문에 any로 리턴을 한다.

JSON.parse('{hello: world}')
// (method) JSON.parse(text: string, reviver?: ((this: any, key: string, value: any) => any) | undefined): any

any의 유혹은 강력하다. 그냥 뭐든지 넘겨주기 때문이다.

function testNumber(num: number) {
  return num + 1
}

const num: any = 'eleven' // any 라서 number만 넘길 수 있었는데 무시되었다.
testNumber(num)

따라서 any의 사용은 최소화 하는 것이 좋다. 타입을 아직 알 수 없을 때는, any대신 unknown을 쓰는게 좋다.

Type Assertion

type assertion은 엄밀히 말해서 cast와는 다르다. 타입스크립트에서 type assertion이란 x as number와 같은 형태를 의미한다. 타입이 있는 다른언어 (C, Java등) 에서 cast는 런타임에 영향을 미친다. 예를 들어, (int)f가 있다면 float이 런타임 중에 int로 바뀌게 된다. 그러나 타입스크립트는 런타임시에 타입을 제거하기 때문에 실제로 아무런 영향을 미치지 않는다.

const strOrNum = Math.random() ? '42' : 42
const num = strOrNum as number

을 컴파일하면 별 차이가 없다.

'use strict'
const strOrNum = Math.random() ? '42' : 42
const num = strOrNum

따라서 type assertion이란 타입으로 캐스팅을 하는게 아니고, 그냥 그게 이 타입이라고 주장하는 것이다. (그래서 assertion이기도 하구)

function testNumber(num: number) {
  return num + 1
}

const num = Math.random() ? 'eleven' : 11
testNumber(num as number) // 반반쯤의 확률로 에러가 난다.

이러한 as의 사용은 api 호출에서도 자주 보인다.

const response = await fetch('/api/user')
const result = (await response.json()) as UserInterface

이러한 경우에는, 타입 가드를 써서 막는 것이 좋다.

function isUser(data: unknown): data is UserInterface {
  return data && typeof data === 'objet' && 'name' in data // ...
}
const response = await fetch('/api/user')
const result = await response.json()

if (!isUser(result)) {
  throw new Error(`${result}는 UserInterface가 아니예욧`)
}

조금더 빡세게(?) 타입가드를 하고 싶다면, Zod와 같은 도구를 사용해보는 것도 좋다. 혹은 typescript-json-schema로 JSON schema에서 타입을 뽑아낸 다음에 검사하는 방법도 있을 수 있다.

객체와 배열 lookup

타입스크립트는 배열을 참조할 때 별다른 처리를 하지 않기 때문에, 에러가 날 수 있다.

const l = [1, 2, 3]
const item = l[3]
item + 1 // error 지만, 컴파일시에는 모른다.
const user: { [key: string]: string } = { name: 'kyc' }
user.age + 1 // error 지만, 컴파일 시에는 모른다.

왜 타입스크립트가 가만 뒀을까? 아마도 이러한 상황은 매우 자주 있는 일이고, 이것이 유효한지 체크하는 것이 꽤 어려운일이어서 그런걸수도 있다. 근데, 이를 체크하는 방법이 있다. 바로 noUncheckedIndexedAccess다.

const l = [1, 2, 3]
const item1 = l[3]
const item2 = l[2]
item1 + 1 // Object is possibly 'undefined'.(2532)
item2 + 1 // 근데 문제는 이거까지 에러가 난다는 거다.

l.map((item) => item + 1) // 이런건 괜찮다.

확인해보기

위에서 보이는 것 처럼, noUncheckedIndexedAccess는 적당히 경고를 날려주는 장점도 있지만, 그다지 똑똑하지 않다는 단점도 있다.

결론적으로, 객체나 리스트를 lookup 할때는 undefined가 있을 가능성에 대해서 염두해 두어야 한다.

부정확한 타입 정의

자바스크립트의 라이브러리에 타입선언을 집어넣는 것은 일종의 거대한 type assertion이다. 그들의 라이브러리가 이렇게 정적으로 모델링 했다고 주장하는 거지만, 이를 보장하는 것은 아무것도 없다. (물론 이는 라이브러리가 타입스크립트로 작성되지 않았다는 것에 한해서다. 타입스크립트로 작성되지 않고, 타입만 있다면 이런일이 발생할 수 있다.)

예를 들면 2년째 고쳐지지 않는 잘못된 타이핑 이라던가...

이런 문제를 해결하는 가장 좋은 방법은, 직접 버그를 고치는 것이다. 직접 DefinitelyTyped에 쳐들어가서 고치면 된다. 이게 좀 부담되는 나와 같은 ISTJ 들에겐 augmentation 이나, 최악의 옵션으로 type assertion을 활용하는 방법도 있다.

물론, 몇몇 함수는 이렇게 타입을 선언하기가 굉장히 어렵다는 것도 이해해줘야 한다. String.prototype.replace의 파라미터를 잠깐 보자. Object.assign의 경우엔, 피치못할 사정으로 인해 잘못된 타이핑이 된 경우도 있었다.

variance and arrays

typescript github의 이 이슈를 살펴보자.

function addDogOrCat(arr: Animal[]) {
  arr.push(Math.random() > 0.5 ? new Dog() : new Cat())
}

const z: Cat[] = [new Cat()]
addDogOrCat(z) // 개가 들어갈 수도 있다.

이러한 짓을 방지하려면 어떻게 해야할까? readonly를 쓰는 방법이 있을 수 있다.

function addDogOrCat(arr: readonly Animal[]) {
  arr.push(Math.random() > 0.5 ? new Dog() : new Cat())
  //  Property 'push' does not exist on type 'readonly Animal[]'.
}

아니면, push 대신 이렇게 하거나.

function dogOrCat(): Animal {
  return Math.random() > 0.5 ? new Dog() : new Cat()
}

const z: Cat[] = [new Cat(), dogOrCat()]

관련해서 읽어볼만 한 좋은 글: https://iamssen.medium.com/typescript-%EC%97%90%EC%84%9C%EC%9D%98-%EA%B3%B5%EB%B3%80%EC%84%B1%EA%B3%BC-%EB%B0%98%EA%B3%B5%EB%B3%80%EC%84%B1-strictfunctiontypes-a82400e67f2

함수 호출에 따른 부작용

interface UserInterface {
  name: string
  age?: number
}

function userProcessor(user: UserInterface, processor: (user: UserInterface) => void) {
  if (user.age) {
    processor(user)
    document.body.innerHTML = `${user.age + 1}`
  }
}

만약 processor에서 이런짓을 하면 어떻게 될까?

userProcessor({ name: 'kyc', age: 15 }, (u) => delete u.age)
// 타입 체크는 성공하지만, 에러가 날거다.

물론 만일을 위해서 if처리가 추가되었지만, processor에서 이를 무효화 했다. 타입스크립트는 저 함수에서 무슨짓을 할지 모르기 때문에, 어찌보면 당연한 것이다.

자바스크립트에서 파라미터를 조작하는 일은 흔치 않고 또한 안티패턴이기 때문에, 타입스크립트에서는 이러한 동작을 허용해 뒀을 것이다.

이를 수정해두는 방법은, 역시 readonly를 사용하는 것이 있다.

function userProcessor(
  user: UserInterface,
  processor: (user: Readonly<UserInterface>) => void,
) {
  if (user.age) {
    processor(user)
    document.body.innerHTML = `${user.age + 1}`
  }
}

userProcessor({ name: 'kyc', age: 15 }, (u) => delete u.age)
// The operand of a 'delete' operator cannot be a read-only property.

Readonly는 얕은 비교만 하기 때문에, ts-essentials를 사용해야 깊은 비교를 할 수 있다.

당연하게도, 가장 좋은 방법은 객체의 처리를 객체가 정의된 이후에 하는 것이다.

function processFact(
  user: UserInterface,
  processor: (user: UserInterface) => void,
) {
  const { age } = user
  if (age) {
    processor(user)
    document.body.innerHTML = `${age + 1}` // safe
  }
}

Further reading