hono rpc, typesafe하게 사용하기-1
회사에서 새로운 프로젝트 개발시 서버 http 프레임워크를 hono로 사용하게 되었다.
hono는 rpc처럼 사용할수있게 타입추론도 지원이 된다.
다만 심플하게만 되어있어서 사용하기엔 커스텀이 필요하다.
hono에서 rpc 기능 사용법
hono-guides-docs에 잘 안내 되어있다.
Server
const app = new Hono().post(...).get(...);
export AppType = typeof app
서버에서는 타입추론이 가능하게 app을 체이닝을하여서 만들기만 하면 된다.
Client
const client = hc<AppType>("http://localhost:3000/");
const res = await client.posts.$post({
json: {
title: "Hello yanguk",
body: "goodbye",
},
});
// fetch와 동일하게 statusCode 200 ~ 300 으로 판단된다
if (res.ok) {
const data = await res.json();
console.log(data.message);
}
문제점
-
호노는 에러 타입을 추론해주지 않는다.
- 이전 프로젝트에서
trpc를 썼을땐 에러 타입도 같이 잡혔는데, 여기선 에러 응답을 어떻게 하는지는 자유라서 당연한 것일 수도 있다.
- 이전 프로젝트에서
-
위 케이스로 인하여, 클라이언트에서 받는 결과값은 성공케이스 타입만 존재하게 된다.
const res = await client.posts.$post({
json: {
title: "Hello yanguk",
body: "goodbye",
},
});
/**
* 해당 api에서 json으로 `{ result: 'ok' }`을 내려준 케이스의 응답 타입이다.
*
* ClientResponse<{ result: 'ok' }, 200, "json">
*
* 여기선 무조건 200의 타입만 잡히기의 res.ok의 타입도 boolean이 아닌 true로 나옴.
*
* but 사실 실제 상황에선
* InternalServerError도 나올수가 있음. (공통 에러 핸들러로 부터...)
*/
type ResType = typeof res;
/**
* { result: 'ok' } 값이 담김.
*/
const data = await res.json();
express 썼을때 처럼 직접 에러 처리 부분을 개발해야된다.
trpc 쓰면 이런 모듈 개발 없이 서비스 개발에 만 집중할 수 있었는데, hono에서는 좀 아쉬운 부분이다.
해결하기
Server
- 에러의 공통 포멧을 위하여 커스텀 에러 만들기
hono-exception 처럼 이미 지원되는게 있지만, 필자는 커스텀 해서 사용하였음.
밑에 예제 코드에서의 Yu는 yanguk을 줄여서 prefix로 붙힘
// server/lib/error.ts
import type { ContentfulStatusCode } from 'hono/utils/http-status'
export const YuErrorCodes = {
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
INTERNAL_SERVER_ERROR: 500,
...,
} as const
export type YuErrorCode = keyof typeof YuErrorCodes
export type YuErrorOptions<T> = {
code: T
message?: string
}
export type YuErrorJson = {
code: YuErrorCode
message: string
}
/**
* Yanguk 커스텀에러
*/
export class YuError extends Error {
public code: YuErrorCode
public statusCode: ContentfulStatusCode
constructor({ code, message }: YuErrorOptions<YuErrorCode>) {
super(message ?? code)
this.name = 'YuError'
this.code = code
this.statusCode = YuErrorCodes[code]
}
toJSON(): YuErrorJson {
return {
code: this.code,
message: this.message,
}
}
}
export const NotFund = new YuError({
code: 'NOT_FOUND',
message: 'Not found',
})
export const InternalServerError = new YuError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Something went wrong.',
})
- 공통 에러 처리하기
app.onError((err, c) => {
logger.error({ err });
if (err instanceof YuError) {
return c.json(err.toJSON(), err.statusCode);
}
return c.json(InternalServerError.toJSON(), InternalServerError.statusCode);
});
이렇게 해서 서비스로직에서는 YuError로 throw 해주면
받아서 처리하고, 그외에 에러들은 InternalServerError로 내려준다.
이러면 에러응답은 json 값으로, 항상 같은 타입을 띄게된다. (YuErrorJson)
Client
- Client 에러 객체 정의하기
type YuClientCode = YuErrorCode | "REQUEST_FAILED";
type YuClientErrorJson = {
code: YuClientCode;
message: string;
};
export class YuClientErr extends Error {
public code: YuClientCode;
constructor({ code, message }: YuErrorOptions<YuClientCode>) {
super(message ?? code);
this.name = "YuClientErr";
this.code = code;
}
}
export const isYuClientErr = (err: unknown): err is YuClientErr =>
err instanceof YuClientErr;
클라이언트에서는 api 호출시 요청실패 타입도 존재하기에
서버 에러에서 확장하여 작성한다.
callRpc, 응답 실패 구분하여 타입 제네릭 넣어주기
export type { ClientResponse } from "hono/client";
export const callRpc = async <T>(
rpc: Promise<ClientResponse<T>>,
): Promise<{ ok: true; data: T } | { ok: false; data: YuClientErrorJson }> => {
try {
const data = await rpc;
if (!data.ok) {
const res = (await data.json()) as YuClientErrorJson;
return { ok: false, data: res };
}
const res = await data.json();
return { ok: true, data: res as T };
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: "Failed to process the request.";
return {
ok: false,
data: {
code: "REQUEST_FAILED",
message,
},
};
}
};
rpc요청에선 성공응답 타입은 얻을 수 있으니까,
성공했을땐 해당 타입을 리턴하고, 실패한경우 위에서 정의한 커스텀 에러 형태의 포멧으로 리턴해준다.
여기선 에러응답도 json으로 받은 값으로 그대로 사용하였다.
결과 받아서 분기하여 타입추론을 돕기위해 { ok: boolean } 값을 추가해준다.
callRpcOrThrow, 에러일때 throw 하는 형태로 한번더 구현체 만들기
export const callRpcOrThrow = async <T>(
rpc: Promise<ClientResponse<T>>,
): Promise<T> => {
const res = await callRpc(rpc);
if (!res.ok) {
throw new YuClientErr({
code: res.data.code,
message: res.data.message,
});
}
return res.data;
};
- 에러 핸들링 하는부분 타입세이프하게 처리하도록하기
export const isYuClientErr = (err: unknown): err is YuClientErr =>
err instanceof YuClientErr;
export const handleYuClientErrOrThrow =
<T extends (e: YuClientErr) => void>(handler: T) =>
(err: Error) => {
if (isYuClientErr(err)) {
handler(err);
return;
}
// 코드가 잘못되서 런타임에러 말고는 도달 불가능한 케이스
console.error("Unreachable error: ", err);
throw err;
};
- 실제사용, tanstack-query랑 같이쓰기
실무에 적용시킨 예제 코드를 가져와봤다.
const loginMutation = useMutation({
mutationFn: (account: string, pwd: string) =>
callRpcOrThrow(
client.auth.login.$put({
json: { account, pwd },
}),
),
onSuccess: () => {
void navigate({ to: "/dashboard" });
},
onError: handleYuClientErrOrThrow((err) => {
if (err.code === "INTERNAL_SERVER_ERROR") {
console.error("something wrong");
}
}),
});
마무리
tanstack-query랑 연계해서 하는방식이 아쉬운데,
trpc를 참고하여서 추후 개발해야겠다. (개발 진행중)
다음 포스팅 에서 ky랑 같이 쓰는 방법을 찾아서, 따로 개발할 필요 없을 듯하다
// trpc 사용예제
trpc.posts.mutationOptions({
onSucess: ...,
onError: ...,
})
참고로 메소드로 값을 받고 추론해서 하는 방식을 할려면
Proxy 객체를 써야 하는데 그 구현체는 아래에서 확인할 수 있다.
hono-rpc-query패키지는 에러 케이스를 다룰수가 없어서 아쉬운 부분이있다.
다른 페칭 라이브러리랑 같이 사용하면 문제없음.