(rough) 타입스크립트를 이용한 풀스택 강좌 - useRPC 훅 만들기

44523·2024년 1월 27일

훅을 만들기전에 필요한 타입들을 만들건데, 서버에서도 필요할 수 있기 때문에 rpc.ts파일에 추가한다.

rpc.ts

export type RpcFunctionRequest<T extends keyof IRpc> = Parameters<IRpc[T]>[0];
export type RpcFunctionResponse<T extends keyof IRpc> = ReturnType<IRpc[T]>;

export type RpcRequest<T extends keyof IRpc> = {
  name: T;
  request: RpcFunctionRequest<T>;
};

export type RpcResponse<T extends keyof IRpc> = {
  error?: RpcError;
  response?: RpcFunctionResponse<T>;
};

export type RpcError = string;

뜯어보기

export type RpcFunctionRequest<T extends keyof IRpc> = Parameters<IRpc[T]>[0];

keyof RPC로 T를 제안했기 때문에 T는 항상 RPC함수의 이름이 된다. Parameters는 타입스크립트에서 기본으로 제공해주는 유틸리티 타입으로 주어진 함수의 인자를 배열로 반환한다. 우리가 작성했던 RPC의 모든 함수는 인자를 하나만 받기 때문에 [0]으로 첫번째 인자만 반환하게 한다.

리스폰스는 비슷한데, ReturnType라는 기본 유틸리티 타입을 사용해서 주어진 RPC 기능의 리턴 타입을 반환한다.

type Parameters<T extends (...args: any) => any> = T extends (
  ...args: infer P
) => any
  ? P
  : never;
type ReturnType<T extends (...args: any) => any> = T extends (
  ...args: any
) => infer R
  ? R
  : any;

이것은 Parameters, ReturnType의 실제 소스코드이다. 제네릭타입을 함수로 제한하고 컨디셔널 타입과 infer를 사용해서 원하는 타입을 추론하는게 PromiseRPC를 만들떄와 정확히 같은 로직이다.
내부 구현까지 숙지하면 더 깊은 이해가 가능하다.

RpcFunctionRequest와 RpcFunctionResponse는 RPC함수의 이름으로 부터 요청타입과 반환타입을 쉽게 가져올 수 있다.

export type RpcRequest<T extends keyof IRpc> = {
  name: T;
  request: RpcFunctionRequest<T>;
};

RpcRequest는 특정 RPC함수의 이름과 요청타입을 가지고 있다. 서버로 요청을 보낼때 전송될 객체의 타입이다. 그냥 요청값만 보내면 어떤 기능을 요청한것인지 알수 없기 때문에 기능 이름까지 보낸다.

export type RpcResponse<T extends keyof IRpc> = {
  error?: RpcError;
  response?: RpcFunctionResponse<T>;
};

서버에서 반환되는 제이슨 문자열을 해석할때 기준이되는 타입. 다만 기능 이름에 대한 정보는 담지않고 있는데 모든 응답은 요청과 함께 처리되기 때문에 응답을 받을때는 어떤 기능에 대한 응답인지 알 수 있기 때문이다.

export type RpcError = string;

에러가 발생했을때, 에러의 원인을 문자열로 받기위함이다.
여기서

error?: RpcError;
  response?: RpcFunctionResponse<T>;

이렇게 옵셔널 타입인 이유는 문제가 발생하면 리스폰스가 없고, 문제가 없으면 에러가 없기때문이다.

RPC를 다 작성하면 꼭 스트립트를 실행해주자.

./cprpc.sh

fetchRpc.ts

서버와 통신을 담당하는 함수를 작성해주자.

import { IRpc, RpcFunctionResponse, RpcRequest } from "../rpcgen"
import server from "../server"

export default function fetchRpc<T extends keyof IRpc>(rpcRequest: RpcRequest<T>): Promise<RpcFunctionResponse<T>> {
  return server[rpcRequest.name](rpcRequest.request)
}

이런식으로 작성하면 에러가 나는데, 호출하는부분을 보면

rpcRequest.name은 T타입이다. 서버는 PromiseRpc로 명시해놨고, 결국 서버에 네임으로 접근한 타입은 PromiseRpc[t]로 접근한 타입이고, 이는 PromiseRpc에 모든 프로퍼티의 합집합이다.

rpcRequest.request는 모든 요청의 합집합이기 때문에 함수의 타입과 인자의 타입이 일치하지 않는다.

ex)

F함수는 string와 number를 받는 함수의 합집합이다. 이를 잘못 생각하면 string 또는? number 을 넘겨주면 될거라고 생각하는데 F가 string을 받는지 number을 받는지 알수없기 때문에 string이면서 number인 타입을 넘겨주어야한다. 타입스크립트에는 string이며 number인 타입이 없기 때문에 never가 뜬다.

결국 이 에러는 내가 넣으려는 함수의 타입은 rpcRequest.request, 실제 필요한 타입은 모든 Request의 교집합이기 때문에 에러가 발생하는 것이다.

전체 요청중에 어떤 타입을 받는지 알 수 없기 때문에 타입스크립트가 모든 요청을 만족하는 타입을 받는것으로 추론한것이다.

해결책은 as any를 사용하거나, 서버의 타입을 PromiseRpc대신에

const server: {
  [K in keyof IRpc]: (arg: RpcFunctionRequest<K>) => Promise<RpcFunctionResponse<K>>
} = {
  createPost: (req) => {
    posts.push({ body: req.body, comments: [], id: posts.length, author: user, timestamp: Date.now() })
    return Promise.resolve({})
  },

이런식으로 바꾸면 에러가 발생되지 않는다. 프로퍼티의 이름을 기준으로 해당 프로퍼티와 짝이 맞는 요쳥과 응답을 받는다고 명시하였기 때문이다.

비동기처리를 위한 리액트 훅 작성

useAsync.tsx

import { useState } from "react"

export default function useAsync<TArg, TReturn, TError>(f: (arg: TArg) => Promise<TReturn>) {
  const [loading, setLoading] = useState(false)
  const [value, setValue] = useState<TReturn>()
  const [error, setError] = useState<TError>()

  const request = (arg: TArg) => {
    setLoading(true)
    setValue(undefined)
    setError(undefined)
    f(arg)
      .then((v) => {
        setValue(v)
        setLoading(false)
        setError(undefined)
      })
      .catch((e) => {
        setLoading(false)
        setError(e)
      })
  }

  return { request, loading, value, error }
}

리액트로 비동기 처리를 한다면 꼭 만들어보는 훅인데, Promise를 반환하는 비동기 함수를 인자로 받아서 로딩중인지의 여부, 반환값, 오류가 났다면 오류까지 상태로 만들고 요청을 하는 함수를 만들어서 반환한다.

Promise가 거절되면 뭘 던질지 알 수 없으므로 에러의 타입은 제네릭으로 받았다.

이부분의 리턴타입의 명시는 타입스크립트에게 맡기도록 했다.

useRpc.ts

import {
  IRpc,
  RpcError,
  RpcFunctionRequest,
  RpcFunctionResponse,
} from "../rpcgen";
import fetchRpc from "../utils/fetchRpc";
import useAsync from "./useAsync";

export default function useRpc<T extends keyof IRpc>(name: T) {
  const f = (request: RpcFunctionRequest<T>) => fetchRpc({ name, request });
  return useAsync<RpcFunctionRequest<T>, RpcFunctionResponse<T>, RpcError>(f);
}

Promise를 반환하는 함수를 만들어주고 useAsync에 인자로 넣어주면 된다.

0개의 댓글