Pengion Pilot The Network ·

Full-Stack Type Safety in a Turborepo Monorepo with NestJS, Next.js, and Prisma

How we achieved end-to-end type safety from database schema to API to frontend — no codegen needed — using TypeScript inference and a typed apiClient pattern.

TypeScriptTurborepoNestJSNext.jsPrismaMonorepo

The single biggest advantage of a TypeScript monorepo isn’t shared code — it’s shared types. When a Prisma model changes, TypeScript errors appear in the API service, the controller, and the React component that displays the data. All in the same build. No deploy-and-discover.

This post covers the type safety setup in Pengion Pilot’s Turborepo monorepo, and the pattern that eliminated the need for API client codegen.

The Type Safety Chain

Type safety chain from database to frontend

Types flow through four layers: Prisma Schema → NestJS Service → Controller Return Type → Frontend apiClient

Each link in the chain uses TypeScript inference rather than manual type definitions. Change the schema, and the compiler tells you everywhere that breaks.

Layer 1: Prisma → TypeScript

Prisma generates TypeScript types from our schema files. Every model, every relation, every enum becomes a type. DateTime fields become Date, arrays become union types, enums become TypeScript enums. The generated types are the single source of truth for the shape of our data.

Layer 2: Prisma → NestJS Service

Services use the Prisma client with full type inference. The return type of a service method is inferred from the Prisma query — including which relations are eagerly loaded. No manual type annotations needed. Change the query’s include clause and the return type updates automatically.

Layer 3: Service → Controller Export

Here’s where the monorepo magic happens. Controller methods return the service’s typed response, and the controller class is exported from the API package. The package’s exports map points each controller to its compiled type declarations.

Layer 4: Controller → Frontend apiClient

The frontend imports the controller type and uses it with a generic apiClient function. The generic parameter extracts the return type of the controller method, giving the frontend full type inference on the API response. No codegen. No OpenAPI spec. Just TypeScript inference using Awaited<ReturnType<T>>.

Why Not tRPC or GraphQL Codegen?

We considered both:

tRPC requires restructuring the entire API around its router pattern. Our NestJS controllers, guards, and decorators don’t map to tRPC’s model. We’d be throwing away NestJS’s strengths to get type safety we can achieve with plain TypeScript exports.

GraphQL codegen generates typed hooks from a schema. It works well but adds a build step, a schema definition layer, and generated files to manage. We already have types flowing from Prisma — adding another schema layer felt redundant.

The controller-export pattern gives us type safety with zero additional tooling. The tradeoff: it’s not as robust as tRPC’s runtime validation, and it requires the API to build before the dashboard (for the .d.ts files to exist).

Multi-File Prisma Schemas at Scale

With 26 schema files, organization matters. Each domain — users, tenants, brands, posts, content streams, billing, integrations, approvals, background jobs — lives in its own file.

Benefits:

  • Reduced merge conflicts: two developers can modify different domains simultaneously
  • Navigation: finding a model means opening its domain file, not scrolling through a massive single file
  • Domain boundaries: the file structure reflects the domain model

Prisma’s multi-file support handles cross-file relations seamlessly. A model in one file can reference a model in another file without any special configuration.

Build Orchestration

Turborepo manages the build dependency graph. The build order is: database package (Prisma generate) → API (compile to type declarations) → dashboard (imports API types). Turborepo resolves this automatically from the dependency graph.

Remote caching means subsequent builds only recompile changed packages. A change to a brand schema file rebuilds the database package, then the API (which imports brand types), then the dashboard (which imports the brand controller). A change to a dashboard component only rebuilds the dashboard.

Pitfalls We Hit

TypeScript Build Cascades

A type error in the database package cascades to the API and dashboard. During heavy refactoring, you can end up with hundreds of errors across packages. The fix: work bottom-up. Fix the schema, regenerate types, fix API errors, then fix dashboard errors.

Keeping Exports in Sync

When we add a new controller, we need to add its export path to the API package configuration. Forgetting this means the dashboard can’t import the types. We added a build-time check that verifies all controllers have corresponding exports.

Version Conflicts

Multiple packages depending on different versions of shared libraries (like Zod) caused type resolution failures. The fix: pin shared dependencies to a single version in the root configuration using npm overrides.

any Creep in Error Handling

Exception filters and error handlers often use any types. These holes in the type chain mean the frontend might receive an error response shaped differently than expected. We added typed error response interfaces and used them consistently.

The result

Change a database field, and the build fails everywhere the old field was referenced. Add a required parameter to an API endpoint, and the frontend call sites light up with TypeScript errors. Rename an enum value, and every switch statement that matched the old value gets flagged.

We’ve done full-stack refactors — renaming domain concepts, restructuring database relations — that would have taken days of manual grep-and-fix. With this setup, the compiler finds every breakage. No deploy-and-discover, no runtime surprises. Just TypeScript inference and Turborepo’s build graph.

Series: Building Pengion Pilot

This post is part of a series on the technical challenges we hit building Pengion Pilot. If you haven’t already, start with the first post covering the full architecture and tech stack.

  1. How We Built an AI SaaS from First Commit to Production
  2. Migrating from Clerk to Better Auth
  3. Multi-Tenancy in NestJS
  4. AI Content Generation Pipeline
  5. Credit-Based Billing with Stripe
  6. Content Streams
  7. Full-Stack Type Safety ← you are here
  8. SaaS Security Lessons
  9. Background Jobs and Workers

Each post covers actual decisions and bugs we hit. If you’re building a SaaS, hopefully some of this is useful.