🛠️

型のズレに悩まない!Prisma → NestJS → Orval → React:型連携の最短ルートを試す

に公開

本記事では、「Prismaのスキーマ定義から、ほぼ全てのコードを自動生成して、React製のTodoアプリを作る方法」を紹介します。

APIのDTOやクライアントコードを手書きした場合、ミスや繰り返しの作業になってしまったり、だんだん辛くなってきますよね。

使用する技術スタック

ツール 役割
Prisma DBスキーマ定義、DB操作
NestJS バックエンド(APIサーバー)
Prisma Generator NestJS DTO PrismaスキーマからDTOを自動生成
Orval OpenAPI定義からAPIクライアントを自動生成
React フロントエンド、UI、API通信

NestJSとPrismaでバックエンドを作成

NestJSの公式ドキュメントに沿ってバックエンドを作成していきます。

npm i -g @nestjs/cli
nest new todo-backend --strict

Prismaのインストール

npm i prisma --save-dev

Prismaの初期化(schema.prismaの作成、.envの作成)

npx prisma init

Todoモデルを定義

prisma/schema.prisma
datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

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

model Todo {
  id        Int      @id @default(autoincrement())
  title     String
  completed Boolean
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

.envは下記の通り。

.env
DATABASE_URL=SQLiteのdbファイルのパス

DBマイグレーション

npx prisma migrate dev --name init

Prisma Clientの作成

npm i @prisma/client

PrismaServiceを作成

src/prisma/prisma.service.ts
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from 'generated/prisma/client';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy
{
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

PrismaModuleを作成

src/prisma/prisma.module.ts
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

バリデーションのセットアップとDTOの作成

NestJSのバリデーションのセットアップを行います。

バリデーションライブラリをインストール

npm i class-validator class-transformer

アプリケーションレベルでバリデーションを設定

src/main.ts
+import { ValidationPipe } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
 import { AppModule } from './app.module';

 async function bootstrap() {
   const app = await NestFactory.create(AppModule);

+  app.useGlobalPipes(new ValidationPipe({ transform: true }));

   await app.listen(process.env.PORT ?? 3000);
 }
 bootstrap();

Prisma Generator NestJS DTOのインストール

npm i --save-dev @brakebein/prisma-generator-nestjs-dto

schema.prismageneratorを追加

prisma/schema.prisma
 datasource db {
   provider = "sqlite"
   url      = env("DATABASE_URL")
 }

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

+generator nestjsDto {
+  provider                        = "prisma-generator-nestjs-dto"
+  output                          = "../generated/prisma/nestjs-dto"
+  fileNamingStyle                 = "kebab"
+}

 model Todo {
   id        Int      @id @default(autoincrement())
   title     String
   completed Boolean  @default(false)
   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt
 }

DTOの生成

npx prisma generate

nest-cli.jsoncompilerOptionsを編集します。

nest-cli.json
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true,
+   "assets": [
+     {
+       "include": "../generated",
+       "outDir": "dist/generated",
+       "watchAssets": true
+     }
+   ]
  }
}

ConfigModuleのセットアップ

@nestjs/configをインストール

npm i @nestjs/config

ConfigModuleのインポート

src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [ConfigModule.forRoot()],
})
export class AppModule {}

OpenAPIを有効化

@nestjs/swaggerをインストール

npm i @nestjs/swagger

セットアップ

src/main.ts
 import { ValidationPipe } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
+import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
 import { AppModule } from './app.module';

 async function bootstrap() {
   const app = await NestFactory.create(AppModule);

   app.useGlobalPipes(new ValidationPipe({ transform: true }));

+  app.setGlobalPrefix('api');
+
+  const config = new DocumentBuilder()
+    .setTitle('Todo API')
+    .setDescription('The Todo API description')
+    .setVersion('1.0')
+    .build();
+  const documentFactory = () => SwaggerModule.createDocument(app, config);
+  SwaggerModule.setup('api', app, documentFactory);

   await app.listen(process.env.PORT ?? 3000);
 }
 bootstrap();

APIの作成

nest g resource todos

これにより、ServiceやControllerなどが一括生成されます。

TodoServiceの作成

src/todos/todos.service.ts
import { Injectable } from '@nestjs/common';
import { Prisma, Todo } from 'generated/prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';

@Injectable()
export class TodosService {
  constructor(private readonly prisma: PrismaService) {}

  async create(data: Prisma.TodoCreateInput): Promise<Todo> {
    return await this.prisma.todo.create({ data });
  }

  async find(): Promise<Todo[]> {
    return await this.prisma.todo.findMany({ where: { completed: false } });
  }

  async update(params: {
    where: Prisma.TodoWhereUniqueInput;
    data: Prisma.TodoUpdateInput;
  }): Promise<Todo> {
    return await this.prisma.todo.update(params);
  }

  async remove(where: Prisma.TodoWhereUniqueInput): Promise<void> {
    await this.prisma.todo.delete({ where });
  }
}

TodoControllerの作成(自動生成したDTO、Entityを使用しています)

src/todos/todos.controller.ts
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { ApiCreatedResponse, ApiOkResponse } from '@nestjs/swagger';
import { CreateTodoDto } from 'generated/prisma/nestjs-dto/create-todo.dto';
import { Todo } from 'generated/prisma/nestjs-dto/todo.entity';
import { UpdateTodoDto } from 'generated/prisma/nestjs-dto/update-todo.dto';
import { TodosService } from './todos.service';

@Controller('todos')
export class TodosController {
  constructor(private readonly todosService: TodosService) {}

  @Post()
  @ApiCreatedResponse({ type: Todo })
  async create(@Body() createTodoDto: CreateTodoDto): Promise<Todo> {
    return await this.todosService.create(createTodoDto);
  }

  @Get()
  @ApiOkResponse({ type: [Todo] })
  async find(): Promise<Todo[]> {
    return await this.todosService.find();
  }

  @Patch(':id')
  @ApiOkResponse({ type: Todo })
  async update(
    @Param('id') id: number,
    @Body() updateTodoDto: UpdateTodoDto,
  ): Promise<Todo> {
    return await this.todosService.update({
      where: { id },
      data: updateTodoDto,
    });
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  async remove(@Param('id') id: number): Promise<void> {
    await this.todosService.remove({ id });
  }
}

APIサーバーを起動

npm run start:dev

ViteとReactでフロントエンドを作成

Viteでプロジェクトを作成

npm create vite@latest todo-frontend -- --template react-ts
cd todo-frontend
npm i

APIクライアントの作成

@tanstack/react-queryaxiosをインストール

npm i @tanstack/react-query axios

orvalのインストール

npm i --save-dev orval

package.jsonscriptsに下記を追加しておきます。

package.json
"scripts": {
  "dev": "vite",
  "build": "tsc -b && vite build",
  "lint": "eslint .",
  "preview": "vite preview",
+ "orval": "orval --config orval.config.ts"
},

Orvalの設定ファイルを作成

orval.config.ts
export default {
  todo: {
    output: {
      mode: "tags-split",
      target: "./src/generated/api/todo.ts",
      client: "react-query",
    },
    input: {
      target: "http://localhost:3000/api-json",
    },
  },
};

APIクライアントの生成

npm run orval

UIの作成

自動生成したAPIクライアントのコード(TanStack Query)を使用しています。

App.tsx
import {
  QueryClient,
  QueryClientProvider,
  useQueryClient,
} from "@tanstack/react-query";
import { useState } from "react";
import {
  getTodosControllerFindQueryOptions,
  useTodosControllerCreate,
  useTodosControllerFind,
  useTodosControllerUpdate,
} from "./generated/api/todos/todos";
import type { Todo } from "./generated/api/todo.schemas";

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Todo />
    </QueryClientProvider>
  );
}

function Todo() {
  const queryClient = useQueryClient();
  const { data } = useTodosControllerFind();
  const [newTitle, setNewTitle] = useState("");

  const createMutation = useTodosControllerCreate({
    mutation: {
      onSuccess: () =>
        queryClient.invalidateQueries(getTodosControllerFindQueryOptions()),
    },
  });
  const updateMutation = useTodosControllerUpdate({
    mutation: {
      onSuccess: () =>
        queryClient.invalidateQueries(getTodosControllerFindQueryOptions()),
      onError: () => {
        alert("エラーが発生しました");
      },
    },
  });

  const handleCreate = () => {
    if (!newTitle.trim()) {
      return;
    }

    createMutation.mutate({ data: { title: newTitle, completed: false } });
    setNewTitle("");
  };

  const toggleComplete = (todo: Todo) => {
    updateMutation.mutate({
      id: todo.id,
      data: {
        title: todo.title,
        completed: !todo.completed,
      },
    });
  };

  return (
    <div>
      <h1>Todo List</h1>
      <input
        type="text"
        value={newTitle}
        onChange={(e) => setNewTitle(e.target.value)}
        placeholder="新しいTodoを入力"
      />
      <button onClick={handleCreate}>追加</button>

      <ul>
        {data?.data.map((todo) => (
          <li key={todo.id}>
            <label>
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleComplete(todo)}
              />
              {todo.title}
            </label>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

以上で完了です。

まとめ

Prismaのスキーマ定義を起点として、バックエンドからフロントエンドまで一気通貫でコードを自動生成できる仕組みを紹介しました。

手書きのDTOやAPIクライアントを自動化することで、実装ミスを減らし、開発速度を大きく向上させることができます。

Discussion