← Blog
typescriptmonoreporeact nativenode.jsarchitecture

Sharing Types Across Four Apps

10 April 2026

Sharing Types Across Four Apps

ReD Sposi is four applications talking to each other:

  • Mobile app — Expo + React Native, Expo Router, NativeWind
  • Backend API — Express + Node.js, MongoDB with Mongoose
  • Admin panel — React + Vite, SPA
  • Landing page — Next.js

All four use TypeScript. All four talk to the same APIs. All four model the same entities: guests, RSVPs, photos, chat messages, bets.

The inevitable question is: how do you keep the types aligned?

The Options

There are three main approaches.

Shared package. You create a @red-sposi/types package in the monorepo, put all shared interfaces there, and each app imports it as a dependency. It's the cleanest approach architecturally — a single source of truth, no duplication.

Runtime validation with Zod. You define schemas with Zod, and infer TypeScript types from each schema. The advantage is that validation happens at runtime — if the backend sends an extra or missing field, you know immediately, not just at compile time.

Conscious copy-paste. You define types where they make most sense (usually on the backend, near the Mongoose models), and copy them manually to the apps that use them when they change. It's technically the least elegant, but has zero configuration overhead.

What I Chose and Why

For ReD Sposi I chose a middle ground between the shared package and copy-paste: types defined on the backend, exported from a dedicated file, and manually copied to the frontend when they change.

It's not the most elegant choice. It's the choice with the lowest setup cost for a project with a fixed deadline, built by one person.

A shared package requires monorepo configuration — path aliases, build pipeline, module resolution in Expo and Next.js. Expo in particular has some limitations on how it resolves modules external to its root, requiring additional configuration in metro.config.js and tsconfig.json. Not impossible, but an hour of setup that adds no functionality.

Zod is great — I use it on the backend to validate request bodies — but using it as source of truth for shared types adds a layer of complexity that didn't pay off for this project.

How It Works in Practice

On the backend, types live near the Mongoose models:

// models/Guest.ts
export interface IGuest {
  _id: string;
  name: string;
  code: string;
  rsvp?: {
    confirmed: boolean;
    menuChoice: "standard" | "vegetarian" | "vegan";
    shuttle: boolean;
    completedAt: Date;
  };
  pushToken?: string;
}

const GuestSchema = new Schema<IGuest>({ ... });
export const Guest = model<IGuest>("Guest", GuestSchema);

On the frontend, I either import this type directly or define a derived one for specific UI needs:

// mobile app: local types mirroring the backend
export interface Guest {
  _id: string;
  name: string;
  rsvp?: {
    confirmed: boolean;
    menuChoice: "standard" | "vegetarian" | "vegan";
    shuttle: boolean;
  };
}

It's not DRY in the strict sense. But for an app with a dozen main entities and a lifespan of a few months, it's sufficient.

The Real Problem: Type Drift

The challenge isn't in the setup — it's in the maintenance. Types desync when backend models change and the frontend isn't updated.

In practice this manifests in two ways: TypeScript errors that don't clearly explain what changed, or — worse — no errors because the type is any or the API response isn't validated.

I mitigated this in two ways:

  1. Validation at the boundary. On the backend, every API response is explicitly typed. If I add a field to the model, I have to add it to the response type too — and Zod validates that incoming data matches.

  2. Lightweight integration tests. I don't have unit tests on everything, but I have some tests that call the real APIs and verify the shape of the response. If a field changes, the test fails, and I know there's something to update on the frontend.

When I Would Have Chosen Differently

If ReD Sposi were a long-term product with a roadmap, I would have invested in the shared package from the start. The setup cost amortizes over a long horizon — every change to the data model that doesn't require manual updates in three different places is time saved.

For a project that ends in August, the calculation was different. The advanced setup cost was certain; the benefit depended on how many times I would change models significantly. With a tight deadline and a fairly stable feature list, the added complexity didn't pay off.

The Lesson

There's no universally correct architecture — there's a correct architecture for the context you're working in.

A shared package is the right answer for a long-term product with a team. Conscious copy-paste is the right answer for a deadline-driven project built alone. Zod as source of truth is the right answer when runtime validation is a product requirement.

The wrong choice is the one that solves the theoretical problem instead of the real one.