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
}