fetchの結果に型を付けたい
この記事は Aizu Advent Calendar 2020 6 日目の記事になります。
Fetch API の問題
Web フロントエンドでは任意のタイミングで HTTP リクエストをすることがあります。
XMLHttpRequest
, jQuery.ajax
, axios
, etc... たくさんの手段があります。
僕はその中でも Fetch API
を使うことが多いのですが、ここで少し問題点があります。
下のコードを見てみましょう。
/*
このエンドポイントにリクエストを送ると
{
id: String,
name: String,
age: Number,
created_at: Number,
updated_at: Number,
}
というデータが返ってくるとする
*/
const END_POINT = '<URL>';
const apiRequest = () => fetch(END_POINT).then((x) => x.json());
const main = async () => {
// この時点でもuserの型は・・・?
const user = await apiRequest();
};
main();
単純に GET リクエストを出すだけのコードですね。
コメントにもあるように user
という変数はどのような方になっているでしょうか?
正解は、 any
型ですね。
ここで any
型から期待してるデータ型になるよう 解釈 させる方法は以下のようになると思います。
type User = {
id: String;
name: String;
age: Number;
created_at: Number;
updated_at: Number;
};
const apiRequest = (): Promise<User> => fetch(END_POINT).then((x) => x.json());
これで apiRequest
関数を呼び出した返り値は Promise<User>
になるので any
型ではなく User
型として 解釈 されるようになります。
ここでやたら 解釈 という言葉を強調してますが、真に User
型ではないということです。
仮に API 側の変更がありリクエストの結果が以下のようなデータになったとしましょう。
/*
このエンドポイントにリクエストを送ると
{
id: String,
firstName: String,
lastName: String,
age: Number,
created_at: Number,
updated_at: Number,
}
というデータが返ってくるとする
*/
const END_POINT = '<URL>';
このデータは、User
型と一致しませんが上記のコードでは User
型ということで 解釈 されていますので問題なくコンパイルされます。
どこでエラーさせる?
ここでエラーが発生する場所はどこでしょうか?
おそらく API リクエストをするということは返ってきた値をなんらかの処理に使っていると思います。
例えばこんな感じで
const UserView = () => {
const [user, setUser] = useState(null);
useEffect(() => {
apiRequest().then((x) => setUser(x));
}, []);
return (
<div>
<div>{user.name}</div>
<div>{user.age}</div>
</div>
);
};
初めの API リクエストのデータだった場合これでも平気ですが、変更があった場合はどうなるでしょうか?
user.name
の呼び出しでエラーが発生します。このエラーは本来ここで起こっても良いエラーでしょうか?
Web フロントエンド的には User
という型が正しく、今回のエラーはリクエストの段階で起こってほしいエラーのはずです。
そこで、API リクエストの型でバリデーションをかけようという発想になります。
JSON にバリデーションをかける
当然自分でバリデーションをかけることも可能ですが、大抵のアプリケーションは膨大な数のリクエストになるので地道にバリデーションを書けていくとそのうち面倒になって書かなくなります。
そこでライブラリに頼ります。JSON にバリデーションをかけるライブラリはいくつかありますが今回は @mojotech/json-type-validation を紹介します。
このライブラリは、 Decoder
というものを定義し、any
な値に対して実行することによりバリデーションを書けます。
実際に User
型を使って見てみましょう。
import {
Decoder,
object,
string,
number,
} from '@mojotech/json-type-validation';
type User = {
id: String;
name: String;
age: Number;
created_at: Number;
updated_at: Number;
};
const userDecoder: Decoder<User> = object({
id: string(),
name: string(),
age: number(),
created_at: number(),
updated_at: number(),
});
const apiRequest = (): Promise<User> =>
fetch(END_POINT)
.then((x) => x.json())
.then(userDecoder.runPromise);
const main = async () => {
const user = await apiRequest();
};
main();
これで apiRequest
は、 User
型じゃなかったらリクエストの段階でエラーになるようになります。
このエラーを修正するために User
型の定義を更新してみましょう。
type User = {
id: String;
firstName: String;
lastName: String;
age: Number;
created_at: Number;
updated_at: Number;
};
そうすると
Type 'Decoder<{ id: string; name: string; age: number; created_at: number; updated_at: number; }>' is not assignable to type 'Decoder<User>'.
Type '{ id: string; name: string; age: number; created_at: number; updated_at: number; }' is missing the following properties from type 'User': firstName, lastName
という型エラーが出ます。実行する前にエラーが出てくれるのでコンパイルの段階で落ちてくれます。
Decode のタイミングで色々やる
バリデーションをしてくれる、型と Decoder の型が違った場合に型エラーが出てくれる、というのだけでも結構便利ですが、このライブラリは任意の Decoder に対して map
等の高階関数を使うことができます。
例えば、User
型の created_at
と updated_at
を TS で扱いやすいように Date
型にしたいとします。
type User = {
id: String;
firstName: String;
lastName: String;
age: Number;
created_at: Date;
updated_at: Date;
};
このとき、Decoder
は下のコードのように出来ます。
const userDecoder: Decoder<User> = object({
id: string(),
firstName: string(),
lastName: string(),
age: number(),
created_at: number().map((x) => new Date(x)),
updated_at: number().map((x) => new Date(x)),
});
これで created_at
と updated_at
は Date
型になってます。
Decoder が持っているメソッドには map
の他にも where
, andThen
があります。
where
は、Array.filter
のようなもので渡した関数が true
になる値だけ Decode を成功させます。
string().where((x) => x.length === 5);
andThen
は、 Array.flatMap
のようなもので Decoder
型が返り値になるような関数を渡します。
type State = 'read' | 'unread';
const stateDecoder = string().andThen((x) => {
switch (x) {
case 'read':
return succeed<State>('read');
case 'unread':
return succeed<State>('unread');
default:
return fail('error');
}
});
まとめ
これ以外にも JSON をバリデーションしてくれるライブラリはありますが、僕は Elm を少しやっているということもありこのライブラリが使いやすかったです。
API もシンプルなものになっているので同じような悩みを持っている方は一度使ってみてはどうでしょうか?
Discussion