D
Dev SOPKnowledge Base
Search
← All topics

TypeScript Full-Stack Type Safety: Shared Types, API Contracts, and Zod

Full-stack TypeScript type safety patterns — sharing types between frontend and backend, API contract validation with Zod, database type generation, and safe error handling.

typescripttype-safetyzodapinextjsfull-stack
Agent trigger phrases: full-stack TypeScript · shared types · API type safety · Zod schema · database types · TypeScript API contract · type-safe fetch

Overview

End-to-end type safety from database schema to React component. When types are shared and validated at every boundary, runtime type errors become compile-time errors.

The Type Safety Stack

Database schema (Supabase/Drizzle/Prisma)
    ↓ generates
Database types (auto-generated)
    ↓ used in
API route handlers (server)
    ↓ validated by
Zod schemas (boundary)
    ↓ inferred as
TypeScript types
    ↓ consumed by
React components (client)

Supabase — Generate Types from Schema

npx supabase gen types typescript --project-id <ref> > src/types/database.ts
// src/types/database.ts (auto-generated — never edit manually)
export interface Database {
  public: {
    Tables: {
      posts: {
        Row: {
          id: string;
          title: string;
          content: string;
          author_id: string;
          published: boolean;
          created_at: string;
        };
        Insert: Omit<Database["public"]["Tables"]["posts"]["Row"], "id" | "created_at">;
        Update: Partial<Database["public"]["Tables"]["posts"]["Insert"]>;
      };
    };
  };
}

// Extract convenience types
type Post = Database["public"]["Tables"]["posts"]["Row"];
type PostInsert = Database["public"]["Tables"]["posts"]["Insert"];

Shared API Types (Frontend + Backend)

// src/types/api.ts — shared between client and server
import { z } from "zod";

// Zod schema defines both runtime validation AND TypeScript type
export const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  tags: z.array(z.string()).max(10),
  published: z.boolean().default(false),
});

export type CreatePostInput = z.infer<typeof CreatePostSchema>;

export const PostSchema = z.object({
  id: z.string().uuid(),
  title: z.string(),
  content: z.string(),
  tags: z.array(z.string()),
  published: z.boolean(),
  createdAt: z.string().datetime(),
  author: z.object({
    id: z.string(),
    name: z.string(),
    avatar: z.string().url().nullable(),
  }),
});

export type Post = z.infer<typeof PostSchema>;

// Paginated response shape
export const PaginatedSchema = <T extends z.ZodType>(itemSchema: T) =>
  z.object({
    items: z.array(itemSchema),
    total: z.number(),
    page: z.number(),
    perPage: z.number(),
    hasMore: z.boolean(),
  });

export type PaginatedPosts = z.infer<ReturnType<typeof PaginatedSchema<typeof PostSchema>>>;

API Route Handler (Server)

// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { CreatePostSchema, PostSchema } from "@/types/api";
import { createServerClient } from "@/lib/supabase";

export async function POST(req: NextRequest) {
  const body = await req.json();
  const parsed = CreatePostSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      { error: "Validation failed", details: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const supabase = createServerClient();
  const { data, error } = await supabase
    .from("posts")
    .insert(parsed.data)
    .select()
    .single();

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }

  return NextResponse.json(data, { status: 201 });
}

Type-Safe Fetch Client

// src/lib/api-client.ts
import { z } from "zod";

export class ApiError extends Error {
  constructor(
    public status: number,
    message: string,
    public details?: unknown
  ) {
    super(message);
  }
}

export async function apiCall<T>(
  url: string,
  schema: z.ZodType<T>,
  options?: RequestInit
): Promise<T> {
  const res = await fetch(url, {
    headers: { "Content-Type": "application/json" },
    ...options,
  });

  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new ApiError(res.status, body.error ?? "Request failed", body.details);
  }

  const json = await res.json();
  return schema.parse(json);  // Runtime validation of response
}

// Usage — fully typed, validated at runtime
const post = await apiCall(`/api/posts/${id}`, PostSchema);
// post is typed as Post — no manual casting

React Query Integration

// src/hooks/usePosts.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiCall } from "@/lib/api-client";
import { PostSchema, CreatePostInput, PaginatedSchema } from "@/types/api";
import { z } from "zod";

const PaginatedPostsSchema = PaginatedSchema(PostSchema);

export function usePosts(page = 1) {
  return useQuery({
    queryKey: ["posts", page],
    queryFn: () => apiCall(`/api/posts?page=${page}`, PaginatedPostsSchema),
  });
}

export function useCreatePost() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (input: CreatePostInput) =>
      apiCall("/api/posts", PostSchema, {
        method: "POST",
        body: JSON.stringify(input),
      }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
    },
  });
}

Discriminated Union for API Results

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string; status: number };

async function safeApiCall<T>(
  url: string,
  schema: z.ZodType<T>
): Promise<ApiResult<T>> {
  try {
    const data = await apiCall(url, schema);
    return { ok: true, data };
  } catch (err) {
    if (err instanceof ApiError) {
      return { ok: false, error: err.message, status: err.status };
    }
    return { ok: false, error: "Unknown error", status: 500 };
  }
}

// Caller never needs to try/catch
const result = await safeApiCall(`/api/posts/${id}`, PostSchema);
if (result.ok) {
  console.log(result.data.title); // data typed as Post
} else {
  console.error(result.error);    // error typed as string
}