🅿️

prisma-json-types-generator で Prisma の JSON カラムに型を付ける

2023/11/05に公開

prisma-json-types-generator

DB テーブルの JSON カラムの値を Prisma API で取得するとその型は Prisma.JsonValue となるので、別途バリデーションや型アサーションを行う必要があります。
型付けのための機能要望は 2020 年から存在しますが、2023/11 現在対応されていません。

https://github.com/prisma/prisma/issues/3219

代わりに prisma-json-types-generator というサードパーティのモジュールが公開されていて、これを使って Prisma API からの返り値に型を付けることができます。

https://www.npmjs.com/package/prisma-json-types-generator

ちなみに、Prisma の開発者はこのモジュールのことを認識しているため、今後 Prisma にも同様の機能が導入される可能性があります。

We know and agree that this would be best supported at a Prisma native level, which is why we keep #3219 around and will hopefully be able to prioritize that soon.

https://github.com/prisma/prisma/issues/20524#issuecomment-1667448155

次のように、JSON カラム content を含む Article テーブルを定義してレコードを取得すると、

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Article {
  id      Int  @id @default(autoincrement())
  content Json
}
src/main.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

const result = await prisma.article.findFirst();

返り値 result.content の型は JSON として有効なすべての型の複合型 Prisma.JsonValue になります。

推論された result の型
const result: {
    id: number;
    content: Prisma.JsonValue;
} | null

https://github.com/prisma/prisma/blob/5.5.2/packages/client/src/generation/TSClient/common.ts#L223-L227

そのため、JSON カラムを使用する際は、JSON カラムに格納するデータ型を定義したうえで、以下のように型アサーション等を用いる必要があります。

src/main.ts
(略)

type ArticleContentType = {
  title: string;
  tags: string[];
  text: string;
};
type ArticleRecordType = {
  id: number;
  content: ArticleContentType;
}

const typedResult = result as ArticleRecordType | null;
if (!typedResult) {
  throw Error();
}
typedResult.content.tags; // (property) tags: string[]

ここで prisma-json-types-generator を使用すると、findFirst() の返り値の時点で result.content に指定した型を付けられるようになります。

https://www.npmjs.com/package/prisma-json-types-generator

$ npm install -D prisma-json-types-generator
src/type.ts を作成
declare global {
  namespace PrismaJson {
    type ArticleConetentType = {
      title: string;
      tags: string[];
      content: string;
    };
  }
}
prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

generator json {                                                     // 追加
  provider = "prisma-json-types-generator"                           // 追加
}                                                                    // 追加

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Article {
  id      Int  @id @default(autoincrement())
  /// [ArticleContentType]                                           // 追加
  content Json
}
$ npx prisma generate
src/main.ts
(略)

const result = await prisma.article.findFirst();

if (!result) {
  throw Error();
}
result.content.tags; // (property) tags: string[]
推論された result の型
const result: {
    id: number;
    content: PrismaJson.ArticleContentType;
} | null
  • なお、これは Prisma 内部で型アサーションが行われているということなので、実際に JSON カラムの中身がその型を満たしているかどうかは保証されないことに注意が必要です。

tRPC と zod を使った使用例

tRPC と zod を使って、id を指定し記事の内容を取得する API を作成するとします。
JSON カラムの型は zod スキーマとして定義し、input()output() を用いて( // ★ )zod による入出力の型付けとバリデーションを行うことにします。

src/type.ts
import { z } from 'zod';

export const articleContentSchema = z.object({
  title: z.string(),
  tags: z.array(z.string()),
  text: z.string(),
});
src/trpc.ts
import { initTRPC } from '@trpc/server';

export const t = initTRPC.create();
src/main.ts
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { z } from 'zod';
import { t } from './trpc';
import { articleContentSchema } from './type';

import { PrismaClient } from '@prisma/client';
import { TRPCError } from '@trpc/server';

const prisma = new PrismaClient();

const appRouter = t.router({
  articleById: t.procedure
    .input(z.object({ id: z.number() }))                   // ★
    .output(z.object({ article: articleContentSchema }))   // ★
    .query(async ({ input }) => {
      const record = await prisma.article.findFirst({
        where: { id: input.id },
      });
      if (!record) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }
      return {
        article: record.content,
      };
    }),
});

export type AppRouter = typeof appRouter;

const server = createHTTPServer({
  router: appRouter,
});

server.listen(3000);

record.contentJsonValue 型なので、コンパイルするとスタックトレースにType 'JsonValue' is not assignable 〜を含むエラーが発生します。

src/main.ts:15:12 - error TS2345: Argument of type '({ input }: ResolveOptions<{ _config: RootConfig<{ ctx: object; meta: object; errorShape: never; transformer: DataTransformerOptions; }>; _meta: object; _ctx_out: object; _input_in: { ...; }; _input_out: { ...; }; _output_in: { ...; }; _output_out: { ...; }; }>) => Promise<...>' is not assignable to parameter of type '(opts: ResolveOptions<{ _config: RootConfig<{ ctx: object; meta: object; errorShape: never; transformer: DataTransformerOptions; }>; _meta: object; _ctx_out: object; _input_in: { ...; }; _input_out: { ...; }; _output_in: { ...; }; _output_out: { ...; }; }>) => MaybePromise<...>'.
  Type 'Promise<{ article: JsonValue; }>' is not assignable to type 'MaybePromise<{ article?: { title?: string; tags?: string[]; text?: string; }; }>'.
    Type 'Promise<{ article: JsonValue; }>' is not assignable to type 'Promise<{ article?: { title?: string; tags?: string[]; text?: string; }; }>'.
      Type '{ article: Prisma.JsonValue; }' is not assignable to type '{ article?: { title?: string; tags?: string[]; text?: string; }; }'.
        Types of property 'article' are incompatible.
          Type 'JsonValue' is not assignable to type '{ title?: string; tags?: string[]; text?: string; }'.
            Type 'string' has no properties in common with type '{ title?: string; tags?: string[]; text?: string; }'.

15     .query(async ({ input }) => {
              ~~~~~~~~~~~~~~~~~~~~~~

この場合、zod の infer を用いて、次のように prisma-json-types-generator を適用すると型エラーが発生しなくなります。またこの場合、zod のバリデーションを通しているため、API レスポンス内の JSON カラム由来のデータが指定した型と合致することが保証できます。

src/type.ts
import { z } from 'zod';

export const articleContentSchema = z.object({
  title: z.string(),
  tags: z.array(z.string()),
  content: z.string(),
});

declare global {                                                     // 追加
  namespace PrismaJson {                                             // 追加
    type ArticleContentType = z.infer<typeof articleContentSchema>;  // 追加
  }                                                                  // 追加
}                                                                    // 追加
prisma/schema.prisma
(略)

model Article {
  id      Int  @id @default(autoincrement())
  /// [ArticleContentSchema]                                         // 追加
  content Json
}

package.jsonは以下の通りです。

package.json
{
  "dependencies": {
    "@prisma/client": "^5.5.2",
    "@trpc/server": "^10.43.1",
    "zod": "^3.22.4"
  },
  "devDependencies": {
    "prisma": "^5.5.2",
    "prisma-json-types-generator": "^3.0.3"
  }
}

Discussion