Sharing types between front end and back end
The seam between front end and back end is where most bugs in a full-stack application live — not because either side is wrong, but because they disagree about something and neither notices. Shared types turn that disagreement into a compile error.
The problem
// The API returns
{ id: "clx1", name: "Atta", pricePaise: 28500 }
// The front end expects
<p>₹{product.price / 100}</p> // undefined / 100 = NaN
price versus pricePaise. No error, no warning — NaN appears on the page.
Somebody renamed the field in the API and the front end kept compiling, because
as far as it knows the response is any.
That is the whole category. An API response is untyped data crossing a boundary, and without a shared definition each side believes whatever it was told last.
One definition
// packages/shared/src/product.ts
export type Unit = "GRAM" | "KILOGRAM" | "MILLILITRE" | "LITRE" | "PIECE" | "PACKET";
export interface VariantSummary {
id: string;
sku: string;
label: string;
unit: Unit;
pricePaise: number;
mrpPaise: number | null;
inStock: boolean;
}
export interface ProductSummary {
id: string;
slug: string;
name: string;
brand: string | null;
imageUrl: string | null;
category: { slug: string; name: string };
cheapestVariant: VariantSummary;
}
export interface ProductDetail extends ProductSummary {
description: string | null;
variants: VariantSummary[];
}
// packages/shared/src/index.ts
export * from "./product";
export * from "./order";
export * from "./auth";
export * from "./pagination";
export * from "./errors";
The API declares its return type:
@Get(":slug")
findOne(@Param("slug") slug: string): Promise<ProductDetail> {
return this.products.findBySlug(slug);
}
The front end consumes it:
import type { ProductDetail } from "@kirana/shared";
export function ProductPage({ product }: { product: ProductDetail }) {
return <Money paise={product.cheapestVariant.pricePaise} />;
}
Rename pricePaise in packages/shared and both halves fail to compile at
once. You find out in your editor, in seconds.
The types are the contract
This is the point worth internalising.
Without shared types, the contract is whatever the API happens to return today, discovered by reading code or by things breaking. With them, the contract is a file both sides import, and changing it is a deliberate act with immediate consequences.
This is also why the monorepo exists — module 1's justification, now earning itself.
Do not share your database models
import type { Product } from "@prisma/client"; // do not expose this
Tempting, and wrong, for three reasons.
It leaks. A Prisma User has passwordHash. Typing an endpoint with it
invites returning it.
It couples the API to your schema. Rename a column and every client breaks — the thing versioning was supposed to avoid.
It is the wrong shape. The database has stock: 12; the API exposes
inStock: true. The database has a Decimal; JSON does not.
Keep API types separate from database types, and map between them explicitly:
// apps/api/src/products/product.mapper.ts
import type { ProductSummary } from "@kirana/shared";
export function toProductSummary(row: ProductWithRelations): ProductSummary {
const cheapest = row.variants[0];
return {
id: row.id,
slug: row.slug,
name: row.name,
brand: row.brand,
imageUrl: row.imageUrl,
category: { slug: row.category.slug, name: row.category.name },
cheapestVariant: {
id: cheapest.id,
sku: cheapest.sku,
label: cheapest.label,
unit: cheapest.unit,
pricePaise: cheapest.pricePaise,
mrpPaise: cheapest.mrpPaise,
inStock: cheapest.stock > 0,
},
};
}
Tedious, and it is a boundary. Adding a database column does not change your API until you decide it should — which is exactly the property you want.
stock > 0 becoming inStock is where the commercial decision from module 7
is enforced, in one place.
Types do not exist at runtime
The limit worth being clear about:
const product: ProductDetail = await response.json();
That is a claim, not a check. response.json() returns any, and the
annotation silences TypeScript without verifying anything. If the API returns
something else — an old deployment, an error body, a proxy's HTML error page —
you get a runtime failure somewhere unrelated.
Types protect you when both halves are built together. They do not protect against a deployed API disagreeing with a deployed front end.
For most of this project that is acceptable, because both deploy together. Where it matters — parsing a webhook, reading third-party data — validate at runtime with Zod:
import { z } from "zod";
export const productSummarySchema = z.object({
id: z.string(),
slug: z.string(),
name: z.string(),
pricePaise: z.number().int().nonnegative(),
});
export type ProductSummary = z.infer<typeof productSummarySchema>;
z.infer derives the TypeScript type from the schema, so there is still one
definition — and now it can be checked at runtime where that is worth the cost.
A typed client
// apps/web/src/lib/api.ts
import type { Paginated, ProductDetail, ProductSummary } from "@kirana/shared";
const BASE = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL;
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!response.ok) {
throw await ApiError.from(response);
}
return response.json() as Promise<T>;
}
export const api = {
products: {
list: (query: Record<string, string> = {}) =>
request<Paginated<ProductSummary>>(
`/products?${new URLSearchParams(query)}`,
),
get: (slug: string) => request<ProductDetail>(`/products/${slug}`),
},
};
const { items } = await api.products.list({ category: "staples" });
Every call typed, the base URL in one place, and error handling in one place.
No fetch calls scattered through components — when the error shape or the
auth header changes, one file changes.
Sharing more than types
Constants belong here too:
// packages/shared/src/constants.ts
export const MAX_CART_QUANTITY = 50;
export const FREE_DELIVERY_THRESHOLD_PAISE = 50000;
export const DEFAULT_PAGE_SIZE = 20;
The front end shows "Add ₹150 more for free delivery" and the API decides whether delivery is free. Both must use the same number, or the interface promises something the server refuses.
Pure functions that both sides need:
export function formatPaise(paise: number): string {
return new Intl.NumberFormat("en-IN", {
style: "currency",
currency: "INR",
}).format(paise / 100);
}
Keep it pure. No database access, no browser APIs, no Node built-ins —
packages/shared runs in both environments, and an import of fs breaks the
browser build.
Check your work
What shared types prevent: the two halves silently disagreeing about a field name or shape.
Why not share Prisma types: they leak internal fields, couple the API to the schema, and are the wrong shape for a client.
What a mapper buys you: adding a database column does not change your API until you decide it should.
Why const x: T = await response.json() is not a guarantee: json() returns
any, so the annotation is a claim TypeScript cannot verify.
When runtime validation is worth it: when the data comes from something you do not deploy with — a webhook, a third-party API.
What z.infer gives you: a TypeScript type derived from a runtime schema,
so there is still only one definition.
Why a typed client module: the base URL, headers and error handling live in one place rather than in every component.
What must not go in packages/shared: anything environment-specific — it
runs in both Node and the browser.
Practice
- Define
ProductSummaryandProductDetailinpackages/shared. Import them in both halves. - Rename a field in the shared file. Confirm both halves fail to compile.
- Type an endpoint with a Prisma model and return it. Find an internal field in the response.
- Write the mapper and confirm the internal field can no longer appear.
- Return a shape that does not match the declared type by casting. Confirm TypeScript allows it and the front end breaks at runtime.
- Add a Zod schema for one response and parse it. Break the API's shape and confirm you now get a clear error at the boundary.
- Build the typed
apiclient and replace one directfetchwith it. - Put
FREE_DELIVERY_THRESHOLD_PAISEin shared. Use it in both halves, change it once, and confirm both follow. - Import
node:fsintopackages/sharedand watch the web build fail.
Next: fetching patterns, loading states and optimistic updates.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship