본문 바로가기

프로젝트 개발일지/TIFY

함수 반환 타입을 명확히 지정하고 특정 프로퍼티를 가지는 타입도 지정해보자

이번 글도 타입스크립트를 써보며 배웠던 점을 적어보려 한다.
사실 프로젝트를 진행하면서 타입을 구체적으로 지정하는 게 생각보다 쉽지는 않았지만, 그 과정에서 배운 점들이 정말 많았다.
 
이번에는 useToggle이라는 커스텀 훅을 작성해 보며 겪었던 일들과 배운 점들에 대해서 알아보자.
 
useToggle은 생각보다 되게 자주 사용하는 커스텀 훅이다.
타입스크립트와는 처음 사용해 봤는데, 타입을 지정해 주는 게 쉽지 않았다.
 
내가 처음에 작성한 useToggle 훅과 사용하는 코드는 다음과 같다.

import { useCallback, useState } from 'react'

const useToggle = (initialState = true) => {
  const [state, setState] = useState(initialState)

  const toggle = useCallback(() => {
    setState((prevState) => !prevState)
  }, [])

  return [state, toggle]
}

export default useToggle
  const [isCubeList, toggleListOption] = useToggle(true)

 
이렇게 하니까 에러가 발생했는데, 타입스크립트가 타입을 제대로 추론해주지 못했다.
내가 의도한 바는 [isCubeList, toggleListOption]이 [boolean, () => void] 타입으로 추론되기를 기대했는데, 실제로 추론된 결과는 다음과 같다.
isCubeList와 toggleListOption 모두 boolean | () => void로 나타났다.

 
나는 튜플처럼 첫 번째 항목은 boolean으로, 두 번째 항목은 () => void 타입을 가지는 함수로 추론되기를 기대했지만, 타입스크립트는 이를 배열로 생각하여 boolean 또는 () => void 타입으로 예상한 것 같다.
 
그래서 다른 방식으로 표현해보려 하다가, 첫 번째 시도로 강제 형변환을 해보게 되었다.
강제 형변환이란 타입을 개발자가 명시적으로 지정하는 걸 말한다.
쉽게 말하자면 타입스크립트가 타입 추론 같은 걸 개발자가 원하는 방향으로 하지 못할 때 타입을 명시적으로 지정해 주는 것이다.
 
강제 형변환을 사용해 수정한 코드는 다음과 같다.

import { useCallback, useState } from 'react'

const useToggle = (initialState = true) => {
  const [state, setState] = useState(initialState)

  const toggle = useCallback(() => {
    setState((prevState) => !prevState)
  }, [])

  return [state, toggle]
}

export default useToggle
  const [isCubeList, toggleListOption] = useToggle() as [boolean, () => void]

 
아래와 같이 타입 가드를 목적으로 하는 코드에서는 강제 형변환을 사용하면 좋다.

function isDog(animal: Animal): animal is Dog {
    return (animal as Dog).isBark !== undefined;
}

 
하지만 대부분의 경우에 강제 형변환을 하게 되면 타입 안정성이 떨어지게 되기 때문에 되도록이면 덜 사용하는 것이 좋은데, 그래서 위 코드는 에러는 나지 않지만 좋은 타입 정의는 아니다.
그 이유는, 정의하는 곳에서 타입이 제대로 지정되지 않은 채 사용처에서 타입을 강제로 변환해 준 것이기 때문이다.
 
아래 사진을 보면 좋지 않은 타입 정의라는 것을 한눈에 알 것이다.

useToggle이 반환하는 것은 튜플의 형태가 아닌, 배열의 형태이다.
그래서 근본적인 문제가 해결된 것이 아닌, 그저 에러는 나지 않는 정도의 해결인 것이다.
 
다른 방식으로 표현하면 조금 더 깔끔하게 타입을 표현할 수 있을 것 같아 계속해서 고민해 보다가, 함수가 튜플을 반환하도록 반환값을 지정해주기로 했다.
 
아래와 같이 표현해서 useToggle 함수의 반환값 타입을 지정했다.
readonly는 써주지 않아도 타입 추론이 잘 되지만, 불변성을 강제하기 위해서 써줬다.
이렇게 하니, 원하는 대로 반환값 타입이 각각 잘 추론된 것을 확인할 수 있었다.

import { useCallback, useState } from 'react'

type useToggleReturnType = readonly [boolean, () => void]

const useToggle = (initialState = true): useToggleReturnType => {
  const [state, setState] = useState(initialState)

  const toggle = useCallback(() => {
    setState((prevState) => !prevState)
  }, [])

  return [state, toggle]
}

export default useToggle
const [isCubeList, toggleListOption] = useToggle()

 
사실 이 문제가 반환값 타입을 지정해주는 방식으로 해결될 줄 몰랐다.
앞으로는 함수를 작성할 때 반환값 타입도 명확히 지정해줘야겠다.
 
여기서 글이 끝나면 좋겠지만, 아직 얘기할 게 좀 더 남았다.
이렇게 만든 useToggle 함수를 잘 사용하고 있었는데, 지역 상태가 아니라 전역 상태를 이용한 useToggle 커스텀 훅으로 변경해야 하는 일이 생겨버렸다.
 
그래서 아래부터는 useRecoilToggle 함수를 작성하며 배운 점을 조금 더 추가해보려 한다.
 
useRecoilToggle에는 제네릭을 도입해서 구현하였다.
이는 커스텀 훅 자체가 재사용성을 위한 함수인데, 단 하나의 전역 상태만을 다룰 수 있다면 굳이 커스텀 훅으로 뺄 필요가 없지 않나,라는 생각에 제네릭을 사용했다.
 
그래서 구현한 useRecoilToggle 함수는 다음과 같다.

import { useCallback } from 'react'
import { RecoilState, useRecoilState } from 'recoil'

type UseToggleReturnType<T> = readonly [T, () => void]

type HasValueProperty<T> = T extends { value: unknown } ? T : never

const useRecoilToggle = <T,>(
  toggleState: RecoilState<HasValueProperty<T>>,
): UseToggleReturnType<HasValueProperty<T>> => {
  const [state, setState] = useRecoilState(toggleState)

  const toggle = useCallback(() => {
    setState((prev) => ({
      ...prev,
      value: !prev.value,
    }))
  }, [setState])

  return [state, toggle]
}

export default useRecoilToggle

 
생각보다 복잡한데, HasValueProperty 타입부터 살펴보자.
HasValueProperty 타입은 value 프로퍼티를 가진 전역 상태에 한해서 토글 함수가 작동하도록 설정하기 위해서 정의된 것이다.
 
아래와 같이 전역 상태를 정의할 때, value 프로퍼티를 추가해야 위 토글 커스텀 훅을 사용할 수 있다.

export type ProfileState = {
  value: boolean
}

 
 

type HasValueProperty<T> = T extends { value: unknown } ? T : never

그래서 T에는 전역 상태를 정의한 타입이 들어가게 되고, 해당 전역 상태에 value 프로퍼티가 존재한다면 반환값으로 T를 반환하도록 했다.
원래는 value 프로퍼티의 타입을 any로 지정할까 고민했었는데, 이펙티브 타입스크립트에서 any는 타입 시스템과 상충하는 면이 있어 unknown 타입을 지정해 주는 게 좋다고 해서 일부러 unknown 타입으로 지정했다.
 
이렇게 제네릭조건부 타입을 이용해서 HasValueProperty 타입을 지정해 주었다. 
 
한 가지 더 알게 된 점이 있는데, 아래 T라는 제네릭 변수를 정의해 줄 때 <T>라고 쓰면 제네릭으로 인식을 못 해서 <T,>라고 ,를 추가해 주었다.

const useRecoilToggle = <T,>(
  toggleState: RecoilState<HasValueProperty<T>>,
): UseToggleReturnType<HasValueProperty<T>> => {
  const [state, setState] = useRecoilState(toggleState)

  const toggle = useCallback(() => {
    setState((prev) => ({
      ...prev,
      value: !prev.value,
    }))
  }, [setState])

  return [state, toggle]
}

export default useRecoilToggle

원래는 <T>로 지정했었고, 인식을 못해서 왜 그런가 했었는데, 다른 제네릭을 사용한 라이브러리를 둘러보다가 발견했던 ,를 넣어보았는데 잘 해결되었다.

신기하더라.

확장자가 tsx인 파일에서는 <T>라고만 쓰면 제네릭으로 인식하지 못하고 jsx 문법으로 이해한다고 한다.

그래서 tsx 파일 확장자에서는 위처럼 , 하나를 추가해주면 된다.
 
앞으로는 좀 타입을 잘 활용할 수 있는 사람이 되고 싶다.