adam
Back to Blog
10 min read

Type Safety as a Contract: Building Bridges in Monorepos

How TypeScript, Zod, Prisma, and GraphQL Codegen enable type sharing across frontend and backend in monorepos

Main image for Type Safety as a Contract: Building Bridges in Monorepos

Type safety isn't just about avoiding undefined is not a function errors at 2 AM—it's about establishing contracts between the moving parts of your application. In modern web engineering, the chasm between frontend and backend has historically been a source of bugs, miscommunication, and those delightful "it works on my machine" moments. But here's my stance: TypeScript types, when shared intelligently across your stack, become the single source of truth that transforms fragile assumptions into enforceable contracts. Tools like Zod, Prisma, and GraphQL Codegen have matured to the point where type sharing in monorepos isn't just possible—it's elegant, maintainable, and honestly a bit magical.

The old way of doing things meant your backend would return some JSON, your frontend would cross its fingers and hope the shape matched expectations, and inevitably someone would rename a field without telling anyone. We've all been there. The promise of TypeScript was supposed to fix this, but for the longest time, that promise only extended to the boundaries of a single codebase. What we needed—what we finally have—is a way to make types flow seamlessly from database schema to API layer to UI components, all while keeping our sanity intact.

The Monorepo Foundation: Why Structure Matters

Before we talk about sharing types, let's establish the playing field. A monorepo using npm workspaces provides the scaffolding we need. Here's a minimal setup that'll serve as our foundation:

// package.json (root)
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "packages/*",
    "apps/*"
  ]
}
my-monorepo/
├── apps/
│   ├── frontend/          # Next.js or React app
│   └── backend/           # Express, Fastify, etc.
├── packages/
│   ├── shared-types/      # Our shared type definitions
│   └── validation/        # Zod schemas live here
└── package.json

This structure isn't revolutionary, but it's intentional. The packages/ directory becomes our shared layer—a DMZ where frontend and backend can safely exchange type information without coupling implementation details. The monorepo setup means we can import from @my-app/shared-types in both apps without publishing to npm or wrestling with npm link. Simple, clean, effective.

The beauty of npm workspaces is that dependencies resolve naturally. When your backend defines a type and your frontend imports it, TypeScript's language server picks it up immediately. No build steps, no publishing ceremony, just pure type information flowing where it needs to go. This foundation is critical because all the fancy type-sharing strategies we're about to explore depend on having a sane project structure.

Zod: Runtime Validation Meets Compile-Time Safety

Zod has become something of a darling in the TypeScript community, and for good reason. It's a schema validation library that generates TypeScript types from runtime validators, bridging the gap between "I hope this data is correct" and "I know this data is correct." Here's where it gets interesting: Zod schemas can live in your shared package and serve both as API validators on the backend and type sources for the frontend.

Let's say you're building a user profile feature. In packages/validation/src/user.ts:

import { z } from 'zod';

export const UserProfileSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  displayName: z.string().min(2).max(50),
  avatarUrl: z.string().url().optional(),
  createdAt: z.string().datetime(),
});

export type UserProfile = z.infer<typeof UserProfileSchema>;

export const UpdateUserProfileSchema = UserProfileSchema.partial()
  .omit({ id: true, createdAt: true });

export type UpdateUserProfile = z.infer<typeof UpdateUserProfileSchema>;

Now in your backend (apps/backend/src/routes/user.ts):

import { UserProfileSchema } from '@my-app/validation';
import type { UserProfile } from '@my-app/validation';

app.get('/api/user/:id', async (req, res) => {
  const user = await db.users.findUnique({ where: { id: req.params.id } });
  
  // Validate the data matches our contract
  const validated = UserProfileSchema.parse(user);
  
  res.json(validated);
});

And in your frontend (apps/frontend/src/hooks/useUser.ts):

import type { UserProfile } from '@my-app/validation';
import { UserProfileSchema } from '@my-app/validation';

export function useUser(id: string) {
  return useQuery<UserProfile>({
    queryKey: ['user', id],
    queryFn: async () => {
      const res = await fetch(`/api/user/${id}`);
      const data = await res.json();
      
      // Optionally validate at runtime on the frontend too
      return UserProfileSchema.parse(data);
    },
  });
}

This pattern is powerful because the same schema that validates incoming data on your backend provides type information to your frontend. You've eliminated the possibility of drift between what your API returns and what your UI expects. When you add a field, both sides know about it immediately. When you change a validation rule, TypeScript will complain in both codebases until you address it.

Prisma: Database Schema as the Source of Truth

Prisma takes a different approach—your database schema becomes the type origin. If you're using Prisma as your ORM, you define models in schema.prisma, run prisma generate, and suddenly you have TypeScript types for every table and relation. The workflow looks like this:

// packages/database/prisma/schema.prisma
model User {
  id          String   @id @default(uuid())
  email       String   @unique
  displayName String
  avatarUrl   String?
  posts       Post[]
  createdAt   DateTime @default(now())
}

model Post {
  id        String   @id @default(uuid())
  title     String
  content   String
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
}

After running prisma generate, Prisma creates a client with full type information in node_modules/@prisma/client. You can then re-export select types from your shared package:

// packages/shared-types/src/index.ts
export type { User, Post } from '@prisma/client';

// Or create API-specific types that omit sensitive fields
import type { User } from '@prisma/client';

export type PublicUser = Omit<User, 'passwordHash' | 'emailVerificationToken'>;

The frontend can now import these types and trust that they match the database schema exactly. This approach works especially well when your API is a thin layer over database operations. The main caveat: Prisma types include all fields, so you'll often want to create derivative types (like PublicUser above) that exclude sensitive or internal data before sending responses to the client.

GraphQL Codegen: The Type-Safe API Layer

If you're in the GraphQL camp, graphql-code-generator might be the most elegant type-sharing solution available. You define a schema, write queries and mutations, and codegen automatically generates TypeScript types for everything—including fully-typed React hooks if you're using Apollo or urql. The developer experience is frankly stunning.

Start with a GraphQL schema (apps/backend/src/schema.graphql):

type User {
  id: ID!
  email: String!
  displayName: String!
  avatarUrl: String
  posts: [Post!]!
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  createdAt: DateTime!
}

type Query {
  user(id: ID!): User
  posts(authorId: ID): [Post!]!
}

type Mutation {
  updateUser(id: ID!, input: UpdateUserInput!): User!
}

input UpdateUserInput {
  displayName: String
  avatarUrl: String
}

Configure codegen (codegen.yml):

schema: "apps/backend/src/schema.graphql"
documents: "apps/frontend/src/**/*.graphql"
generates:
  packages/shared-types/src/graphql.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo
    config:
      withHooks: true
      withHOC: false
      withComponent: false

Define a query in your frontend (apps/frontend/src/queries/user.graphql):

query GetUser($id: ID!) {
  user(id: $id) {
    id
    email
    displayName
    avatarUrl
    posts {
      id
      title
    }
  }
}

Run graphql-codegen, and you get a fully typed React hook:

import { useGetUserQuery } from '@my-app/shared-types';

function UserProfile({ userId }: { userId: string }) {
  const { data, loading, error } = useGetUserQuery({
    variables: { id: userId },
  });

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  
  // data.user is fully typed - autocomplete and type checking work perfectly
  return (
    <div>
      <h1>{data?.user?.displayName}</h1>
      <img src={data?.user?.avatarUrl} alt="" />
      <PostList posts={data?.user?.posts} />
    </div>
  );
}

The types flow from schema to resolver to query to component with zero manual intervention. Change the schema, regenerate, and TypeScript immediately tells you everywhere that needs updating. For teams building GraphQL APIs, this is as close to nirvana as type sharing gets.

Other Strategies: tRPC and OpenAPI

Two more approaches deserve mention. tRPC has exploded in popularity for full-stack TypeScript apps, offering end-to-end type safety without codegen. You define procedures on the backend, and the frontend gets autocomplete and type checking for free:

// Backend router
import { z } from 'zod';
import { router, publicProcedure } from './trpc';

export const userRouter = router({
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return await db.user.findUnique({ where: { id: input.id } });
    }),
});
// Frontend
import { trpc } from './trpc';

function UserProfile({ id }: { id: string }) {
  const { data } = trpc.user.getUser.useQuery({ id });
  // `data` is fully typed based on the backend return type
}

tRPC is brilliant for monorepos where both frontend and backend are TypeScript. The catch: it's RPC-style, not REST, so it's a bigger architectural commitment.

For REST APIs, OpenAPI (formerly Swagger) with tools like openapi-typescript can generate types from your API spec:

# openapi.yaml
paths:
  /users/{id}:
    get:
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: User found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'

Run openapi-typescript openapi.yaml -o packages/shared-types/src/api.ts, and you get type definitions your frontend can import. This works well if you're already maintaining OpenAPI docs or if your backend isn't TypeScript.

The Broader Point: Contracts Over Conventions

All these tools share a philosophy: explicit contracts beat implicit assumptions. When types are shared, the contract between frontend and backend becomes impossible to violate accidentally. Rename a field without updating consumers? TypeScript yells at you. Add a required parameter? The compiler won't let you forget to pass it. Return the wrong data shape? Runtime validation with Zod catches it before it reaches production.

This isn't just about preventing bugs—though it absolutely does that. It's about enabling fearless refactoring. When your types are shared and enforced, you can restructure your API with confidence. Change a return type, and TypeScript shows you every call site that needs attention. In a codebase without shared types, that same change might ship broken to production and only surface when users start complaining.

The monorepo structure amplifies these benefits. With everything in one repository, there's no version mismatch hell, no waiting for package updates, no drift between what the backend thinks it's sending and what the frontend expects to receive. The feedback loop becomes instantaneous: change the schema, save the file, and your IDE lights up with red squiggles showing exactly what broke.

Choosing Your Approach

So which strategy should you choose? As with most engineering decisions, it depends. If you're already using Prisma, leveraging its generated types is a no-brainer. If you're building a GraphQL API, codegen is the clear winner. For greenfield TypeScript projects, tRPC offers the smoothest experience. And if you need maximum flexibility with runtime validation, Zod schemas in a shared package give you both type safety and runtime guarantees.

In practice, you might use multiple approaches. Prisma types for database models, Zod schemas for API validation and frontend forms, and maybe GraphQL codegen for a public API. The tools compose well because they're all playing in the same TypeScript sandbox. The important thing is committing to the pattern: define types once, use them everywhere, and let the compiler enforce contracts.

The alternative—keeping backend and frontend types separate and hoping they stay in sync—is a recipe for bugs, frustration, and 3 AM production incidents. We've lived in that world long enough. The tools to do better are here, battle-tested, and increasingly considered standard practice. If you're still manually duplicating type definitions between frontend and backend, you're working too hard and getting too little safety in return.

Sources & Further Reading