環境
- NestJS: 11.0.4
- Prisma: 5.15.0
概要
以前の記事でPrisma ORMが返す値はプレーンなJavaScriptオブジェクトとなるため、Active Record型のORMのようなModelクラスにマッピングするORMと比較した場合、従来Modelクラスに書いていたような実装は別の方法で置き換える必要があるという解説をしました。
その中でもPrismaの便利な機能としてPrisma Client extensionsを紹介したのですが、この機能をNestJSで型安全に実装するには少し工夫が必要になります。
Prisma Client extensionsとは
その名の通りPrisma Clientを拡張してモデルの拡張やカスタムクエリの定義などができるとても便利な機能。
バージョン4.16.0で標準機能に昇格したので、4.16.0より前のバージョンの場合にはpreviewFeaturesでclientExtensionsw指定する必要があります。
使い方
Prismaの公式ドキュメントに記載されている使い方は以下のようにPrismaClientのコンストラクタを呼び出しそのインスタンスの$extends
で拡張するというもの。
※defineExtensionを使用した使い方は割愛
const prisma = new PrismaClient().$extends({
name: 'signUp', // Optional: name appears in error logs
model: { // This is a `model` component
user: { ... } // The extension logic for the `user` model goes inside the curly braces
},
})
例えばこんな感じでsignUpメソッドを定義しておけば、前後に処理を入れたり動的に初期値を決定したりと柔軟な拡張が実装できる。
const prisma = new PrismaClient().$extends({
name: 'signUp',
model: {
user: {
signUp: (email: string) => {
const name = email.split('@')[0]
return prisma.user.create({
data: {
name,
email
}
})
}
}
}
})
const user = await prisma.user.signUp('test@example.com')
console.log(user)
// { id: 1, name: 'test', email: 'test@example.com' }
NestJSで使用する際の注意点
NestJSでPrismaを使用している方はご存知だと思いますが、NestJSの公式ドキュメントで紹介しているPrismaの使い方は以下のようにonModuleInitやenableShutdownHooksといったNestJSのライフサイクル関数をハンドリングし適切な実装を追加するためにPrismaClientを継承したInjectableなPrismaServiceクラスを実装するというもの。
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
シンプルにPrismaServiceクラスで$extends
を呼び出すのであればのコンストラクタで以下のように書くことができる。
import { Injectable, OnModuleInit } from '@nestjs/common'
import { PrismaClient } from '@prisma/client'
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
constructor() {
super()
this.$extends({
name: 'signUp',
model: {
user: {
signUp: (email: string) => {
const name = email.split('@')[0]
return this.user.create({
data: {
name,
email
}
})
}
}
}
})
}
async onModuleInit() {
await this.$connect()
}
}
しかし、このPrismaServiceのインスタンスでuser.signUp
を実行しようとしても、Property 'signUp' does not exist on type 'UserDelegate<DefaultArgs, PrismaClientOptions>'
とTSのコンパイルエラーが発生してしまう。
import { Injectable } from '@nestjs/common'
import { PrismaService } from '@src/lib/prisma/service'
import { User } from '@prisma/client'
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async signUp(): Promise<User> {
// Error: Property 'signUp' does not exist on type 'UserDelegate<DefaultArgs, PrismaClientOptions>'.
return this.prisma.user.signUp({
email: 'test@example.com'
})
}
}
なぜこのようなエラーになるかを理解するために、Prismaの公式ドキュメントのコードからPrismaClientの型を確認してみると$extendsの呼び出しにより追加された実装からPrismaClientの型を動的に拡張していることがわかる。
/**
* const prisma: DynamicClientExtensionThis<Prisma.TypeMap<InternalArgs & {
* result: {};
* model: {
* user: {
* signUp: () => (email: string) => void;
* };
* };
~~~~~~~~~~~~~~
*/
const prisma = new PrismaClient().$extends({
name: 'signUp',
model: {
user: {
signUp: (email: string) => {}
}
}
})
PrismaClientの型を動的に拡張することによって、prisma.user.signUp
が正しくTSに認識されるということは、以下のようにインスタンス化と$extends
の呼び出しを分離すると、先ほどのPrismaServiceクラスの問題と同じコンパイルエラーが発生する。
const prisma = new PrismaClient()
prisma.$extends({
name: 'signUp',
model: {
user: {
signUp: (email: string) => {}
}
}
})
// Error: Property 'signUp' does not exist on type 'UserDelegate<DefaultArgs, PrismaClientOptions>'.
const user = await prisma.user.signUp({
email: 'test@example.com'
})
つまり、PrismaServiceのコンストラクタで$extends
を呼び出すだけでは、PrismaClientの型の動的な拡張をPrismaServiceに適用することができないというのが問題の本質となる。
対応
調べてみるとPrismaのgithubリポジトリに全く同じ問題を提起しているIssueが存在した。
useFactoryでInjectionする際に拡張したインスタンスを返す方法など、様々な解決方法が提案されているが、個人的には型のキャストを使用して拡張されたPrismaClient(ExtendedPrismaClient)をPrismaServiceに継承させるという解決方法が一番スマートに感じた。
自分なりに少し調整しつつまとめてみると。
import { Injectable, OnModuleInit } from '@nestjs/common'
import { PrismaClient } from '@prisma/client'
import { Prisma } from '@prisma/client'
// Prisma.defineExtensionでextensionを個別に定義
const userExtension = Prisma.defineExtension((client) => {
return client.$extends({
model: {
user: {
signUp: (email: string) => {
const name = email.split('@')[0]
return client.user.create({
data: {
email,
name
}
})
}
}
}
})
})
// PrismaClientを拡張する関数
const extendClient = (client: PrismaClient) => {
return client.$extends(userExtension)
}
// PrismaClientを拡張したクラス
class UntypedExtendedPrismaClient extends PrismaClient {
constructor() {
super()
// extendClientの型とは適合しないためアサーション(as this)
return extendClient(this) as this
}
}
/**
* UntypedExtendedPrismaClientを型キャスト
* extendClient関数の戻り値の型(ReturnType<typeof extendClient>)を適用させる
*/
const ExtendedPrismaClient =
UntypedExtendedPrismaClient as unknown as new () => ReturnType<
typeof extendClient
>
// 継承元のクラスをPrismaClient => ExtendedPrismaClientに変更
@Injectable()
export class PrismaService
extends ExtendedPrismaClient
implements OnModuleInit
{
async onModuleInit() {
await this.$connect()
}
}
こうすると呼び出し元で問題となっていたProperty 'signUp' does not exist on type 'UserDelegate<DefaultArgs, PrismaClientOptions>'.
は発生せず、無事にNestJSでPrisma Client extensionsによって拡張された機能を型安全に実装することができます。
おわりに
Prismaはその成り立ちからGraphQLとの親和性が高く、必然的にNestJSとセットで採用されるケースが多いと思います。
しかし、今回紹介したような有用な機能を適切に実装するための手順をキャッチアップするにはまだ少し敷居の高い印象があるので、少しでも多くのNestJS x Prismaユーザーの参考になれば幸いです。
ちなみに、PrismaのInteractive transactions($transaction<R>(fn: (prisma: PrismaClient) => R): R
)の中でPrisma Client extensionsによって拡張された機能を呼び出す場合は厄介な問題があったりもするのでお気をつけください。
※こちらはまた別記事で書きます。
参考
関連