A comprehensive guide to refactoring TypeScript codebases from manual type definitions to auto-generated, type-safe schemas using modern tooling.
- Overview
- The Problem
- The Solution
- Strategic Approach
- Tactical Fixes
- Implementation Phases
- Tools and Patterns
- Best Practices
This playbook provides a battle-tested approach to migrating TypeScript codebases from manually maintained type definitions to automatically generated, database-driven schemas. It combines strategic architectural improvements with tactical error fixes to create a sustainable, type-safe codebase.
Many TypeScript projects suffer from "schema drift" where:
- Manual type definitions don't match the actual database schema
- Changes to the database aren't reflected in TypeScript types
- Developers spend time maintaining duplicate definitions
- Runtime errors occur due to type mismatches
- Testing becomes unreliable due to type inconsistencies
Example of Schema Drift:
// Manual type definition (what developers think exists)
type Friendship = {
id: string
user_id: string // Wrong!
friend_id: string // Wrong!
status: string
}
// Actual database schema
CREATE TABLE friendships (
id UUID,
user_id_1 UUID, // Actual column name
user_id_2 UUID, // Actual column name
status TEXT CHECK (status IN ('pending', 'accepted', 'blocked'))
)Don't fight the errors—eliminate them at the source.
Instead of fixing type errors one by one, restructure your codebase to:
- Generate types from your database schema (single source of truth)
- Use type-safe query builders (Prisma, Kysely, Drizzle)
- Validate at runtime (Zod schemas derived from generated types)
- Migrate incrementally (using the Strangler Fig pattern)
Goal: Make your database schema the single source of truth for types.
# Generate types from your live database
npx supabase gen types typescript --project-id your-project-id > types/database.generated.ts// schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Friendship {
id String @id @default(uuid())
userId1 String @map("user_id_1")
userId2 String @map("user_id_2")
status String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("friendships")
}npx prisma generateGoal: Allow old code and new code to coexist during migration.
// types/adapters.ts
import { Database } from './database.generated'
// Generated type from database
type FriendshipRow = Database['public']['Tables']['friendships']['Row']
// Legacy type your code expects
type LegacyFriendship = {
id: string
user_id: string
friend_id: string
status: string
}
// Adapter function
export function adaptFriendship(row: FriendshipRow): LegacyFriendship {
return {
id: row.id,
user_id: row.user_id_1,
friend_id: row.user_id_2,
status: row.status
}
}// lib/database.ts
import { createClient } from '@supabase/supabase-js'
import { Database } from '@/types/database.generated'
// New type-safe client (for new code)
export const supabase = createClient<Database>(url, key)
// Legacy client (for old code during migration)
export const supabaseLegacy = createClient(url, key)Goal: Migrate one module/feature at a time without breaking existing code.
- Start with leaf nodes (components with no dependencies)
- Move to data access layer (API routes, server actions)
- Update UI components (pages, components)
- Remove legacy code (adapters, old types)
// ❌ OLD CODE (before migration)
async function getFriendships(userId: string) {
const { data } = await supabaseLegacy
.from('friendships')
.select('*')
.eq('user_id', userId) // Wrong column!
return data
}
// ✅ NEW CODE (after migration)
async function getFriendships(userId: string) {
const { data } = await supabase
.from('friendships')
.select('*')
.or(`user_id_1.eq.${userId},user_id_2.eq.${userId}`)
// TypeScript knows the exact shape of 'data'
return data // Type: FriendshipRow[] | null
}Goal: Ensure type safety at runtime, not just compile time.
import { zodSchemaFromSupabaseTable } from 'supazod'
import { Database } from './database.generated'
// Auto-generate Zod schema from Supabase types
const FriendshipSchema = zodSchemaFromSupabaseTable<
Database['public']['Tables']['friendships']['Row']
>()
// Validate API responses
async function createFriendship(data: unknown) {
const validated = FriendshipSchema.parse(data)
const { data: friendship } = await supabase
.from('friendships')
.insert(validated)
.select()
.single()
return friendship
}import { z } from 'zod'
const FriendshipStatusSchema = z.enum(['pending', 'accepted', 'blocked'])
const FriendshipSchema = z.object({
id: z.string().uuid(),
user_id_1: z.string().uuid(),
user_id_2: z.string().uuid(),
status: FriendshipStatusSchema,
created_at: z.string().datetime(),
updated_at: z.string().datetime()
})
// Use for form validation, API parsing, etc.
export type Friendship = z.infer<typeof FriendshipSchema>While the strategic approach is the long-term solution, you may need quick tactical fixes to unblock development.
Problem: Test configuration files causing build errors.
Solution:
// tsconfig.json
{
"exclude": [
"node_modules",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx",
"**/__tests__/**",
"deno.json",
"deno.lock"
]
}Or use Next.js pageExtensions:
// next.config.js
module.exports = {
pageExtensions: ['page.tsx', 'page.ts', 'api.ts'],
}Then rename files:
app/page.tsx→app/page.page.tsxapp/api/users/route.ts→app/api/users/route.api.ts
Problem: Manual types don't match database schema.
Solution:
// ❌ BEFORE (manual type)
type Friendship = {
user_id: string
friend_id: string
}
// ✅ AFTER (matches database)
type Friendship = {
user_id_1: string
user_id_2: string
}Better Solution: Use generated types instead:
import { Database } from '@/types/database.generated'
type Friendship = Database['public']['Tables']['friendships']['Row']Problem: Enums don't match database constraints.
Solution:
// ❌ BEFORE
type ReportReason = 'spam' | 'harassment' | 'inappropriate'
// Check your database:
// status TEXT CHECK (status IN ('spam', 'harassment', 'inappropriate_content', 'other'))
// ✅ AFTER
type ReportReason = 'spam' | 'harassment' | 'inappropriate_content' | 'other'Problem: Global type declaration conflicts.
Solution:
// types/youtube.d.ts
declare global {
interface Window {
YT: {
Player: new (elementId: string, config: YT.PlayerOptions) => YT.Player
PlayerState: {
ENDED: number
PLAYING: number
PAUSED: number
BUFFERING: number
CUED: number
}
}
onYouTubeIframeAPIReady: () => void
}
}
export {} // Make this a moduleTasks:
- Set up type generation from database
- Create types/database.generated.ts
- Add generation to package.json scripts
- Document the process in README
Commands:
{
"scripts": {
"db:generate-types": "supabase gen types typescript --project-id your-id > types/database.generated.ts",
"db:push": "prisma db push && npm run db:generate-types"
}
}Tasks:
- Create adapter functions for critical types
- Set up dual exports (legacy + new clients)
- Choose migration targets (start with 2-3 files)
- Create migration checklist
Checklist Template:
## Migration Checklist
### Module: Friendships
- [ ] Generate types from database
- [ ] Create adapter function
- [ ] Migrate data access layer (lib/friendships.ts)
- [ ] Migrate API routes (app/api/friendships/*)
- [ ] Migrate UI components (components/friendships/*)
- [ ] Add runtime validation (Zod)
- [ ] Update tests
- [ ] Remove legacy codeTasks:
- Migrate one module per week
- Update tests as you go
- Remove adapters when no longer needed
- Monitor TypeScript error count
Metrics:
# Track progress
npm run typecheck 2>&1 | grep "error TS" | wc -l
# Goal: Reduce by 20% each weekTasks:
- Remove all legacy type definitions
- Remove adapter functions
- Update documentation
- Add pre-commit hooks
Pre-commit Hook:
// package.json
{
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"tsc --noEmit"
]
}
}| Tool | Use Case | Pros | Cons |
|---|---|---|---|
| Supabase CLI | Supabase projects | Official, accurate | Supabase-specific |
| Prisma | Any database | Great DX, migrations | Learning curve |
| Kysely | Type-safe queries | Lightweight | More manual setup |
| Drizzle | Modern ORM | Fast, type-safe | Newer, smaller ecosystem |
const friendships = await prisma.friendship.findMany({
where: {
OR: [
{ userId1: currentUserId },
{ userId2: currentUserId }
]
},
include: {
user1: true,
user2: true
}
})const friendships = await db
.selectFrom('friendships')
.where((eb) => eb.or([
eb('user_id_1', '=', currentUserId),
eb('user_id_2', '=', currentUserId)
]))
.selectAll()
.execute()import { zodSchemaFromSupabaseTable } from 'supazod'
const ProfileSchema = zodSchemaFromSupabaseTable<
Database['public']['Tables']['profiles']['Row']
>()const ProfileSchema = z.object({
id: z.string().uuid(),
username: z.string().min(3).max(20),
email: z.string().email(),
created_at: z.string().datetime()
})# After every schema change:
npx supabase db push
npm run db:generate-types// types/helpers.ts
import { Database } from './database.generated'
// Extract table types
export type Tables = Database['public']['Tables']
export type Friendship = Tables['friendships']['Row']
export type FriendshipInsert = Tables['friendships']['Insert']
export type FriendshipUpdate = Tables['friendships']['Update']
// Extract enum types
export type FriendshipStatus = Friendship['status']// ✅ GOOD: Validate API responses
const response = await fetch('/api/friendships')
const data = await response.json()
const friendships = z.array(FriendshipSchema).parse(data)
// ❌ BAD: Trust external data
const friendships = await response.json() as Friendship[]type UserId = string & { __brand: 'UserId' }
type FriendshipId = string & { __brand: 'FriendshipId' }
function getFriendship(id: FriendshipId) {
// Can't accidentally pass UserId here
}/**
* Represents a friendship relationship between two users.
*
* Database: friendships table
* RLS: Users can only view friendships they're part of
*
* @see {@link https://your-docs.com/friendships}
*/
export type Friendship = Database['public']['Tables']['friendships']['Row']Track these metrics to measure migration success:
- TypeScript Error Count: Should decrease week over week
- Type Coverage: Aim for 95%+ coverage
- Runtime Errors: Should decrease as validation improves
- Development Velocity: Should increase after initial migration
- Schema Drift Incidents: Should approach zero
Problem: Attempting to fix all TypeScript errors simultaneously.
Solution: Use the Strangler Fig pattern—migrate incrementally.
Problem: Trusting TypeScript types at runtime.
Solution: Use Zod or similar for runtime validation.
Problem: Types allow values that database rejects.
Solution: Mirror database constraints in Zod schemas.
const StatusSchema = z.enum(['pending', 'accepted', 'blocked'])
// Matches: CHECK (status IN ('pending', 'accepted', 'blocked'))Problem: Schema changes don't update TypeScript types.
Solution: Automate type generation in CI/CD.
# .github/workflows/types.yml
name: Update Types
on:
push:
paths:
- 'supabase/migrations/**'
jobs:
update-types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm run db:generate-types
- run: |
git config user.name "GitHub Actions"
git add types/database.generated.ts
git commit -m "chore: update generated types"
git push- supazod - Zod schemas from Supabase types
- Prisma - Type-safe ORM
- Kysely - Type-safe query builder
- Drizzle - Modern TypeScript ORM
Contributions welcome! Please:
- Follow the existing structure
- Add examples for new patterns
- Include before/after code samples
- Update the table of contents
MIT
Remember: The goal isn't perfect types—it's a sustainable, type-safe codebase that scales with your team and evolves with your database.