ドーモ、ドクシャ=サン
ある程度経験があるTypeScriptの民ならば、型を大切にするのは当然のこと。「なんかエラーでるしAny
でいいや」なんて言うのは言語道断であることは疑う余地もないだろう。HTTPレスポンスにおいても同様であってもいいはずだ。ちゃんとエラーハンドリングするなら結局Any
を一回嚙ませた方が都合が良いという勢力もいるが、個人的にはそんなことは無いと思う。この記事は議論用にメモとして残す。
※マサカリ大歓迎記事です
そもそもなんで型をガチガチにするべきなのか
TypeScriptはJavaScriptに型システムを追加したプログラミング言語。型システムのおかげで、変数や関数の引数、戻り値のデータ型がコンパイル時に決定され、エラーを早期に検出できる。型を設定するだけで、多くの恩恵が受けられるが、主な利点を挙げるとすれば、以下の3つだろう。
- 戻り値が保証される為ランタイムでエラーが発生するのを避けられる
- インテリセンスが効く
- 可読性が高くなる <- 正直この恩恵が一番デカい
Any
を使うとこういった恩恵が受けられなくなるので、なるべく使わないようにするのが吉だろう。そもそもTypeScript使うなら設計思想にちゃんと従えよという話である。
HTTPレスポンスという例外
しかし、APIサーバーなどにリクエストを飛ばした時のレスポンスは予測が付かないことがある。正常に成功したとき、接続は正常だがAPIサーバー側で問題が発生したとき、クライアント側でエラーが発生したとき、サーバーの接続系に問題があるとき、なんか知らんけどエラーが発生したとき等々。こういう場合、下手に初めから型で受け取ろうとすると全部catchに流れていってエラーの追跡が面倒になる。ゆえに、一度Any
で受けるという選択肢が生まれる。
{
"error": {
"code": 404,
"message": "Resource not found"
}
}
curl 127.0.0.1:3001/api/hoge
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot GET /api/hoge</pre>
</body>
</html>
etc...
しかし、開発初期でもなければ変なレスポンスが帰って来ること自体がおかしな話である。
サンプルコード
ユーザー情報を取ってくる簡単なアプリを作った
アプリ
//composables/getUser.ts
import type { User, ApiError, FetchState } from '~/types/User';
import type { ApiResponse } from '~/types/ApiResponse';
export function getUser() {
const user: Ref<User | null> = ref(null);
const error: Ref<ApiError | null> = ref(null);
const state: Ref<FetchState> = ref('unknown');
const fetchUser = async (userId: number) => {
state.value = 'loading';
try {
const { data, error: fetchError } = await useFetch<ApiResponse>(`http://127.0.0.1:3001/api/users/${userId}`, { method: "GET" });
console.log(data.value);
if (fetchError.value) {
handleFetchError(fetchError);
return;
}
if (!data.value) {
setError('No data received', 'fetchUser');
return;
}
switch (data.value.response) {
case 'success':
if (isUser(data.value.data)) {
user.value = data.value.data;
state.value = 'success';
} else {
setError('Data received is not a valid user', 'fetchUser');
}
break;
case 'error':
setError(data.value.data.errorMessage, data.value.data.functionName);
break;
default:
setError('Unexpected error occurred', 'fetchUser');
}
} catch (err) {
setError('Network or server error', 'fetchUser');
}
};
const isUser = (data: any): data is User => {
return typeof data === 'object' &&
data !== null &&
typeof data.id === 'number' &&
typeof data.name === 'string' &&
typeof data.email === 'string';
};
const setError = (message: string, functionName: string) => {
error.value = { functionName, errorMessage: message };
state.value = 'error';
};
const handleFetchError = (fetchError: any) => {
const statusCode = fetchError.value?.status;
const statusText = fetchError.value?.statusText || 'Server error';
switch (statusCode) {
case 500:
setError(`Server error: ${statusText}`, 'fetchUser');
break;
default:
setError(`Connection failed: ${statusText}`, 'fetchUser');
}
};
return { user, error, state, fetchUser };
}
// ~/pages/app.vue
<template>
<div>
<button @click="fetchUserData">Load User Data</button>
<p v-if="state === 'loading'">Loading...</p>
<div v-if="state === 'success' && user">
<p>ID: {{ user.id }}</p>
<p>Name: {{ user.name }}</p>
<p>Email: {{ user.email }}</p>
</div>
<div v-if="state === 'error'">
<p>Error occurred in {{ error?.functionName || 'unknown function' }}: {{ error?.errorMessage || 'Unknown error' }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getUser } from '~/composables/getUser';
const userId = ref(1);
const { user, error, state, fetchUser } = getUser();
const fetchUserData = () => {
fetchUser(userId.value);
};
onMounted(fetchUserData);
</script>
サーバー
//server.js
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
const port = 3001;
const users = {
1: { id: 1, name: "Alice", email: "alice@example.com" },
2: { id: 2, name: "Bob", email: "bob@example.com" }
};
// ユーザーをIDで検索する関数
function findUserById(userId) {
const user = users[userId];
if (user) {
return { response: "success", data: user };
} else {
return {
response: "error",
data: {
functionName: "findUserById",
errorMessage: "User not found"
}
};
}
}
app.get('/api/users/:id', (req, res, next) => {
try {
const userId = parseInt(req.params.id, 10);
const result = findUserById(userId);
res.json(result);
} catch (err) {
// エラーハンドリング: エラーレスポンスをクライアントに送信
res.status(200).json({ //バッドプラクティスだが、簡便のためエラーも200で返す
response: "error",
data: {
functionName: "findUserById",
errorMessage: err.message || 'Unknown server error'
}
});
}
});
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
});
なんでサーバー側はjsなのかと聞いてはいけない
基本仕様
/api/users/${userId}
にリクエストを飛ばせばユーザー情報が帰って来る。
成功レスポンス
{
"response": "success",
"data": {
"id": number,
"name": string,
"email": string
}
}
失敗レスポンス
{
"response": "error",
"data": {
"functionName": string,
"errorMessage": string
}
}
// ApiResponse型で受ける
const { data, error: fetchError } = await useFetch<ApiResponse>(`http://127.0.0.1:3001/api/users/${userId}`, { method: "GET" });
// ~/types/ApiResponse.ts
import type { User, ApiError } from './User';
export interface ApiResponseSuccess {
response: 'success';
data: User;
}
export interface ApiResponseError {
response: 'error';
data: ApiError;
}
export type ApiResponse = ApiResponseSuccess | ApiResponseError;
想定するエラーと挙動
1. サーバーエラー(ユーザーが見つからない)
サーバー側のfindUserById関数で、存在しないユーザーID(例: http://127.0.0.1:3001/api/users/999 )を指定してリクエストを行う。
2. ネットワークエラー
サーバーが一時的に停止中・異なるエンドポイントを指定してリクエストを行い、ネットワークエラーをシミュレートする。
3. 不正なデータ形式
サーバーが予期しない形式のデータを返すように一時的にコードを変更し(emailプロパティの削除等)、クライアントのエラーハンドリングをテストする。
4. サーバー側の予期せぬエラー
サーバー側で予期せぬエラーを発生させ(適当なところでthrow new Error()
する)、そのハンドリングをテストする。
動く...動く... 実際動く
あとはここに400系、500系のステータスコードのハンドリングを充実させるだけでも十分使用に耐えうるものになると思うが、実際の所どうなのか(エンタープライズレベルの開発経験ほぼ無い並感)