TypeScript でのエラーハンドリングについて検討する機会があったので、その結果を記事にしました。
あくまでも 1 つの例として参考にしてください。
対象読者
- TypeScript の知識がある人
- NestJS でアプリケーション開発したことがある人
- Java のエラーハンドリングの知識がある人
アプリケーションの前提
- アプリケーションはコントローラークラス、サービスクラス、リポジトリクラスの構成
- サービスクラスからリポジトリクラスを呼ぶ
- ORM として Prisma を使う(本記事で Prisam の使い方などの説明はしません)
問題点
TypeScript では Java のように throws
が使えないという問題があります。
最初は以下のコードのようにエラーをスローするように実装しました。
リポジトリクラス
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma, Users } from '@prisma/client';
import { PrismaRepository } from 'src/prisma/prisma.repository';
@Injectable()
export class PrismaUserRepository {
constructor(private prisma: PrismaRepository) {}
async createUser(userId: number, userName: string): Promise<Users> {
try {
return await this.prisma.users.create({
data: {
id: userId,
name: userName,
},
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
// サービスクラスがリポジトリクラスの詳細に依存しないためにエラークラスを置き換える
throw new ConflictException(error.message);
} else {
throw new Error(error.message);
}
}
}
}
サービスクラス
import { ConflictException, Injectable } from '@nestjs/common';
import { PrismaUserRepository } from './prismaUser.repository';
import { Users } from '@prisma/client';
@Injectable()
export class UserService {
constructor(private prismaUserRepository: PrismaUserRepository) {}
async createUser(userId: number, userName: string): Promise<Users> {
try {
// 正常時の処理
return await this.prismaUserRepository.createUser(userId, userName);
} catch (error) {
if (error instanceof ConflictException) {
// ConflictException時の処理
} else if (error instanceof Error) {
// ConflictException以外のエラー時の処理
}
}
}
}
サービスクラスで try-catch
構文を使用してエラーハンドリングしています。しかし、ここでエラーハンドリングしなくてもコンパイルエラーになりません。
そのため、エラーハンドリングの実装を忘れてしまう可能性があります。
リポジトリクラスのコメントに呼び出し側でエラーハンドリングする旨を書く方法もありますが、コメントに頼るのは理想的とは言えません。(参考:Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考)
解決策
上記の問題点を回避するために、エラークラスを戻り値にするという方法を選びました。
発生したエラーによってサービスクラスで処理を分岐したいというのが選定理由です。
リポジトリクラス
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma, Users } from '@prisma/client';
import { PrismaRepository } from 'src/prisma/prisma.repository';
@Injectable()
export class PrismaUserRepository {
constructor(private prisma: PrismaRepository) {}
async createUser(
userId: number,
userName: string,
): Promise<Users | ConflictException | Error> {
try {
return await this.prisma.users.create({
data: {
id: userId,
name: userName,
},
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
// サービスクラスがリポジトリクラスの詳細に依存しないためにエラークラスを置き換える
return new ConflictException(error.message);
} else {
return new Error(error.message);
}
}
}
}
サービスクラス
import { ConflictException, Injectable } from '@nestjs/common';
import { PrismaUserRepository } from './prismaUser.repository';
import { Users } from '@prisma/client';
@Injectable()
export class UserService {
constructor(private prismaUserRepository: PrismaUserRepository) {}
async createUser(userId: number, userName: string): Promise<Users> {
const result = await this.prismaUserRepository.createUser(userId, userName);
if (result instanceof ConflictException) {
// ConflictException時の処理
} else if (result instanceof Error) {
// ConflictException以外のエラー時の処理
} else {
// 正常時の処理
return result;
}
}
}
上記のコードでは正常時、ID が既に存在していた場合、その他のエラーの 3 つで処理を分岐しています。
メソッドの戻り値をユニオン型にすることで、エラーを戻り値として定義できるようにしています。(参考:ユニオン型 (union type) | TypeScript 入門『サバイバル TypeScript』)
サービスクラスで createUser
の結果をそのまま戻り値とすると、以下のようなコンパイルエラーになります。
型 '{ id: number; name: string; } | ConflictException | Error' を型 '{ id: number; name: string; }' に割り当てることはできません。
このようにすることで、エラーハンドリングの実装を忘れてしまうという事態は回避できるでしょう。
余談
他にも独自の Result 型を作るという方法もありました。複数の選択肢がある場合、どの方法が自分たちの状況に最適化を選ぶ力が求められるのだと感じました。