【TypeScript】型安全なAPIリクエストの実装
投稿日: 2025年01月20日
前回APIレスポンスに関して書きました。
今回は前回の記事に続いてAPIリクエストについて書こうと思います。
10章に入って突然GETリクエスト以外を書かないといけなくなり困惑中の方から、オリアプ開発中だけど型に振り回されている方まで参考になるように書こうと思います。
レスポンスはGET(応答)→データが欲しいからください!!
リクエストはPOSTやPUT(要求)→データを作って(更新して)ください!!
GETもリクエストはするしPOSTやPUTもレスポンスはありますが、こんな意味で書き分けました。
型はフロントエンドとバックエンドを接続するとき、とっても重要な役割を果たします。
フロントエンド側から見ると「この型でデータ作って渡せばデータの登録や更新をしてくれるのね、OK!」
バックエンド側から見ると「フロントエンドから渡されるデータは必ずこの形式なのね、OK!そうであることを前提に処理が成立するようにするね!」
ここでいうデータはリクエストボディ、処理は登録(POST)、更新(PUT)のことです。
ザックリこんな感じで、データの受け渡しをどういう型で行うか決めることが型定義です。
では、このバックエンドとフロントエンドでデータの型が共通ではなかった場合(それぞれ別々に定義した場合)、どんなことが起こるか想像つきますか?
バックエンド側で定義しているリクエストボディの型には存在しているプロパティだから補完もされるし、データに存在するはずだと思って扱っているとまさかのエラー・・。
なんで?? ??とデバックすると値がundefindeになってる・・! なんで?ここがこう?修正したら違うところでエラー!そこを修正するとまたこっちでエラー!!!の無限ループとなってギャー!!!となるんです。
これが型に振り回されている状況ですね。
私?もちろん経験してるに決まってます。型にブンブン振り回されて面談で相談してぶべさんがエラー解消する様子を見て「今のは手品を見たんかな?」って思った記憶ありますね。
こういうことにならないようには、バックエンドとフロントエンドの型を共通化しておく(=同じファイルから型をimportする)ことが超絶重要なことになります。
こうするためにはまずは型を決めることが大事です。型を決めてからバックエンド→フロントエンドの順でコーディングするのが一番間違いなく振り回されない方法です。
もし後からあれも必要だったなってことがあれば足せばいいです。
一旦型から入りましょう。足したら修正が必要なところをエディターが教えてくれますので死ぬほど楽です!!
私が最近作ったアプリのコードの一部を少し修正していい例と悪い例をそれぞれ挙げてみます。
app直下に_typesを用意してそこに型を定義します。app/_types/LongVacations/PostRequest.ts
export type PostRequest = {
title: string;
startDate: Date;
endDate: Date;
isActive: boolean;
schoolDay?: Date;
};
次バックエンドです。型についてなので処理関係のところはガッツリ端折ります。app/api/children/[id]/long_vacation/route.ts
//型をインポートする
import { PostRequest } from "@/app/_types/LongVacation/PostRequest";
import { buildPrisma } from "@/app/_utils/prisma";
export const POST = async (request: NextRequest, { params }: Props) => {
const prisma = await buildPrisma();
const { title, endDate, isActive, startDate, schoolDay }: PostRequest = await request.json();
//省略
return NextResponse.json(
{
message: "success!",
},
{ status: 200 }
);
} catch (e) {
return buildError(e);
}
};
重要なのはここ↓です。
const { title, endDate, isActive, startDate, schoolDay }: PostRequest = await request.json();
は? ?ってなった方向けにちょっと書き変えます。
const body: PostRequest = await request.json();
const { title, endDate, isActive, startDate, schoolDay } = body;
これで、リクエストボディの型はimportしたPostRequest
なので、定義した型が持っているプロパティの値が取得できますし、それ以外は取得できないかつ、タイポすら許されない状態になります。
フロントエンドも抜粋します。
//型をインポートする
import { PostRequest } from "@/app/_types/LongVacation/PostRequest";
const body:PostRequest = {
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
title: data.title,
isActive: data.isActive,
schoolDay: data.schoolDay ? new Date(data.schoolDay) : undefined,
}
const response = await fetch(`/api/children/${childId}/long_vacation`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
共通の型を使っているので、body(リクエストボディ)の型は型通りのオブジェクトになっていないとエラー吐きます。渡す方も安心・・バックエンドが受け取ることを想定していない誤ったデータを渡すことはないです。
なぜなら型が同じだから。
安心過ぎる🥺TypeScript最高🥺🥺🥺
一方で、それぞれ定義、もしくは定義しない場合どうなるか見てみます。
世にも恐ろしいことが起きます。(すみません、大袈裟です。)
正直、バラバラに定義する(=型を共通化しない)なら、定義しないのと同じなのでは?TypeScriptである意味ある?って思います。
むしろ逆に、無駄に型エラーに振り回されてエラー解消したところで動かないってこともあり得るんですよね。。。(コワイ・・)
最初からできるものじゃないので、できてないって落ち込まないでください。私も感覚掴めるまでに相当書いてハマって涙目(嘘)になりながら振り回されて目が回りながら徐々に理解できたので大丈夫です。落ち込みそうになった方より多分私の方が書いてますw
では、バックエンドから。app/api/children/[id]/long_vacation/route.ts
//ファイル内のここで定義。このファイルでしか使ってない
type PostRequest = {
title: string;
startDate: Date;
endDate: Date;
isActive: boolean;
schoolDay?: Date;
};
export const POST = async (request: NextRequest, { params }: Props) => {
const prisma = await buildPrisma();
const { title, endDate, isActive, startDate, schoolDay }: PostRequest = await request.json();
//省略
return NextResponse.json(
{
message: "success!",
},
{ status: 200 }
);
} catch (e) {
return buildError(e);
}
};
そしてフロントエンド。
//ファイル内のここで定義。このファイルでしか使ってない
type PostRequest = {
title: string;
startDate: Date;
endDate: Date;
isActive: boolean;
schoolDay?: Date;
};
const body:PostRequest = {
startDate: new Date(data.startDate),
endDate: new Date(data.endDate),
title: data.title,
isActive: data.isActive,
schoolDay: data.schoolDay ? new Date(data.schoolDay) : undefined,
}
const response = await fetch(`/api/children/${childId}/long_vacation`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
今の状態では、型は定義して型付けしているものの、全く別の型ですよね(中身は一緒ですけど。)
極端な例ですが、フロントエンドの型が全然バックエンドと違ったら・・?
//titleのみ
type PostRequest = {
title: string;
};
const body:PostRequest = {
title: data.title,
}
const response = await fetch(`/api/children/${childId}/long_vacation`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
型エラーは吐きません。型通りのリクエストボディになってるので当然型エラーでません。
ではこのリクエストボディを受け取ったバックエンドどうなるでしょう・・?
const { title, endDate, isActive, startDate, schoolDay }: PostRequest = await request.json();
title以外のプロパティは渡してないですよね?ということはこちらも型の通りなのでエラーは吐かないですが、 endDate, isActive, startDate, schoolDayすべてundefindeになって、その後の処理に失敗して、そこでエラーを吐くことになります・・。
鳥肌立つ~!!!って感じです。
型の理解があれば原因すぐわかるんですが、わからないとめちゃ時間かかるかもですね。
初心者こそ型から定義! !ですね。
型を定義しない場合、バックエンドのbodyはanyになるのでなんでも存在するような振る舞いをするけど実際は渡されてない値は存在しないので当然undefindeになり、フロントエンドはリクエストボディの値として定義したままの型に推論されます。
これもう全くTypeScriptである意味ないですよね。。。
公式のドキュメントに親切にリクエストボディのプロパティとそれぞれの型を公表してくれているはずなので、それを見ながら型を定義して、その通りにリクエストボディを書いて叩けばOKです。
こういった値を受け入れてると公式が教えてくれてるので見るしかないです!
だいたいわかりやすく教えてくれてるイメージ・・ですがどうでしょう師匠。。
例えば、Line Messaging APIのwebhookだとPOSTリクエストを受け付けてて公式にリクエストボディのプロパティ書いてますね。
destinationはstring、eventsはwebhookイベントオブジェクトのarray。これがわかればもうこっちのもんです。
公式ドキュメントから探して、見つけたらもうOKです。終わり!
type Event = {
//別途定義
}
type LineWebhook = {
destination:string;
events:Event[]
}
この型と決まってるのでその通りに型書いて、リクエストボディ作って渡すだけ・・☺
今回は結構丁寧に書いたつもりなんですが、わかりにくかったら遠慮なく言ってください!
TypeScriptは最初は私を苦しめる存在みたいなものだったんですが、仲良くなってからは大好きな私の味方です♪
型があったら安心! !推論もあって便利!大好き! !みたいな感じです。
最初からそんな存在にするのは恐らく難しいことですが、味方につけておくと心強いのでたくさんコード書いて体で覚えるつもりで、書いて書いて書いて書いて書きまくって頑張りましょう! !