9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

tRPCを使わずに「型+バリデーション」を共有するTSモノレポ構成(Next.js×Express)

9
Last updated at Posted at 2025-12-13

はじめに

株式会社メタップスホールディングス26卒内定者インターンのTomuです。

個人開発で Next.js × Express × Turborepo × pnpm を使ってDockerレスなモノレポ環境を構築・運用しています。

このTSモノレポ構成では、型とバリデーションを tRPC を使わず、shared ディレクトリで共有・管理しています。

本記事では、tRPC を使わずに「モノレポ × shared 構成」で
フロント/バック間の型・バリデーションをどのように共有しているか を実例ベースで解説します。

※ 運用Tipsについても別記事にしているので、読んでいただけると嬉しいです!
👉 個人開発で7ヶ月回したDockerレスTSモノレポ運用Tips

目次

1. なぜtRPCを使わなかったのか

理由はシンプルで、tRPCの存在を知らなかったからです。

当時(約7ヶ月前)はまだwebアプリ開発を学び始めてちょうど1年で、ほとんど1人で独学開発していることもあり、キャッチアップできていませんでした 🥲

また、モノレポ構築して1ヶ月後にtRPCの存在を知りましたが、移行せず今に至ります。。。

結果として、tRPC 的な思想(I/O の型安全) に自然と辿り着つことができました。

現在はある程度安定して運用できていて、tRPCよりも柔軟に型とバリデーションを管理できるという利点があるなと感じています。

2. 技術・ディレクトリ構造

技術構成

主要技術:Next.js / Express / turborepo / Prisma / NeonDB / Zod / pnpm
CICD:GithubActions / Biome / Vercel / Render
ライブラリ:LIFF / LINE Webhook / OpenAIAPI / Stripe

全体的には「TSフルスタック」といった感じです。
テストは未実装ですが、学習も含めて近々実装したいです。

※ OpenAIAPIとStripeは過去に実装まで行いましたが、要件変更により現在は機能から外しています。

ディレクトリのざっくり全体像

root/
├── .github/                      # CI / build・format(Biome)
├── apps/
│   ├── frontend/                 # Next.js(LIFFアプリ)
│   │   └── src/
│   │       └── features/        # APIドメイン別・カスタムフック
│   └── backend/                 # Express(API) / LINE webhook
│       ├── prisma/              # DBスキーマ & マイグレーション
│       │   ├── schema.prisma
│       │   └── migrations/
│       └── src/
│           └── features/     # APIドメイン別・controllers/services
├── shared/                # Zodスキーマ / 共通型 / Utils
│   └── src/
│       ├── features/            # APIドメイン別・zodスキーマ & 型
│       └── utils/
│
├── pnpm-workspace.yaml
├── turbo.json
└── package.json                 # ルート共通設定

sharedディレクトリの構成

  • frontend / backend間で「型」と「バリデーション」を一元管理するための場所
shared/
└── src/
    └── features/
        └── common/
        │   ├── types/
        │   │   └── errors.ts         // エラー型(通常エラー / バリデーションエラー)
        │   └──  validations/          // クエリパラメータのバリデーション
        │       └── weekStartParam.ts      
        └── auth/
            ├── types/
            │   ├── login.ts          // ログインAPIで使うレスポンス・ドメイン型
            │   └── verify.ts         // LIFF verifyで使うレスポンス・ドメイン型
            ├── validations/
            │   ├── login.ts          // ログインの Zod バリデーション
            │   └── verify.ts         // LIFF verify の Zod バリデーション
            └── dto.ts                // API I/O(Request/Response)のDTO定義
  • backend と frontend から使う「型」や「バリデーション」を shared にまとめることで、どちらも同じ型を参照でき、ズレが起きにくくなる
  • types と validations を分けることで、「APIレスポンス型だけ使うとき」と 「バリデーション周辺を扱いたいとき」が混ざらない
  • dto.ts はレスポンス用の型を整えたもの

backend のディレクトリ構成

  • ユースケース単位(login / liff-verify)で controller / service / DTO を分離
  • I/O、ロジック、変換処理を小さく分けて保守性を高める設計
apps/backend/
└── src/
    └── features/
        └── auth/
            ├── login/
            │   ├── controller.ts   // I/O担当(validation / response)
            │   ├── service.ts      // ドメインロジック
            │   └── toDTO.ts        // 返却値のDTOへの変換
            ├── liff-verify/
            │   ├── controller.ts
            │   ├── service.ts
            │   └── toDTO.ts
            └── router.ts           // /auth 配下のルーティング定義
  • controllerは「入出力だけ」に責務を限定
  • serviceは「ビジネスロジック」だけに集中
  • DTO.tsはPrismaクエリの返却値をDTO型に変換

frontend のディレクトリ構成

  • API 呼び出し・カスタムフック・共通ロジックを分離して保守性を高める
apps/frontend/
└── src/
    └── features/
        ├── _shared
        |   ├── path.ts     /// APIエンドポイント変数
        |   └── fetcher.ts    /// 汎用フェッチ関数(型安全)
        |
        ├── auth         /// Auth関連のユースケース
        |   ├── useLiffVerify.ts   
        |   └── useLogin.ts
  • featuresでバックエンドと同じ「ドメイン単位」に揃えることで
    「shared」「frontend」「backend」の対応関係がわかりやすい
  • _sharedには全てのドメインで使う「APIフェッチ関数」と「APIパスの変数」を置くことで再利用できる

型・バリデーションの共有

ここからは、実際に frontend / backend から shared の型を参照している例 を見ていきます。
例として、ユーザー編集 の 処理を取り上げます。(デモコード)

1. sharedの型・バリデーション(例:ユーザー編集)

shared/src/features/users/types/update-profile.ts

import type { UserDTO } from "../dto.ts";

// APIレスポンス型(DTO)
export type UpdateProfileResponse = {
  ok: boolean;
  user: UserDTO; // ← Prisma.User をそのまま出さず、DTO型に整えたもの
};
shared/src/features/users/types/dto.ts
// prisma型をそのまま使わず型を再定義
export type UserDTO = {    
  id: string;
  name: string;
  email: string;
};

UpdateProfileResponse:ユーザー編集APIのレスポンス型
(バックエンドではcontrollerとserviceで共通利用)

shared/src/features/users/validations/update-profile.ts
import { z } from "zod";

// バックエンドで扱う完全な入力
export const UpdateProfileSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1, "名前は必須です"),
  email: z.string().email("メールアドレスの形式が正しくありません"),
});

// バックエンド用の“完全な”入力型
export type UpdateProfileInput = z.infer<typeof UpdateProfileSchema>;

// フロントのフォームでは id は触らないので Omit
export type UpdateProfileFormInput = Omit<UpdateProfileInput, "id">;

updateProfileSchema:ユーザー編集APIのリクエスト値に対するバリデーション
updateProfileInput:リクエスト値のInput型
updateProfileFormInput:フォーム用のInput型

2. バックエンド側での共有

2-1. コントローラ層でsharedの型・バリデーションをインポート

apps/backend/src/features/users/update-profile/controller.ts
import type { Request, Response } from "express";
// sharedから型・バリデーションをインポート
import {
  UpdateProfileSchema,
  type UpdateProfileInput,
} from "@shared/features/users/validations/update-profile";
import type {
  ErrorResponse,
  ValidationErrorResponse,
} from "@shared/features/common/types/errors";
import type { UpdateProfileResponse } from "@shared/features/users/types/update-profile";
// サービスロジック
import { updateProfileService } from "./service";
// DTO型変換処理
import { toUserDTO } from "./toDTO"


export const updateProfileController = async (
  req: Request,
  res: Response<UpdateProfileResponse | ErrorResponse | ValidationErrorResponse>,
) => {
    try{
      // Zodでリクエストボディを検証
      const parsed = UpdateProfileSchema.safeParse(req.body);
    
      if (!parsed.success) {
        return void res.status(400).json({
          ok: false,
          message: "Invalid request body",
          errors: parsed.error.errors,
        });
      }
    
      // ここに来る時点で parsed.data は UpdateProfileInput 型
      const res = await updateProfileService(parsed.data);
      if(!res.ok && "message" in res) {
        return void res.status(404).json(res.message);
      }
      
      const toDtoRes = {
        ok: res.ok,
        user: toUserDTO(res.user)  // prisma型 → DTO型に変換
      };
    
      return void res.status(200).json(toDtoRes);
    } catch(error) {
      return void res.status(500).json({ ok: false, message: "Internal Server Error:" + error });
    }
};

2-2. サービス層でも同様にインポート

apps/backend/src/features/users/update-profile/service.ts
import type {
  UpdateProfileInput,
} from "@shared/features/users/validations/update-profile";
import type { UpdateProfileResponse } from "@shared/features/users/types/update-profile";
import type { ErrorResponse } from "@shared/features/common/types/errors";
import { updateUserProfile } from "../../../repositories/users";
 
export const updateProfileService = async (
  input: UpdateProfileInput,
): Promise<UpdateProfileResponse | ErrorResponse> => {

  // prismaクエリで処理(省略)
  const user = await updateUserProfile(input);
  if(!user) {
    return { ok: false, message: "user is not found"}
  }

  // 実際は DB 更新などのロジック
  return { ok: true, user };
};

2-3. prisma型をDTO型に変換

apps/backend/src/features/users/update-profile/toDTO.ts
import type { User } from "@prisma/client";
import type { UserDTO } from "@shared/features/users/dto.ts";

export const toUserDTO = (entity: User): UserDTO => ({
  id: entity.id,
  name: entity.name,
  email: entity.email,
});

3. フロントエンド側での共有

3-1. ユーザー編集API用フック useUpdateProfile

apps/frontend/src/features/users/useUpdateProfile.ts
"use client";

import { fetcher } from "@/features/_shared/fetcher";
import { users } from "@/features/_shared/path"; // /api/users/profile など
import type { UpdateProfileResponse } from "@shared/features/users/types/update-profile";
import type {
  ErrorResponse,
  ValidationErrorResponse,
} from "@shared/features/common/types/errors";
import type { UpdateProfileFormInput } from "@shared/features/users/validations/update-profile";

type UpdateProfileResult =
  | UpdateProfileResponse
  | ErrorResponse
  | ValidationErrorResponse;

export const useUpdateProfile = () => {
  const { authToken } = useSelector((state: RootState) => state.authToken);
  const updateProfile = async (
    input: UpdateProfileFormInput,
  ): Promise<UpdateProfileResult> => {
  
    // 自前の汎用フェッチ関数に「APIレスポンス型」をセット
    const res = await fetcher<UpdateProfileResponse>({
      method: "PUT",
      path: users.updateProfile,
      body: input,
      token: authToken
    });

    return res;
  };

  return { updateProfile };
};

3-2. フォームバリデーション用フック useUpdateProfileForm

apps/frontend/src/app/settings/profile/hooks/useUpdateProfileForm.ts
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
  UpdateProfileSchema,
  type UpdateProfileFormInput,
} from "@shared/features/users/validations/update-profile";

// フロント側では role を使わないので omit したスキーマを使う例
const UpdateProfileFormSchema = UpdateProfileSchema.omit({ role: true });

export const useUpdateProfileForm = (
  init?: Partial<UpdateProfileFormInput>,
) => {
  const {
    register,
    handleSubmit,
    formState: { errors, isValid, isSubmitting },
  } = useForm<UpdateProfileFormInput>({
    resolver: zodResolver(UpdateProfileFormSchema),
    mode: "onChange",
    defaultValues: {
      name: init?.name ?? "",
      email: init?.email ?? "",
    },
  });

  const isDisabled = !isValid || isSubmitting;

  return {
    register,
    handleSubmit,
    errors,
    isDisabled,
  };
};

3-3. コンポーネント内で useUpdateProfileuseUpdateProfileForm を呼び出す

apps/frontend/src/app/settings/profile/page.tsx
import { useUpdateProfileForm } from "@/features/users/useUpdateProfileForm";
import { useUpdateProfile } from "@/features/users/useUpdateProfile";

export default function ProfilePage() {
  const { register, handleSubmit, errors, isDisabled } =
    useUpdateProfileForm();
  const { updateProfile } = useUpdateProfile();

  const onSubmit = handleSubmit(async (values) => {
    const res = await updateProfile(values);

    if (!res.ok) {
      // ValidationErrorResponse / ErrorResponse の message などを表示
      alert(res.message ?? "更新に失敗しました");
      return;
    }

    alert("プロフィールを更新しました");
  });

  return (
    <form onSubmit={onSubmit}>
      <div>
        <label>名前</label>
        <input {...register("name")} />
        {errors.name && <p>{errors.name.message}</p>}
      </div>

      <div>
        <label>メールアドレス</label>
        <input {...register("email")} />
        {errors.email && <p>{errors.email.message}</p>}
      </div>

      <button type="submit" disabled={isDisabled}>
        更新する
      </button>
    </form>
  );
}


4. alias設定

backend / frontend の両方で shared の型・validation を参照するために、tsconfig.jsonalias を設定しているのでインポートがラクになりました。

  • backend / frontend の両側で同様の設定を追加
tsconfig.json
{
  "compilerOptions": {
    /// その他省略
     
    "paths": {
      "@shared/*": [
        "../../shared/src/*" 
      ]
    }
  }

「tRPC」 と「モノレポ × shared構成」の比較

観点 tRPC モノレポ × shared構成(本記事方式)
型安全性 フル自動で同期(最強) Zod × TSで十分高い
コード構造 ルーターに集まりがち controller / service で分離しやすい
自由度 tRPC流の書き方に寄る REST / Webhook / RPC 何でもOK
学習コスト やや高い(DSL理解が必要) 低い(Zod & TSだけ)
拡張性 中規模まで最適 大規模でも崩れにくいが工夫が必要
既存技術との相性 外部API(webhookなど) は扱いづらいことがある Express / Next / webhook すべて自然
運用コスト 思想に沿えば運用しやすい モノレポの共有管理がやや面倒

最後に

tRPCを知らないまま始めた TSモノレポ構成でしたが、結果として 十分に戦える設計 になったと感じています。

結論としては、

  • 柔軟に開発したい → TSモノレポ
  • 型同期のDXを最大化したい → tRPC

という選び方で十分だと思います。

どちらも強力なので、目的に合わせて選ぶのが一番です。この記事がその判断材料になれば嬉しいです。

9
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?