{"slug":"typescript-full-stack-safety","title":"TypeScript Full-Stack Type Safety: Shared Types, API Contracts, and Zod","tags":["typescript","type-safety","zod","api","nextjs","full-stack"],"agent_summary":"Full-stack TypeScript type safety patterns — sharing types between frontend and backend, API contract validation with Zod, database type generation, and safe error handling.","trigger_phrases":["full-stack TypeScript","shared types","API type safety","Zod schema","database types","TypeScript API contract","type-safe fetch"],"runnable":false,"markdown":"\n## Overview\n\nEnd-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.\n\n## The Type Safety Stack\n\n```\nDatabase schema (Supabase/Drizzle/Prisma)\n    ↓ generates\nDatabase types (auto-generated)\n    ↓ used in\nAPI route handlers (server)\n    ↓ validated by\nZod schemas (boundary)\n    ↓ inferred as\nTypeScript types\n    ↓ consumed by\nReact components (client)\n```\n\n## Supabase — Generate Types from Schema\n\n```bash\nnpx supabase gen types typescript --project-id <ref> > src/types/database.ts\n```\n\n```typescript\n// src/types/database.ts (auto-generated — never edit manually)\nexport interface Database {\n  public: {\n    Tables: {\n      posts: {\n        Row: {\n          id: string;\n          title: string;\n          content: string;\n          author_id: string;\n          published: boolean;\n          created_at: string;\n        };\n        Insert: Omit<Database[\"public\"][\"Tables\"][\"posts\"][\"Row\"], \"id\" | \"created_at\">;\n        Update: Partial<Database[\"public\"][\"Tables\"][\"posts\"][\"Insert\"]>;\n      };\n    };\n  };\n}\n\n// Extract convenience types\ntype Post = Database[\"public\"][\"Tables\"][\"posts\"][\"Row\"];\ntype PostInsert = Database[\"public\"][\"Tables\"][\"posts\"][\"Insert\"];\n```\n\n## Shared API Types (Frontend + Backend)\n\n```typescript\n// src/types/api.ts — shared between client and server\nimport { z } from \"zod\";\n\n// Zod schema defines both runtime validation AND TypeScript type\nexport const CreatePostSchema = z.object({\n  title: z.string().min(1).max(200),\n  content: z.string().min(10),\n  tags: z.array(z.string()).max(10),\n  published: z.boolean().default(false),\n});\n\nexport type CreatePostInput = z.infer<typeof CreatePostSchema>;\n\nexport const PostSchema = z.object({\n  id: z.string().uuid(),\n  title: z.string(),\n  content: z.string(),\n  tags: z.array(z.string()),\n  published: z.boolean(),\n  createdAt: z.string().datetime(),\n  author: z.object({\n    id: z.string(),\n    name: z.string(),\n    avatar: z.string().url().nullable(),\n  }),\n});\n\nexport type Post = z.infer<typeof PostSchema>;\n\n// Paginated response shape\nexport const PaginatedSchema = <T extends z.ZodType>(itemSchema: T) =>\n  z.object({\n    items: z.array(itemSchema),\n    total: z.number(),\n    page: z.number(),\n    perPage: z.number(),\n    hasMore: z.boolean(),\n  });\n\nexport type PaginatedPosts = z.infer<ReturnType<typeof PaginatedSchema<typeof PostSchema>>>;\n```\n\n## API Route Handler (Server)\n\n```typescript\n// app/api/posts/route.ts\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { CreatePostSchema, PostSchema } from \"@/types/api\";\nimport { createServerClient } from \"@/lib/supabase\";\n\nexport async function POST(req: NextRequest) {\n  const body = await req.json();\n  const parsed = CreatePostSchema.safeParse(body);\n\n  if (!parsed.success) {\n    return NextResponse.json(\n      { error: \"Validation failed\", details: parsed.error.flatten() },\n      { status: 400 }\n    );\n  }\n\n  const supabase = createServerClient();\n  const { data, error } = await supabase\n    .from(\"posts\")\n    .insert(parsed.data)\n    .select()\n    .single();\n\n  if (error) {\n    return NextResponse.json({ error: error.message }, { status: 500 });\n  }\n\n  return NextResponse.json(data, { status: 201 });\n}\n```\n\n## Type-Safe Fetch Client\n\n```typescript\n// src/lib/api-client.ts\nimport { z } from \"zod\";\n\nexport class ApiError extends Error {\n  constructor(\n    public status: number,\n    message: string,\n    public details?: unknown\n  ) {\n    super(message);\n  }\n}\n\nexport async function apiCall<T>(\n  url: string,\n  schema: z.ZodType<T>,\n  options?: RequestInit\n): Promise<T> {\n  const res = await fetch(url, {\n    headers: { \"Content-Type\": \"application/json\" },\n    ...options,\n  });\n\n  if (!res.ok) {\n    const body = await res.json().catch(() => ({}));\n    throw new ApiError(res.status, body.error ?? \"Request failed\", body.details);\n  }\n\n  const json = await res.json();\n  return schema.parse(json);  // Runtime validation of response\n}\n\n// Usage — fully typed, validated at runtime\nconst post = await apiCall(`/api/posts/${id}`, PostSchema);\n// post is typed as Post — no manual casting\n```\n\n## React Query Integration\n\n```typescript\n// src/hooks/usePosts.ts\nimport { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { apiCall } from \"@/lib/api-client\";\nimport { PostSchema, CreatePostInput, PaginatedSchema } from \"@/types/api\";\nimport { z } from \"zod\";\n\nconst PaginatedPostsSchema = PaginatedSchema(PostSchema);\n\nexport function usePosts(page = 1) {\n  return useQuery({\n    queryKey: [\"posts\", page],\n    queryFn: () => apiCall(`/api/posts?page=${page}`, PaginatedPostsSchema),\n  });\n}\n\nexport function useCreatePost() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: (input: CreatePostInput) =>\n      apiCall(\"/api/posts\", PostSchema, {\n        method: \"POST\",\n        body: JSON.stringify(input),\n      }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"posts\"] });\n    },\n  });\n}\n```\n\n## Discriminated Union for API Results\n\n```typescript\ntype ApiResult<T> =\n  | { ok: true; data: T }\n  | { ok: false; error: string; status: number };\n\nasync function safeApiCall<T>(\n  url: string,\n  schema: z.ZodType<T>\n): Promise<ApiResult<T>> {\n  try {\n    const data = await apiCall(url, schema);\n    return { ok: true, data };\n  } catch (err) {\n    if (err instanceof ApiError) {\n      return { ok: false, error: err.message, status: err.status };\n    }\n    return { ok: false, error: \"Unknown error\", status: 500 };\n  }\n}\n\n// Caller never needs to try/catch\nconst result = await safeApiCall(`/api/posts/${id}`, PostSchema);\nif (result.ok) {\n  console.log(result.data.title); // data typed as Post\n} else {\n  console.error(result.error);    // error typed as string\n}\n```\n","html":"<h2>Overview</h2>\n<p>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.</p>\n<h2>The Type Safety Stack</h2>\n<pre><code>Database schema (Supabase/Drizzle/Prisma)\n    ↓ generates\nDatabase types (auto-generated)\n    ↓ used in\nAPI route handlers (server)\n    ↓ validated by\nZod schemas (boundary)\n    ↓ inferred as\nTypeScript types\n    ↓ consumed by\nReact components (client)\n</code></pre>\n<h2>Supabase — Generate Types from Schema</h2>\n<pre><code class=\"language-bash\">npx supabase gen types typescript --project-id &#x3C;ref> > src/types/database.ts\n</code></pre>\n<pre><code class=\"language-typescript\">// src/types/database.ts (auto-generated — never edit manually)\nexport interface Database {\n  public: {\n    Tables: {\n      posts: {\n        Row: {\n          id: string;\n          title: string;\n          content: string;\n          author_id: string;\n          published: boolean;\n          created_at: string;\n        };\n        Insert: Omit&#x3C;Database[\"public\"][\"Tables\"][\"posts\"][\"Row\"], \"id\" | \"created_at\">;\n        Update: Partial&#x3C;Database[\"public\"][\"Tables\"][\"posts\"][\"Insert\"]>;\n      };\n    };\n  };\n}\n\n// Extract convenience types\ntype Post = Database[\"public\"][\"Tables\"][\"posts\"][\"Row\"];\ntype PostInsert = Database[\"public\"][\"Tables\"][\"posts\"][\"Insert\"];\n</code></pre>\n<h2>Shared API Types (Frontend + Backend)</h2>\n<pre><code class=\"language-typescript\">// src/types/api.ts — shared between client and server\nimport { z } from \"zod\";\n\n// Zod schema defines both runtime validation AND TypeScript type\nexport const CreatePostSchema = z.object({\n  title: z.string().min(1).max(200),\n  content: z.string().min(10),\n  tags: z.array(z.string()).max(10),\n  published: z.boolean().default(false),\n});\n\nexport type CreatePostInput = z.infer&#x3C;typeof CreatePostSchema>;\n\nexport const PostSchema = z.object({\n  id: z.string().uuid(),\n  title: z.string(),\n  content: z.string(),\n  tags: z.array(z.string()),\n  published: z.boolean(),\n  createdAt: z.string().datetime(),\n  author: z.object({\n    id: z.string(),\n    name: z.string(),\n    avatar: z.string().url().nullable(),\n  }),\n});\n\nexport type Post = z.infer&#x3C;typeof PostSchema>;\n\n// Paginated response shape\nexport const PaginatedSchema = &#x3C;T extends z.ZodType>(itemSchema: T) =>\n  z.object({\n    items: z.array(itemSchema),\n    total: z.number(),\n    page: z.number(),\n    perPage: z.number(),\n    hasMore: z.boolean(),\n  });\n\nexport type PaginatedPosts = z.infer&#x3C;ReturnType&#x3C;typeof PaginatedSchema&#x3C;typeof PostSchema>>>;\n</code></pre>\n<h2>API Route Handler (Server)</h2>\n<pre><code class=\"language-typescript\">// app/api/posts/route.ts\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { CreatePostSchema, PostSchema } from \"@/types/api\";\nimport { createServerClient } from \"@/lib/supabase\";\n\nexport async function POST(req: NextRequest) {\n  const body = await req.json();\n  const parsed = CreatePostSchema.safeParse(body);\n\n  if (!parsed.success) {\n    return NextResponse.json(\n      { error: \"Validation failed\", details: parsed.error.flatten() },\n      { status: 400 }\n    );\n  }\n\n  const supabase = createServerClient();\n  const { data, error } = await supabase\n    .from(\"posts\")\n    .insert(parsed.data)\n    .select()\n    .single();\n\n  if (error) {\n    return NextResponse.json({ error: error.message }, { status: 500 });\n  }\n\n  return NextResponse.json(data, { status: 201 });\n}\n</code></pre>\n<h2>Type-Safe Fetch Client</h2>\n<pre><code class=\"language-typescript\">// src/lib/api-client.ts\nimport { z } from \"zod\";\n\nexport class ApiError extends Error {\n  constructor(\n    public status: number,\n    message: string,\n    public details?: unknown\n  ) {\n    super(message);\n  }\n}\n\nexport async function apiCall&#x3C;T>(\n  url: string,\n  schema: z.ZodType&#x3C;T>,\n  options?: RequestInit\n): Promise&#x3C;T> {\n  const res = await fetch(url, {\n    headers: { \"Content-Type\": \"application/json\" },\n    ...options,\n  });\n\n  if (!res.ok) {\n    const body = await res.json().catch(() => ({}));\n    throw new ApiError(res.status, body.error ?? \"Request failed\", body.details);\n  }\n\n  const json = await res.json();\n  return schema.parse(json);  // Runtime validation of response\n}\n\n// Usage — fully typed, validated at runtime\nconst post = await apiCall(`/api/posts/${id}`, PostSchema);\n// post is typed as Post — no manual casting\n</code></pre>\n<h2>React Query Integration</h2>\n<pre><code class=\"language-typescript\">// src/hooks/usePosts.ts\nimport { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { apiCall } from \"@/lib/api-client\";\nimport { PostSchema, CreatePostInput, PaginatedSchema } from \"@/types/api\";\nimport { z } from \"zod\";\n\nconst PaginatedPostsSchema = PaginatedSchema(PostSchema);\n\nexport function usePosts(page = 1) {\n  return useQuery({\n    queryKey: [\"posts\", page],\n    queryFn: () => apiCall(`/api/posts?page=${page}`, PaginatedPostsSchema),\n  });\n}\n\nexport function useCreatePost() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: (input: CreatePostInput) =>\n      apiCall(\"/api/posts\", PostSchema, {\n        method: \"POST\",\n        body: JSON.stringify(input),\n      }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"posts\"] });\n    },\n  });\n}\n</code></pre>\n<h2>Discriminated Union for API Results</h2>\n<pre><code class=\"language-typescript\">type ApiResult&#x3C;T> =\n  | { ok: true; data: T }\n  | { ok: false; error: string; status: number };\n\nasync function safeApiCall&#x3C;T>(\n  url: string,\n  schema: z.ZodType&#x3C;T>\n): Promise&#x3C;ApiResult&#x3C;T>> {\n  try {\n    const data = await apiCall(url, schema);\n    return { ok: true, data };\n  } catch (err) {\n    if (err instanceof ApiError) {\n      return { ok: false, error: err.message, status: err.status };\n    }\n    return { ok: false, error: \"Unknown error\", status: 500 };\n  }\n}\n\n// Caller never needs to try/catch\nconst result = await safeApiCall(`/api/posts/${id}`, PostSchema);\nif (result.ok) {\n  console.log(result.data.title); // data typed as Post\n} else {\n  console.error(result.error);    // error typed as string\n}\n</code></pre>\n"}