TypeScriptの例外処理にResult型を使ってみたら最高に心地よかった
きっかけ
業務で関わっているプロジェクトにおいて、例外処理にResult型が使われており直近で自分でも例外処理を書く機会があった。
実際に書いてみたところ、エラー処理がパズルのようにパチっとはまっていく感覚がありすごく心地よかったので、ちゃんと理解しておこうと思う。
Result型ってどんな型?
ざっくり表現すると処理に失敗する可能性があることを示す型です。
Result型を使うとどんないいことがあるの?
- コードを見るだけで少なくても例外が発生する可能性がわかる
- (TypeScriptにおける)Result型では型の検証を飛ばして値を使用することは原則できない
自分がResult型を例外処理で使ってみて良いなと思ったのは、
throwしたErrorがどこまで伝搬するのかが把握しやすいところ
例えば、記事情報と執筆者の情報をAPIから取得する処理を考える
throwで例外を投げ、try-catchでハンドリングする場合はこんな感じ
const fetchPost = async (id: number) => {
const url = `https://jsonplaceholder.typicode.com/posts/${id}`;
const response = await fetch(url, {
method: 'GET',
});
if (!response.ok) {
throw new Error("Failed to fetch post");
}
const post = await response.json();
return post;
};
const fetchAuthor = async (userId: number) => {
const url = `https://jsonplaceholder.typicode.com/users/${userId}`;
const response = await fetch(url, {
method: 'GET',
});
if (!response.ok) {
throw new Error("Failed to fetch author");
}
const author = await response.json();
return author;
};
const fetchPostAndAuthor = async (id: number) => {
try {
const post = await fetchPost(id);
const author = await fetchAuthor(post.userId);
return { post, author };
} catch (error) {
console.error(error instanceof Error ? error.message : "An unknown error occurred");
}
};
各関数でthrowされたErrorは呼び出し側のtry-catch文によって捕まえられるような実装になる
fetchPostとfetchAuthorの例外はcatch 内でまとめられて処理される感じ
この場合、コードが複雑になってくると、throwされたErrorがどこでハンドリングされるのかが非常に追いづらい(もしかするとうまい実装があるのかもしれないが)
一方、Result型を使った場合はこんな感じ
const fetchPost = async (id: number) => {
const url = `https://jsonplaceholder.typicode.com/posts/${id}`;
const response = await fetch(url, {
method: 'GET',
});
if (!response.ok) {
return r.fail("Failed to fetch post");
}
const post = await response.json()
return r.succeed(post);
};
const fetchAuthor = async (userId: number) => {
const url = `https://jsonplaceholder.typicode.com/users/${userId}`
const response = await fetch(url, {
method: 'GET',
});
if (!response.ok) {
return r.fail("Failed to fetch author");
}
const post = await response.json()
return r.succeed(post);
}
const fetchPostAndAuthor = async (id: number) => {
const postResult = await fetchPost(id);
if (r.isFailure(postResult)) {
console.error(postResult.cause);
}
const userId = postResult.value.userId;
const authorResult = await fetchAuthor(userId);
if (r.isFailure(authorResult)) {
console.error(authorResult.cause);
}
return r.succeed({ post: postResult.value, author: authorResult.value });
}
各関数(fetchPost
,fetchAuthor
)はResult型を返すので、値を使用するためには都度型の検証を行わないといけない。
この例でいうと、const useId = postResult.value.userId;
の箇所でpostResultからuserIdを抜き出しているが、これはr.isFailure(postResult)
を前の行で行わないと型エラーが起きる。
(TypeScriptにおける)Result型では型の検証を飛ばして値を使用することは原則できない
つまり、関数呼び出しと型の検証がセットになるので、throwされたErrorがどこまで伝搬してどのようにハンドリングされるのかがめちゃわかりやすくなる。
また、自然とコードは上から処理順に並ぶようになるので、全体的な処理も追いやすい。
記事内でResult型の不便なところ
抜粋)
- コード量が増える
- 一箇所Resultだと全部Resultに汚染される可能性がある(実装により改善可能)
コード量が増えてしまう問題、Result型を一部で使うとそれ以外も使わないといけなくなりそうなのは実感してる
このライブラリだと、Result型を通常のtry...catch文で使えるように変換する(戻す)unwrap
関数を提供してくれてる 👀
const result = returnResult();
// Failure型だとthrowされる
// 〇〇なのでunwrapします
const value = unwrap(result);
コード量が増えてしまう問題、Result型を一部で使うとそれ以外も使わないといけなくなりそうなのは実感してる
unwrap関数もうまく使えばこの問題は解決できそう