Prisma でメソッドはやせない問題どうしたらいいんだ

型安全にクエリ発行できて便利な Prisma だけど、Prisma Client の戻り値がプレーンオブジェクトであるためメソッドを生やせないつらみがある

例えば適当なサービスのユーザーのドメインモデルがあるとして

class UserModel {
  id: number;
  firstName: string,
  lastName: string,
  
  fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

の fullName 実装が Prisma だと書けないよね〜という話

僕はサーバーサイドは NestJS で書くことが多くて、だいたい user.service.tsgetUserFullName(user: User): string とかを生やすことになる or 使いたい箇所で愚直に書くになるのでつらい

ざっくり2方針

Explore how to extend the Prisma Client · Issue #7161 · prisma/prisma · GitHub

この辺りの Issue で議論されてるけど、すぐに Prisma 側でなにかをサポートする形にはならなそうなのでこちら側でなんとかしたみ

とりえあず2方針うかぶのでやってみる

  • クラスにマッピングする
  • 後付けでオブジェクトにメソッドを生やす

クラスにマッピングする

拾ってきたデータをクラスにマッピングしてあげる

import type { User } from '@prisma/client'

class UserModel<Data extends User> implements User {
  id: number;
  firstName: string;
  lastName: string;
  friendId: number | null;

  private data: Data;

  constructor(data: Data) {
    this.id = user.id;
    this.firstName = user.firstName;
    this.lastName = user.lastName;
    this.friendId = user.friendId;
    this.data = data;
  }
  
  fullName() {
    return `${this.firstName} ${this.fullName}`;
  }
}

こんな感じでクラスにマッピングすればメソッド生やせる

ただやっぱりクラスだと柔軟性がワンランク落ちてしまうというのはあって、例えば Prisma だと例えば A JOIN B で拾ってきたら A & { b: B } で型解決される(データも拾ってこられる)けど、クラスでこれを表現しようと思ったら使用するリレーションのパターン分モデル作る(AModel, AWithBModel, …etc)しかないのでなんともなーという感じ

上の例だと一応 data にデータおいてて型も残してるので参照自体はできるけど、リレーション先に依存したメソッドははやせない(hoge() { this.relationProp でなにか } ができない)

あと Prisma だとスキーマを schema.prisma に書いていく ので重複で同じくスキーマみたいなものをクラスで書くのは冗長な気もかなりする

オブジェクトに生やす

JS のオブジェクトはメソッドはやせるから

const user = {}
user.method = () => {
  console.log('Hi')
}

型解決できるならこれでも良いはず

Vue とか config 系でよく使われる defineXXX のパターンで型解決はできる

アプリケーションコードでは↓の感じで実装できる

import type { User } from '@prisma/client'
import { defineMethods } from 'path/to/prisma-util'

const userMethods = defineMethods<User>()({
  // 補完はちゃんと効くので、ストレスなく拡張メソッドを定義できる
  fullName() {
    return this.firstName + ' ' + this.lastName
  },
  otherMethod() {
    this.fullName()  // 別のドメインメソッドでも型解決できる
  }
})

export const withUserMethods = createWithDomainMethod<User, UserMethod>(userMethods)
export type UserMethods = typeof userMethods
export type UserModel = ReturnType<typeof withUserMethods>
import { Injectable } from "@nestjs/common"
import { PrismaService } from "~/path/to/prisma.service"
import { UserModel, withUserMethods } from './user.methods.ts'

@Injectable()
export class UserService {
  constructor(private prisma: PrismaService) {}
  
  async fetchUser(): Promise<UserModel | undefined> {
    const userData = await this.prisma.user.findUnique({ where: { id: 'id' } });
    if (userData === null) return;

    const user = withUserMethods(userData);
    user.fullName();  // 生やしたメソッドが型安全に使える
    return user;
  }
}

上のプレイグラウンドに書いたけど、こっちのやり方ならリレーション先にもメソッドを生やす & 型解決したりができる

const userWithFriend = withUserMethods(userWithFriendData, {
  friend: withUserMethod,
})

ので、クラスベースじゃないから Prisma が解決してくれる柔軟な型定義とデータ構造を生かしやすいのはこっちかなという気がする

比較する

  • スキーマの再定義の必要もない
  • リレーション先のメソッド生やすのも割とスマートに解ける

ので基本的には後者のやり方でヘルパ使ってメソッド生やすのがすっきりすると思う

ただ NestJS で使う前提なら class-validator, class-transformer を使ってバリデーションとか表示データへの整形(Presenter 層っぽいこと)とかをやるし、コードファーストならクラスで書かないと OAS 作れないしでいずれにせよドメインモデルをクラスで定義する必要があるので

  • repository: prisma を使ってクラスで再宣言したドメインモデルを返す
  • service: prisma に依存せず、repository から受け取ったドメインモデルでビジネスロジックを書く

とかにしてしまうのがスマートな気がする

結論

  • NestJS 等、Prisma に限らずドメインモデルをクラスで書きたいモチベーションがあるならマッピングする
  • それ以外なら、受け取ったオブジェクトに後付けでメソッドを生やす

が良さそう