fetchの結果に型を付けたい

5 min read読了の目安(約4800字

この記事は 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_atupdated_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_atupdated_atDate 型になってます。
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 もシンプルなものになっているので同じような悩みを持っている方は一度使ってみてはどうでしょうか?

この記事に贈られたバッジ