Back to Blog
TypeScript8 min read

End-to-End Type Safety: From Database to UI

Albert Watbin
Aug 2024

Featured Image

The Type Safety Gap

You write TypeScript on the frontend. Maybe on the backend too. But between them? A dark void where runtime errors hide and any refactoring becomes a game of "I hope nothing breaks."

The core problem: your API returns data that doesn't match what your components expect. TypeScript can't help because it has no idea what your API actually returns at runtime. You end up with type assertions, `any` casts, and a false sense of safety.

Closing the Gap

End-to-end type safety means your types travel from the database schema all the way to your UI components. When you change a column name in your database, TypeScript immediately tells you exactly which API routes, service functions, and React components will break.

There are three proven approaches, each with different tradeoffs:

Approach 1: Shared Types Package

Create a types package that both your frontend and backend import. This is the simplest approach and works exceptionally well in monorepo setups.

typescript
// packages/shared-types/src/product.ts
export interface Product {
  id: string;
  name: string;
  price: number;
  currency: "USD" | "EUR" | "GBP";
  status: "draft" | "active" | "archived";
  createdAt: string; // ISO 8601
  updatedAt: string;
}

export interface CreateProductInput {
  name: string;
  price: number;
  currency: Product["currency"];
}

export type ProductListResponse = {
  data: Product[];
  pagination: {
    page: number;
    totalPages: number;
    totalItems: number;
  };
};

Simple, explicit, no magic. Both sides import from the same source of truth. The tradeoff: you have to keep it updated manually when schemas change.

Approach 2: Code Generation from OpenAPI

Write your OpenAPI specification, then generate typed client code automatically. Your frontend gets types that exactly match your API, and changes to the API spec automatically update the generated types.

bash
# Generate a fully typed API client from your OpenAPI spec
npx openapi-typescript-codegen \
  --input ./api-spec.yaml \
  --output ./src/generated/api \
  --client fetch

This approach shines in organizations where backend and frontend teams are separate and the API spec serves as the formal contract between them.

Approach 3: tRPC for Maximum Safety

tRPC eliminates the API layer as a source of type errors entirely. Your frontend imports types directly from your backend router — no code generation, no manual syncing, no drift.

typescript
// server/routers/product.ts
export const productRouter = router({
  list: publicProcedure
    .input(z.object({
      page: z.number().default(1),
      limit: z.number().default(20),
    }))
    .query(async ({ input }) => {
      const products = await db.product.findMany({
        skip: (input.page - 1) * input.limit,
        take: input.limit,
      });
      return products; // Return type is inferred automatically
    }),
});

// client component — fully typed, zero configuration
const ProductList = () => {
  const { data, isLoading } = trpc.product.list.useQuery({
    page: 1,
    limit: 20,
  });
  // data is fully typed as Product[] — no assertions needed
};

What You Actually Gain

On a recent marketplace project, implementing end-to-end type safety produced measurable improvements that compounded over time:

  • 67% reduction in API integration bugs — the most common category of production errors
  • ~3 hours/week saved from "what does this API return?" Slack conversations
  • 40% less frontend error handling code — most error states became structurally impossible
  • Refactoring confidence: major schema changes completed safely in hours instead of weeks

The biggest win wasn't fewer bugs — it was confidence. Refactoring became safe. Breaking changes surfaced instantly at build time. Developers stopped being afraid to improve things because the type system had their back.

Beyond API Types: The Full Chain

True end-to-end type safety extends beyond just API responses. The full chain connects your database schema to your form validation to your API responses to your UI components:

typescript
// 1. Database schema defines the source of truth
// Prisma generates types from your schema automatically

// 2. Zod schemas for runtime validation + type inference
const ProductSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(200),
  price: z.number().positive(),
  status: z.enum(["draft", "active", "archived"]),
});

// 3. Infer TypeScript types from Zod — single source of truth
type Product = z.infer<typeof ProductSchema>;

// 4. Form schemas derived from the same base
const CreateProductForm = ProductSchema.omit({ id: true });
type CreateProductInput = z.infer<typeof CreateProductForm>;

// 5. API response wrapper
type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: { code: string; message: string } };

The Investment Pays Off

Setting up end-to-end type safety requires upfront investment in tooling and team education. But every team I've worked with that adopted it says the same thing: they would never go back.

The cost of finding and fixing type mismatches in production — debugging unclear errors, coordinating between teams, deploying hotfixes — vastly exceeds the cost of setting up proper typing from the start. Your future self, and every teammate who inherits your code, will thank you.

Topics
#TypeScript#API Design#Developer Experience#Best Practices

Want to discuss scalable systems?

I'm always open to discussing software architecture, platform engineering, or potential collaborations.

Let's Talk
Software Engineer | Full Stack Developer | Scalable Web Platforms