型安全なHTTPリクエストを考えてみる
記事として投稿したのでクローズ
型安全な HTTP リクエストというと、aspida が割と有名な気がするが、自分で試せてなかったので試す。
また、この aspida の特徴の一つとして、レスポンスの型をつけられるというものがあるが、
型ガードのように想定したデータがちゃんと返ってきているか検査する仕組みが動いているのかどうかが疑問だった。
それも試してみて、もしそこまではしていないのであれば、別途型ガードができるようなライブラリとの組み合わせを試してみる。
候補としてはこちら
最近よく聞くのは Zod の印象。
Zod メモ
aspida/ky について
個人的に最近使っている HTTP クライアントライブラリが ky であるため、ky のサポートが気になった。
調べている時に ky にも対応しているとの文言を見かけたが、現在リポジトリを見ても packages 配下に存在しない。
作者の方のツイートを見るに、どうも aspida のv1.1リリース時にサポート終了したらしい。
一応、npm パッケージとしては残っているようである
aspida/fetch で代替
ky の恩恵の一つとして、ステータスコード400、500番台が返ってきたときに自動で HTTPError をスローしてくれるというものがあった。
これに関しては、aspida/fetch のオプションとして、スローしてくれるようにするものがあるので、代替てしまってもいいかもしれない。
const fetchConfig: FetchConfig = {
credentials: 'include',
baseURL: '/api',
throwHttpErrors: true, // throw an error on 4xx/5xx, default is false
};
セットアップ
インストール
aspida のインストール
yarn add @aspida/fetch
直接 aspida と関係ないが、コマンド並列実行のためにインストール
yarn add -D npm-run-all
設定
src 配下にapi
ディレクトリを作成(お好みで)
aspida の設定ファイルで、このディレクトリを指定
module.exports = {
input: 'src/api',
};
このディレクト配下に各 API の型情報を作成。
api/users
の GET と POST であれば、以下のような感じ。
(モデルの型は別途 models 配下に作成しておいた)
※追記...クエリパラメータやリクエストボディの型も models 定義の方がいいかも
import { User, Users } from '@/models/User';
export type Methods = {
get: {
query?: {
limit: number;
};
resBody: Users;
};
post: {
reqBody: {
name: string;
};
resBody: User;
/**
* reqHeaders(?): ...
* reqFormat: ...
* status: ...
* resHeaders(?): ...
* polymorph: [...]
*/
};
};
実行
実行すると、定義した型情報を元にロジックを生成してくれる
# 単純な実行
yarn run -s aspida
# 監視モード
yarn run -s aspida --watch
ローカルサーバ起動時に、並列で監視モード起動するようにしておけばよさそう。
"scripts": {
"dev": "run-p dev:*",
"dev:next": "next dev",
"dev:aspida": "aspida --watch",
"build": "aspida && next build",
...
},
クライアント
lib/aspida.ts
を作成しておいて、ここでエクスポートしたクライアントを各ドメインで使う感じでよさそう。
import aspida, { FetchConfig } from '@aspida/fetch';
import api from '@/api/$api';
const fetchConfig: FetchConfig = {
credentials: 'include',
baseURL: '/api',
throwHttpErrors: true, // throw an error on 4xx/5xx, default is false
};
const client = api(aspida(fetch, fetchConfig));
export { client };
試しに定義した型以外の値も返すように API を変えてみたが、特に問題なく動作してしまったので、やはりレスポンスデータの型検査は行われてないっぽい。
Zod
インストール
yarn add Zod
strict モードを有効化しておく
// tsconfig.json
{
// ...
"compilerOptions": {
// ...
"strict": true
}
}
型チェック実行
スキーマを定義しておいて、そのスキーマでparse()
を使うと、型チェック実行。
問題なければ通過したものを返す。問題あればZodError
をスローする。
import { z } from "zod";
// creating a schema for strings
const mySchema = z.string();
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws ZodError
スキーマから型定義生成
infer
を使うことで、このスキーマから型定義を生成することも出来るとのこと。
import { z } from "zod";
const User = z.object({
username: z.string(),
});
User.parse({ username: string });
// extract the inferred type
type User = z.infer<typeof User>;
// { username: string }
すでにある型定義からスキーマを作る
逆にすでにある型定義を元にスキーマを作りたい時はどうするか?と思っていたら、こんな感じにすればいいらしい
型定義と違う型のスキーマにしようとすると、ちゃんとエラーが出る。
import * as z from "zod";
// zodに渡せる型に変換する型
type toZod<T extends Record<string, any>> = {
[K in keyof T]-?: z.ZodType<T[K]>;
}
interface Hoge {
hello: string;
world: string;
}
// Hogeを型引数として渡す
const HogeSchema = z.object<toZod<IHoge>>({
hello: z.string(),
world: z.string()
})
こちらより
check() について
型ガード的なやつ。どうやら v3 リリース時に削除されたらしく使えなくなっていた。
parse でよしなにやってねってことかもしれない。
実際に使ってみる
こんな感じかなぁ。
型ガードの if ブロックなしで書ける分、いくらかスッキリ見えるかもね。
import { z } from 'zod';
export type ToZod<T extends Record<string, any>> = {
[K in keyof T]-?: z.ZodType<T[K]>;
};
export type User = {
id: number;
name: string;
};
export const userSchema = z
.object<ToZod<User>>({
id: z.number(),
name: z.string(),
})
.strict();
export type Users = User[];
export const usersSchema = z.array(userSchema);
import { GetListRequestQuery } from '@/models/User';
import { usersSchema } from '@/models/User';
import { client } from '@/lib/aspida';
const getUsers = async (query?: GetListRequestQuery) => {
const res = await client.users.get({ query });
return usersSchema.parse(res.body);
};
export default getUsers;
import { useState, useEffect, useCallback } from 'react';
import { ZodError } from 'zod';
import { HTTPError } from '@aspida/fetch';
import { Users } from '@/models/User';
import getUsers from '@/domains/getUsers';
const useUsers = () => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>();
const [users, setUsers] = useState<Users>();
useEffect(() => {
setIsLoading(true);
getUsers()
.then((data) => {
setUsers(data);
})
.catch((err) => {
if (err instanceof HTTPError) {
setErrorMessage('ユーザ情報の取得に失敗しました');
} else if (err instanceof ZodError) {
setErrorMessage('想定しないデータの取得が行われました');
}
})
.finally(() => {
setIsLoading(false);
});
}, []);
return { isLoading, errorMessage, users };
};
export default useUsers;