T-CREATOR

TypeScript × Prisma で型安全な ORM 設計を実現する方法

TypeScript × Prisma で型安全な ORM 設計を実現する方法

現代の Web 開発において、データベース操作の型安全性は極めて重要な要素となっています。特に TypeScript を使用したプロジェクトでは、コンパイル時に型エラーを検出できることで、実行時のバグを大幅に減らすことができます。

本記事では、TypeScript と Prisma を組み合わせることで、どのようにして型安全な ORM 設計を実現できるのかを、実践的なコード例と共に詳しく解説していきます。従来の ORM でよく遭遇する問題から、Prisma が提供する革新的な解決策まで、段階的に学んでいきましょう。

Prisma とは何か

Prisma は、次世代の TypeScript/JavaScript 向け ORM として開発されたデータベースツールキットです。従来の ORM とは異なるアプローチで、開発者体験の向上と型安全性の確保を実現しています。

従来の ORM との違い

従来の ORM では、以下のような問題がよく発生していました。

typescript// 従来のORMでよくある問題例
const user = await User.findOne({ where: { id: 1 } });
console.log(user.name); // user が null の可能性があるが、型チェックされない

上記のコードでは、usernullの可能性があるにも関わらず、TypeScript の型チェックでエラーが発生しません。これは実行時エラーの原因となります。

一方、Prisma では以下のような型安全な記述が可能です:

typescript// Prismaによる型安全な記述
const user = await prisma.user.findUnique({
  where: { id: 1 },
});

// user は User | null 型として推論される
if (user) {
  console.log(user.name); // 型安全にアクセス可能
}

この例では、userUser | null型として正しく推論され、null チェックを行わない限りプロパティにアクセスできません。

Prisma の主な特徴は以下の通りです:

#特徴説明
1スキーマファーストデータベーススキーマから型定義を自動生成
2型安全性コンパイル時に型チェックを実行
3自動補完IDE での強力な自動補完機能
4関係性管理リレーションシップの型安全な管理

TypeScript 連携の強み

Prisma と TypeScript の組み合わせは、以下のような強みを提供します。

まず、スキーマ定義からの自動型生成について見てみましょう:

prisma// schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
}

model Post {
  id       Int    @id @default(autoincrement())
  title    String
  content  String?
  authorId Int
  author   User   @relation(fields: [authorId], references: [id])
}

このスキーマから、Prisma は以下のような型定義を自動生成します:

typescript// 自動生成される型定義の例
export type User = {
  id: number;
  email: string;
  name: string | null;
  createdAt: Date;
};

export type Post = {
  id: number;
  title: string;
  content: string | null;
  authorId: number;
};

これにより、データベーススキーマと型定義の同期が自動的に保たれ、スキーマ変更時の型エラーをコンパイル時に検出できます。

型安全性の重要性

データベース操作における型安全性は、アプリケーションの信頼性と開発効率に直結します。特に大規模なアプリケーションでは、型エラーの早期発見が重要になります。

Runtime Error と Compile Time Error の違い

Runtime Error は実行時に発生するエラーで、ユーザーがアプリケーションを使用している最中に発生する可能性があります。一方、Compile Time Error は開発時に発生するエラーで、コードをビルドする際に検出されます。

従来の ORM でよく発生する Runtime Error の例:

typescript// 従来のORMでのRuntime Errorの例
const getUserPosts = async (userId: number) => {
  const user = await User.findById(userId);

  // user が null の場合、以下でRuntime Errorが発生
  return user.posts.map((post) => ({
    id: post.id,
    title: post.title,
  }));
};

// TypeError: Cannot read properties of null (reading 'posts')

このエラーは実行時まで発見されず、ユーザーに悪影響を与える可能性があります。

Prisma を使用した型安全な実装:

typescript// Prismaによる型安全な実装
const getUserPosts = async (userId: number) => {
  const user = await prisma.user.findUnique({
    where: { id: userId },
    include: { posts: true },
  });

  // user が null の場合、コンパイル時にエラーが発生
  if (!user) {
    throw new Error('User not found');
  }

  return user.posts.map((post) => ({
    id: post.id,
    title: post.title,
  }));
};

この実装では、usernullの可能性を型システムが認識し、適切なハンドリングを強制します。

データベーススキーマと型定義の同期

従来の開発では、データベーススキーマと型定義の同期が大きな課題でした。スキーマを変更した際に、対応する型定義の更新を忘れることが頻繁に発生していました。

例えば、以下のような問題が発生することがありました:

typescript// データベーススキーマでは name カラムが NOT NULL に変更されたが、
// 型定義の更新を忘れた場合
interface User {
  id: number;
  email: string;
  name: string | null; // 実際は NOT NULL だが、型定義が古い
}

// 以下のコードは型チェックを通るが、実際はエラーとなる
const createUser = async (userData: Omit<User, 'id'>) => {
  return await db.user.create({
    data: {
      email: userData.email,
      name: null, // 実際は NOT NULL なので、エラーが発生
    },
  });
};

Prisma では、スキーマファイルから型定義を自動生成するため、この問題を根本的に解決できます:

bash# スキーマを更新した後、以下のコマンドで型定義を再生成
yarn prisma generate

このコマンドを実行することで、スキーマの変更が即座に型定義に反映され、型エラーとしてコンパイル時に検出されます。

Prisma 環境構築とセットアップ

実際に Prisma を使用したプロジェクトを構築していきましょう。ここでは、TypeScript プロジェクトでの Prisma の導入手順を詳しく説明します。

プロジェクト初期化

まず、新しい TypeScript プロジェクトを作成します:

bash# 新しいプロジェクトディレクトリを作成
mkdir typescript-prisma-example
cd typescript-prisma-example

# package.jsonの初期化
yarn init -y

# TypeScriptの開発依存関係をインストール
yarn add -D typescript @types/node ts-node nodemon

TypeScript の設定ファイルを作成します:

json// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

次に、Prisma をインストールします:

bash# Prisma CLI と Prisma Client をインストール
yarn add prisma @prisma/client

# Prismaの初期化
yarn prisma init

Prisma Client 生成とスキーマ定義

Prisma の初期化を行うと、以下のファイルが生成されます:

bashprisma/
  schema.prisma
.env

スキーマファイルを編集して、データベースモデルを定義します:

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

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

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  profile   Profile?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Profile {
  id     Int    @id @default(autoincrement())
  bio    String?
  userId Int    @unique
  user   User   @relation(fields: [userId], references: [id])
}

データベース接続設定

環境変数ファイルを設定します:

env# .env
DATABASE_URL="postgresql://username:password@localhost:5432/typescript_prisma_db"

データベースのマイグレーションを実行します:

bash# データベースの作成とマイグレーション
yarn prisma migrate dev --name init

# Prisma Clientの生成
yarn prisma generate

マイグレーションが成功すると、以下のような出力が表示されます:

bashEnvironment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "typescript_prisma_db"

√ Generated Prisma Client (4.16.2) to .\node_modules\@prisma\client in 234ms
You can now start using Prisma Client in your code. Reference: https://pris.ly/d/client

もしデータベース接続でエラーが発生した場合、以下のようなエラーメッセージが表示されます:

bashError: P1001: Can't reach database server at `localhost:5432`
Please make sure your database server is running at `localhost:5432`.

このエラーが発生した場合は、データベースサーバーが起動していることを確認してください。

基本的な型安全操作

Prisma を使用した基本的な CRUD 操作を、型安全性を意識して実装していきます。

CRUD 操作の型定義

まず、Prisma Client を初期化するファイルを作成します:

typescript// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export default prisma;

Create 操作の型安全な実装:

typescript// src/services/userService.ts
import prisma from '../lib/prisma';
import { User, Prisma } from '@prisma/client';

type CreateUserInput = Prisma.UserCreateInput;

export const createUser = async (
  data: CreateUserInput
): Promise<User> => {
  try {
    const user = await prisma.user.create({
      data,
    });
    return user;
  } catch (error) {
    if (
      error instanceof Prisma.PrismaClientKnownRequestError
    ) {
      // P2002: Unique constraint failed
      if (error.code === 'P2002') {
        throw new Error('Email already exists');
      }
    }
    throw error;
  }
};

Read 操作の型安全な実装:

typescript// src/services/userService.ts (続き)
export const getUserById = async (
  id: number
): Promise<User | null> => {
  const user = await prisma.user.findUnique({
    where: { id },
  });
  return user;
};

export const getUserWithPosts = async (id: number) => {
  const user = await prisma.user.findUnique({
    where: { id },
    include: {
      posts: true,
      profile: true,
    },
  });
  return user;
};

Update 操作の型安全な実装:

typescript// src/services/userService.ts (続き)
type UpdateUserInput = Prisma.UserUpdateInput;

export const updateUser = async (
  id: number,
  data: UpdateUserInput
): Promise<User> => {
  try {
    const user = await prisma.user.update({
      where: { id },
      data,
    });
    return user;
  } catch (error) {
    if (
      error instanceof Prisma.PrismaClientKnownRequestError
    ) {
      // P2025: Record not found
      if (error.code === 'P2025') {
        throw new Error('User not found');
      }
    }
    throw error;
  }
};

Delete 操作の型安全な実装:

typescript// src/services/userService.ts (続き)
export const deleteUser = async (
  id: number
): Promise<User> => {
  try {
    const user = await prisma.user.delete({
      where: { id },
    });
    return user;
  } catch (error) {
    if (
      error instanceof Prisma.PrismaClientKnownRequestError
    ) {
      // P2025: Record not found
      if (error.code === 'P2025') {
        throw new Error('User not found');
      }
    }
    throw error;
  }
};

リレーションシップの型管理

Prisma では、リレーションシップを型安全に管理できます。以下は投稿作成時の例です:

typescript// src/services/postService.ts
import prisma from '../lib/prisma';
import { Post, Prisma } from '@prisma/client';

type CreatePostInput = Prisma.PostCreateInput;

export const createPost = async (
  data: CreatePostInput
): Promise<Post> => {
  const post = await prisma.post.create({
    data,
  });
  return post;
};

// 著者情報を含む投稿の取得
export const getPostWithAuthor = async (id: number) => {
  const post = await prisma.post.findUnique({
    where: { id },
    include: {
      author: true,
    },
  });
  return post;
};

複雑なリレーションシップの操作:

typescript// src/services/postService.ts (続き)
export const createPostWithProfile = async (
  authorId: number,
  postData: { title: string; content?: string }
) => {
  const result = await prisma.user.update({
    where: { id: authorId },
    data: {
      posts: {
        create: {
          title: postData.title,
          content: postData.content,
        },
      },
    },
    include: {
      posts: true,
      profile: true,
    },
  });
  return result;
};

クエリ結果の型推論

Prisma では、クエリの内容に応じて結果の型が自動的に推論されます:

typescript// src/services/queryExamples.ts
import prisma from '../lib/prisma';

// 基本的なクエリ - User型が返される
const basicUser = await prisma.user.findUnique({
  where: { id: 1 },
});
// 型: User | null

// select指定のクエリ - 指定したフィールドのみの型が返される
const userWithSelectedFields = await prisma.user.findUnique(
  {
    where: { id: 1 },
    select: {
      id: true,
      name: true,
      email: true,
    },
  }
);
// 型: { id: number; name: string | null; email: string } | null

// include指定のクエリ - リレーションを含む型が返される
const userWithIncludes = await prisma.user.findUnique({
  where: { id: 1 },
  include: {
    posts: true,
    profile: true,
  },
});
// 型: (User & { posts: Post[]; profile: Profile | null }) | null

この型推論により、クエリ結果を使用する際に適切な型チェックが行われます。

実践的な型安全設計パターン

大規模なアプリケーションでは、適切な設計パターンを採用することで、型安全性を保ちながら保守性の高いコードを書くことができます。

Repository パターンの実装

Repository パターンを使用して、データアクセスロジックを分離します:

typescript// src/repositories/userRepository.ts
import prisma from '../lib/prisma';
import { User, Prisma } from '@prisma/client';

export interface UserRepository {
  create(data: Prisma.UserCreateInput): Promise<User>;
  findById(id: number): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  update(
    id: number,
    data: Prisma.UserUpdateInput
  ): Promise<User>;
  delete(id: number): Promise<User>;
  findMany(params?: {
    skip?: number;
    take?: number;
    where?: Prisma.UserWhereInput;
    orderBy?: Prisma.UserOrderByWithRelationInput;
  }): Promise<User[]>;
}

export class PrismaUserRepository
  implements UserRepository
{
  async create(
    data: Prisma.UserCreateInput
  ): Promise<User> {
    return await prisma.user.create({ data });
  }

  async findById(id: number): Promise<User | null> {
    return await prisma.user.findUnique({
      where: { id },
    });
  }

  async findByEmail(email: string): Promise<User | null> {
    return await prisma.user.findUnique({
      where: { email },
    });
  }

  async update(
    id: number,
    data: Prisma.UserUpdateInput
  ): Promise<User> {
    return await prisma.user.update({
      where: { id },
      data,
    });
  }

  async delete(id: number): Promise<User> {
    return await prisma.user.delete({
      where: { id },
    });
  }

  async findMany(params?: {
    skip?: number;
    take?: number;
    where?: Prisma.UserWhereInput;
    orderBy?: Prisma.UserOrderByWithRelationInput;
  }): Promise<User[]> {
    const { skip, take, where, orderBy } = params || {};
    return await prisma.user.findMany({
      skip,
      take,
      where,
      orderBy,
    });
  }
}

Service レイヤーでの型管理

Service レイヤーでビジネスロジックを管理し、型安全性を保持します:

typescript// src/services/userService.ts
import { UserRepository } from '../repositories/userRepository';
import { User } from '@prisma/client';

export class UserService {
  constructor(private userRepository: UserRepository) {}

  async createUser(userData: {
    email: string;
    name?: string;
  }): Promise<User> {
    // バリデーション
    if (!this.isValidEmail(userData.email)) {
      throw new Error('Invalid email format');
    }

    // 重複チェック
    const existingUser =
      await this.userRepository.findByEmail(userData.email);
    if (existingUser) {
      throw new Error(
        'User with this email already exists'
      );
    }

    return await this.userRepository.create(userData);
  }

  async getUserById(id: number): Promise<User> {
    const user = await this.userRepository.findById(id);
    if (!user) {
      throw new Error('User not found');
    }
    return user;
  }

  async updateUser(
    id: number,
    userData: {
      name?: string;
      email?: string;
    }
  ): Promise<User> {
    // 既存ユーザーの存在確認
    await this.getUserById(id);

    // メール重複チェック
    if (userData.email) {
      const existingUser =
        await this.userRepository.findByEmail(
          userData.email
        );
      if (existingUser && existingUser.id !== id) {
        throw new Error('Email already in use');
      }
    }

    return await this.userRepository.update(id, userData);
  }

  private isValidEmail(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }
}

エラーハンドリングの型安全化

カスタムエラータイプを定義して、エラーハンドリングを型安全に行います:

typescript// src/types/errors.ts
export abstract class AppError extends Error {
  abstract readonly statusCode: number;
  abstract readonly isOperational: boolean;

  constructor(
    message: string,
    public readonly isLoggable = true
  ) {
    super(message);
    Object.setPrototypeOf(this, AppError.prototype);
  }
}

export class ValidationError extends AppError {
  readonly statusCode = 400;
  readonly isOperational = true;

  constructor(message: string) {
    super(message);
    Object.setPrototypeOf(this, ValidationError.prototype);
  }
}

export class NotFoundError extends AppError {
  readonly statusCode = 404;
  readonly isOperational = true;

  constructor(message: string = 'Resource not found') {
    super(message);
    Object.setPrototypeOf(this, NotFoundError.prototype);
  }
}

export class ConflictError extends AppError {
  readonly statusCode = 409;
  readonly isOperational = true;

  constructor(message: string) {
    super(message);
    Object.setPrototypeOf(this, ConflictError.prototype);
  }
}

エラーハンドリングを組み込んだサービスの実装:

typescript// src/services/userService.ts (エラーハンドリング版)
import { UserRepository } from '../repositories/userRepository';
import { User, Prisma } from '@prisma/client';
import {
  ValidationError,
  NotFoundError,
  ConflictError,
} from '../types/errors';

export class UserService {
  constructor(private userRepository: UserRepository) {}

  async createUser(userData: {
    email: string;
    name?: string;
  }): Promise<User> {
    try {
      if (!this.isValidEmail(userData.email)) {
        throw new ValidationError('Invalid email format');
      }

      const existingUser =
        await this.userRepository.findByEmail(
          userData.email
        );
      if (existingUser) {
        throw new ConflictError(
          'User with this email already exists'
        );
      }

      return await this.userRepository.create(userData);
    } catch (error) {
      if (error instanceof AppError) {
        throw error;
      }

      if (
        error instanceof
        Prisma.PrismaClientKnownRequestError
      ) {
        if (error.code === 'P2002') {
          throw new ConflictError('Email already exists');
        }
      }

      throw new Error('Failed to create user');
    }
  }

  async getUserById(id: number): Promise<User> {
    const user = await this.userRepository.findById(id);
    if (!user) {
      throw new NotFoundError('User not found');
    }
    return user;
  }

  private isValidEmail(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }
}

パフォーマンス最適化

Prisma を使用したアプリケーションのパフォーマンス最適化は、型安全性を保ちながら行うことが重要です。

N+1 問題の解決

N+1 問題は、リレーションシップを持つデータを取得する際によく発生する問題です。

問題のあるコード例:

typescript// N+1問題が発生するコード
const getAllUsersWithPosts = async () => {
  const users = await prisma.user.findMany(); // 1回のクエリ

  const usersWithPosts = await Promise.all(
    users.map(async (user) => {
      const posts = await prisma.post.findMany({
        where: { authorId: user.id },
      }); // ユーザー数分のクエリが発生(N回)
      return { ...user, posts };
    })
  );

  return usersWithPosts;
};

Prisma のincludeを使用した解決策:

typescript// N+1問題を解決したコード
const getAllUsersWithPosts = async () => {
  const usersWithPosts = await prisma.user.findMany({
    include: {
      posts: true,
    },
  }); // 1回のクエリで全て取得

  return usersWithPosts;
};

複雑なリレーションシップの最適化:

typescript// src/services/optimizedQueries.ts
import prisma from '../lib/prisma';

export const getPostsWithAuthorsAndComments = async (
  limit: number = 10
) => {
  return await prisma.post.findMany({
    take: limit,
    include: {
      author: {
        select: {
          id: true,
          name: true,
          email: true,
        },
      },
      comments: {
        include: {
          author: {
            select: {
              id: true,
              name: true,
            },
          },
        },
        orderBy: {
          createdAt: 'desc',
        },
        take: 5,
      },
    },
    orderBy: {
      createdAt: 'desc',
    },
  });
};

適切な Include と Select

必要なフィールドのみを取得することで、パフォーマンスを向上させます:

typescript// src/services/optimizedSelects.ts
import prisma from '../lib/prisma';

// 不要なフィールドを除外した効率的なクエリ
export const getUsersForListView = async () => {
  return await prisma.user.findMany({
    select: {
      id: true,
      name: true,
      email: true,
      createdAt: true,
      _count: {
        select: {
          posts: true,
        },
      },
    },
  });
};

// 条件付きの include
export const getUserWithConditionalData = async (
  userId: number,
  includePosts: boolean = false,
  includeProfile: boolean = false
) => {
  return await prisma.user.findUnique({
    where: { id: userId },
    include: {
      posts: includePosts,
      profile: includeProfile,
    },
  });
};

// 複雑な条件での最適化
export const getPublishedPostsWithAuthors = async (
  authorIds?: number[],
  limit: number = 50
) => {
  return await prisma.post.findMany({
    where: {
      published: true,
      ...(authorIds && { authorId: { in: authorIds } }),
    },
    select: {
      id: true,
      title: true,
      content: true,
      createdAt: true,
      author: {
        select: {
          id: true,
          name: true,
        },
      },
    },
    orderBy: {
      createdAt: 'desc',
    },
    take: limit,
  });
};

インデックス設計

適切なインデックス設計により、クエリのパフォーマンスを最適化できます:

prisma// prisma/schema.prisma でのインデックス設計
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 複合インデックス
  @@index([createdAt, name])
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 単一インデックス
  @@index([published])
  // 複合インデックス
  @@index([authorId, published])
  @@index([createdAt, published])
}

パフォーマンス監視のためのクエリ分析:

typescript// src/utils/queryAnalyzer.ts
import prisma from '../lib/prisma';

export const enableQueryLogging = () => {
  prisma.$use(async (params, next) => {
    const before = Date.now();

    const result = await next(params);

    const after = Date.now();

    console.log(
      `Query ${params.model}.${params.action} took ${
        after - before
      }ms`
    );

    return result;
  });
};

// クエリの実行計画を確認する関数
export const explainQuery = async (query: string) => {
  const result =
    await prisma.$queryRaw`EXPLAIN ANALYZE ${query}`;
  console.log('Query execution plan:', result);
  return result;
};

まとめ

TypeScript と Prisma を組み合わせることで、型安全な ORM 設計を実現できることを詳しく解説してきました。

重要なポイントをまとめると以下の通りです:

型安全性の実現

  • スキーマファーストな開発により、データベーススキーマと型定義の自動同期が可能
  • コンパイル時の型チェックにより、Runtime Error を大幅に削減
  • クエリ結果の型推論により、適切な型安全なコードの記述が可能

実践的な設計パターン

  • Repository パターンによるデータアクセスロジックの分離
  • Service レイヤーでのビジネスロジック管理
  • カスタムエラータイプによる型安全なエラーハンドリング

パフォーマンス最適化

  • N+1 問題の解決と適切な include/select の使用
  • インデックス設計によるクエリの高速化
  • クエリログとパフォーマンス監視の実装

これらの手法を組み合わせることで、開発効率が高く、保守性に優れ、パフォーマンスも良好なアプリケーションを構築できます。

TypeScript と Prisma の組み合わせは、現代の Web 開発において非常に強力なツールです。本記事で紹介した手法を活用して、より安全で効率的なデータベース操作を実現していただければと思います。

関連リンク