Shared Types Between Frontend and Backend in a TypeScript Monorepo
AI generated
<T>
type
TypeScript · Monorepo · API Contracts
Shared Types Between Frontend and Backend
Contract packages in a TypeScript monorepo

When frontend and backend define their own independently maintained types for the same API, both sides eventually drift apart, and the bug only surfaces at runtime for the customer. A shared contract package in a TypeScript monorepo makes such deviations visible at compile time instead.

17 min read Shared Types · Zod · Contract Package TypeScript 5.x · Node.js 20+

1. Why types drift apart between frontend and backend

In a classic setup, the backend maintains its own types for request and response objects, while the frontend defines separate interfaces for the same data structures. Both sides compile without errors even though the backend has meanwhile renamed a field or changed a date format. The bug only surfaces once a user submits a form and the response no longer matches the shape the frontend expects. This exact scenario is the reason shared types in a TypeScript monorepo are so valuable.

The core idea is simple: instead of maintaining two independent type definitions for the same data structure, there is exactly one that both sides import. When the structure changes, the TypeScript compiler immediately flags every package that still expects the old shape. These errors show up at build time, not later with a customer in production. In a TypeScript monorepo with shared version management, the technical prerequisite already exists, all that is missing is the deliberate decision to create a dedicated package for these shared types.

In practice, the value of shared types shows up especially clearly for teams that iterate quickly and frequently add new fields or endpoints. Without a shared source of truth, such teams almost inevitably fall back on a silent agreement to announce API changes in a chat channel too, because the compiler alone gives no warning. This informal communication does not scale and reliably fails as soon as a team grows or a new member does not yet know the convention.

2. The contract package as single source of truth

The common structure is a standalone package, often named packages/contracts, that contains only type definitions, schemas and possibly small helper functions, but no business logic. This package deliberately has no dependency on a web framework like Express or a frontend framework like React, so it can be imported by both sides without unnecessary overhead. For shared types in a TypeScript monorepo this separation is crucial, because a contract package with backend specific dependencies would unnecessarily bloat the frontend bundle size.

Inside the contract package, types are usually organized by business domain, for example user.ts, order.ts and product.ts, rather than by technical layer. Each file exports both the TypeScript type and, if runtime validation is needed, the associated schema. This structure makes it easy for new team members to find the source of truth for a given data model without searching through backend or frontend code.

A contract package should also get its own independent versioning in the sense of internal semver discipline, even if it is never published publicly on npm. This allows communicating breaking changes to shared types just as clearly as for any other internal package in a TypeScript monorepo, instead of treating changes to contracts implicitly as a side effect of other releases.


// packages/contracts/src/order.ts
import { z } from "zod";

// Single source of truth for the Order shape
export const OrderStatusSchema = z.enum([
  "pending",
  "paid",
  "shipped",
  "cancelled",
]);

export const OrderSchema = z.object({
  id: z.string().uuid(),
  customerId: z.string().uuid(),
  status: OrderStatusSchema,
  totalCents: z.number().int().nonnegative(),
  createdAt: z.string().datetime(),
});

// Type is derived from the schema, never written by hand
export type Order = z.infer<typeof OrderSchema>;
export type OrderStatus = z.infer<typeof OrderStatusSchema>;

3. Zod schemas instead of plain interfaces

A plain TypeScript interface only describes the shape of data at compile time and disappears entirely once the code is compiled to JavaScript. For shared types that cross a network boundary, this is not enough, because an external request is never guaranteed to actually match the expected shape. Zod solves this problem by actually validating the schema at runtime and deriving the TypeScript type via z.infer directly from the schema, instead of maintaining it separately.

This approach prevents a second kind of drift that plain shared types without runtime validation cannot solve: the type can be correct while the actual data at runtime still deviates, for example because a third party webhook delivers unexpected fields. With Zod, every incoming payload is checked against the same schema that also defines the TypeScript type, so type safety and runtime safety come from the same source instead of being two separate truths.

Another practical advantage of Zod over plain interfaces shows up with transformations: a schema can automatically convert incoming strings into numbers or dates while at the same time ensuring the result matches the expected shared types type. This combination of parsing and validating in a single step reduces additional transformation code that would otherwise have to be maintained separately.

4. Usage in the backend: validation at the API boundary

In the backend, the contract package is imported at exactly the point where a request crosses the system boundary, that is in the request handler, before the data is passed on to business logic. The handler calls OrderSchema.parse(requestBody), which either returns a typed object or throws an exception with a detailed error list. This is the only place where unstructured, potentially incorrect input is converted into structured, type safe data, and that is exactly what makes shared types in a TypeScript monorepo practically usable rather than merely theoretically correct.

A common mistake is validating only in the frontend and trusting that the backend will only ever receive valid data. This ignores that an API might also be called by other clients, mobile apps or direct HTTP calls that never go through frontend validation at all. Validating with the same Zod schema in the backend is therefore not redundancy but the actual security boundary, while frontend validation mainly serves user experience.


// apps/api/src/routes/orders.ts
import { OrderSchema } from "@myorg/contracts";
import type { Request, Response } from "express";

export function createOrderHandler(req: Request, res: Response) {
  const result = OrderSchema.omit({ id: true, createdAt: true }).safeParse(
    req.body
  );

  if (!result.success) {
    // The same schema also shapes the frontend form validation
    return res.status(400).json({ errors: result.error.flatten() });
  }

  const order = createOrder(result.data);
  res.status(201).json(order);
}

Another point often overlooked in practice: the error objects safeParse returns on failed validation should themselves be part of the contract package if the frontend wants to display them in a structured way. Without a shared format for validation errors, another place emerges where frontend and backend make independent assumptions that can drift apart just like the actual shared types.

5. Usage in the frontend: forms and response parsing

In the frontend, the same schema serves two purposes. First, it validates form input before a request is even sent, giving the user immediate feedback instead of a round trip to the server. Second, it parses the backend's response to make sure what actually arrives over the network matches the expected contract. This second usage is often overlooked, but it matters especially in a TypeScript monorepo with several backend services, because an individual service can well deviate from the expected response structure.

The practical benefit shows up particularly during refactoring: renaming a field in the contract package makes the TypeScript compiler immediately flag every place in the frontend that still uses the old field name as an error. Without shared types, the same bug would remain unnoticed until a user reports a broken page. This immediate visibility of breaking changes is the real productivity gain, not the saved typing on the type definition itself.

For React applications using form libraries such as React Hook Form, the Zod schema can be wired in directly as a resolver, so no separate validation logic accumulates in the form code. The same schema instance then handles both client side validation and, after submission, interpreting the backend response, without maintaining two different validation libraries.

6. Versioning contracts through breaking changes

Once several frontend versions are in use in parallel, for example during a gradual rollout, a single version of the contract package is no longer enough. A breaking change to the order schema that adds a required field would break older frontend instances that do not yet send it. The common solution is to introduce new fields as optional first, and only make them required in a later version once it is confirmed no active client still uses the old shape.

For API versions that must be supported in parallel, the contract package sometimes exports several schema versions side by side, for example OrderSchemaV1 and OrderSchemaV2, instead of simply deleting the old schema. This practice increases the complexity of the contract package in the short term, but prevents breaking changes to shared types from uncontrollably hitting production. It matters to give old schema versions a clear end date and mark them as deprecated visibly in the code.

A structured versioning process like Changesets is excellent for documenting exactly these decisions for a contract package in a traceable way. Every change to a schema then gets explicitly classified as patch, minor or major, and the associated changelog makes visible which consumers are affected by a given contracts version, before a team accidentally works against a stale schema version.

7. Limits of the approach: when shared types do not fit

Shared types assume that frontend and backend live in the same TypeScript monorepo, or at least can obtain the same contract package through a private registry. For a public API consumed by external third parties, this approach is unsuitable, because external consumers cannot install an npm package from an internal monorepo. Here a language independent standard like OpenAPI is the better choice, because it can generate client code for any programming language, not only TypeScript.

Another edge case arises when frontend and backend are developed by completely different teams with their own release cycles, without both teams working in the same repository. In this case the contract package itself becomes coordination overhead, because every change requires communication between teams that could previously deploy independently. For loosely coupled teams, a contract based approach like consumer driven contract testing is often more practical than fully shared shared types.

8. Alternatives: OpenAPI codegen and tRPC compared

OpenAPI code generation takes a different path to the same goal: instead of hand written Zod schemas, an OpenAPI specification describes the API, and a generator produces TypeScript types for both sides from it. The advantage is language independence and an established tooling landscape, the disadvantage an additional generation step that must run on every API change, instead of types being immediately available as soon as the contract package is saved.

tRPC goes in the opposite direction and does away with schemas as a separate artifact entirely. Instead, backend router definitions are imported directly as a TypeScript type, and the client derives full end to end type safety from that, with no code generation and no manually maintained contract package. This approach works excellently within a TypeScript monorepo with a single backend, but hits limits once multiple independent services or non TypeScript clients are involved.

A hybrid approach often underestimated in practice: define a contract package with Zod schemas as the internal source of truth and additionally generate an OpenAPI specification from it automatically, for example with the library zod-to-openapi. That way the internal developer experience stays fast and type safe, while a language independent documentation for future external consumers emerges at the same time, with no need to maintain two completely separate truths.

9. Shared types approaches side by side

The choice between manually maintained contract packages, OpenAPI codegen and tRPC depends heavily on team structure, number of clients and language diversity.

Approach Prerequisite Runtime validation Language independent
Contract package with Zod Shared TypeScript monorepo Yes, built in No, TypeScript only
OpenAPI codegen Maintained OpenAPI specification Separate, depends on generator Yes, any language
tRPC One backend, TypeScript everywhere Yes, via Zod input schemas No, TypeScript clients only
Consumer driven contracts Multiple independent teams Via separate test suite Yes, language independent

For an internal TypeScript monorepo with its own backend and its own frontend, a contract package with Zod is usually the most pragmatic solution, because it requires no additional code generation step and includes runtime validation directly. Once external clients or other programming languages come into play, the balance shifts toward OpenAPI or consumer driven contracts.

The table also shows that none of the four approaches is superior in every situation. A team should decide based on actual consumers, not on whichever approach happens to be trending, because a wrongly chosen strategy causes considerably more migration effort in a growing TypeScript monorepo than the initial decision would have cost on its own.

Mironsoft

TypeScript architecture, API design and full stack type safety

Frontend and backend drifting apart on the API?

We build contract packages with Zod schemas, set up shared types between frontend and backend in a TypeScript monorepo, and rule out typical API drift bugs already at compile time.

Contracts design

Set up a domain oriented contract package with Zod schemas

API hardening

Validation at every API boundary with the same schema as the frontend

Versioning strategy

Introduce breaking changes to contracts in a controlled, backward compatible way

10. Summary

Shared types solve the core problem that frontend and backend maintain independent type definitions for the same data structure and thereby drift apart unnoticed. A standalone contract package in the TypeScript monorepo, combined with Zod schemas, makes breaking changes visible already at compile time and ensures that runtime validation and type definition come from the same source instead of being two separate truths.

The approach has clear limits for public APIs with external, non TypeScript consumers and for loosely coupled teams with independent release cycles. In these cases, OpenAPI codegen or consumer driven contract testing are the better choice. For an internal TypeScript monorepo with its own frontend and backend, however, a hand maintained contract package remains the most pragmatic path to real end to end type safety.

Starting today with a single internal contract package allows moving later to a hybrid solution with generated OpenAPI documentation without major rework, once external consumers appear. This step by step extensibility makes starting with shared types low risk even for teams that do not yet know whether their API will ever open up publicly.

What matters most is consistently placing validation with the same schema at the actual system boundary, not just wherever it currently seems most convenient.

This single principle prevents the vast majority of API drift problems in practice.

Everything else follows from this one decision almost by itself.

Shared Types Between Frontend and Backend — the key takeaways

Contract package

Standalone package with no framework dependencies, organized by business domain rather than technical layer.

Zod instead of plain interfaces

Type is derived via z.infer from the schema, runtime validation and type safety come from one source.

Validation at the API boundary

Backend always validates, independent of frontend validation, because other clients can call the API too.

Know the limits

For public APIs and loosely coupled teams, OpenAPI codegen or consumer driven contracts are the better choice.

11. FAQ: Shared Types Between Frontend and Backend

1What is a contract package?
A standalone package with type definitions and schemas for exchanged data structures, without its own business logic.
2Why is an interface not enough?
Interfaces only exist at compile time. Network data additionally needs runtime validation.
3Do I have to use Zod?
Not for pure type safety. Once data comes from outside, runtime validation with Zod or similar libraries makes sense.
4Where to validate?
Always in the backend at the API boundary. Frontend validation complements it but does not replace it.
5Handling breaking changes?
Introduce new fields as optional first, export multiple schema versions for parallel supported API versions.
6Works without a monorepo?
Yes, via a versioned npm package in a private registry, with an extra release step.
7When is OpenAPI better?
Once external, non TypeScript clients such as mobile apps need to consume the API.
8Difference to tRPC?
tRPC skips a separate schema package and derives types directly from backend routers, only useful with tight TypeScript coupling.
9Does it bloat bundle size?
Without backend specific dependencies, the overhead stays minimal, Zod itself is small.
10How to organize many types?
By business domain, one file per business object, instead of separate folders for requests and responses.