Skip to content

A comprehensive guide to refactoring TypeScript codebases from manual type definitions to auto-generated, type-safe schemas

License

Notifications You must be signed in to change notification settings

PaulyBearCoding/typescript-refactoring-playbook

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

TypeScript Refactoring Playbook

A comprehensive guide to refactoring TypeScript codebases from manual type definitions to auto-generated, type-safe schemas using modern tooling.

Table of Contents

  1. Overview
  2. The Problem
  3. The Solution
  4. Strategic Approach
  5. Tactical Fixes
  6. Implementation Phases
  7. Tools and Patterns
  8. Best Practices

Overview

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.

The Problem

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'))
)

The Solution

Don't fight the errors—eliminate them at the source.

Instead of fixing type errors one by one, restructure your codebase to:

  1. Generate types from your database schema (single source of truth)
  2. Use type-safe query builders (Prisma, Kysely, Drizzle)
  3. Validate at runtime (Zod schemas derived from generated types)
  4. Migrate incrementally (using the Strangler Fig pattern)

Strategic Approach

Phase 1: Establish Source of Truth

Goal: Make your database schema the single source of truth for types.

For Supabase Projects:

# Generate types from your live database
npx supabase gen types typescript --project-id your-project-id > types/database.generated.ts

For Prisma Projects:

// 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 generate

Phase 2: Create Migration Layer

Goal: Allow old code and new code to coexist during migration.

Pattern: Type Adapters

// 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
  }
}

Pattern: Dual Exports

// 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)

Phase 3: Incremental Migration (Strangler Fig Pattern)

Goal: Migrate one module/feature at a time without breaking existing code.

Migration Order:

  1. Start with leaf nodes (components with no dependencies)
  2. Move to data access layer (API routes, server actions)
  3. Update UI components (pages, components)
  4. Remove legacy code (adapters, old types)

Example: Migrating a Feature

// ❌ 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
}

Phase 4: Runtime Validation with Zod

Goal: Ensure type safety at runtime, not just compile time.

Using supazod (Supabase + Zod):

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
}

Manual Zod Schemas:

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>

Tactical Fixes

While the strategic approach is the long-term solution, you may need quick tactical fixes to unblock development.

Fix 1: Exclude Test Files from Build

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.tsxapp/page.page.tsx
  • app/api/users/route.tsapp/api/users/route.api.ts

Fix 2: Fix Schema Mismatches

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']

Fix 3: Fix Enum Mismatches

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'

Fix 4: YouTube API Declaration Conflict

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 module

Implementation Phases

Week 1: Foundation

Tasks:

  1. Set up type generation from database
  2. Create types/database.generated.ts
  3. Add generation to package.json scripts
  4. 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"
  }
}

Week 2-3: Migration Layer

Tasks:

  1. Create adapter functions for critical types
  2. Set up dual exports (legacy + new clients)
  3. Choose migration targets (start with 2-3 files)
  4. 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 code

Week 4-8: Incremental Migration

Tasks:

  1. Migrate one module per week
  2. Update tests as you go
  3. Remove adapters when no longer needed
  4. Monitor TypeScript error count

Metrics:

# Track progress
npm run typecheck 2>&1 | grep "error TS" | wc -l

# Goal: Reduce by 20% each week

Week 9: Cleanup

Tasks:

  1. Remove all legacy type definitions
  2. Remove adapter functions
  3. Update documentation
  4. Add pre-commit hooks

Pre-commit Hook:

// package.json
{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "tsc --noEmit"
    ]
  }
}

Tools and Patterns

Type Generation Tools

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

Query Builders

Prisma Example:

const friendships = await prisma.friendship.findMany({
  where: {
    OR: [
      { userId1: currentUserId },
      { userId2: currentUserId }
    ]
  },
  include: {
    user1: true,
    user2: true
  }
})

Kysely Example:

const friendships = await db
  .selectFrom('friendships')
  .where((eb) => eb.or([
    eb('user_id_1', '=', currentUserId),
    eb('user_id_2', '=', currentUserId)
  ]))
  .selectAll()
  .execute()

Runtime Validation

supazod (Supabase + Zod):

import { zodSchemaFromSupabaseTable } from 'supazod'

const ProfileSchema = zodSchemaFromSupabaseTable<
  Database['public']['Tables']['profiles']['Row']
>()

Manual Zod:

const ProfileSchema = z.object({
  id: z.string().uuid(),
  username: z.string().min(3).max(20),
  email: z.string().email(),
  created_at: z.string().datetime()
})

Best Practices

1. Always Generate from Schema

# After every schema change:
npx supabase db push
npm run db:generate-types

2. Use Type Helpers

// 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']

3. Validate External Data

// ✅ 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[]

4. Use Branded Types for IDs

type UserId = string & { __brand: 'UserId' }
type FriendshipId = string & { __brand: 'FriendshipId' }

function getFriendship(id: FriendshipId) {
  // Can't accidentally pass UserId here
}

5. Document Your Schema

/**
 * 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']

Success Metrics

Track these metrics to measure migration success:

  1. TypeScript Error Count: Should decrease week over week
  2. Type Coverage: Aim for 95%+ coverage
  3. Runtime Errors: Should decrease as validation improves
  4. Development Velocity: Should increase after initial migration
  5. Schema Drift Incidents: Should approach zero

Common Pitfalls

Pitfall 1: Trying to Fix Everything at Once

Problem: Attempting to fix all TypeScript errors simultaneously.

Solution: Use the Strangler Fig pattern—migrate incrementally.

Pitfall 2: Not Validating at Runtime

Problem: Trusting TypeScript types at runtime.

Solution: Use Zod or similar for runtime validation.

Pitfall 3: Ignoring Database Constraints

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'))

Pitfall 4: Not Keeping Types in Sync

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

Resources

Documentation

Tools

  • supazod - Zod schemas from Supabase types
  • Prisma - Type-safe ORM
  • Kysely - Type-safe query builder
  • Drizzle - Modern TypeScript ORM

Articles

Contributing

Contributions welcome! Please:

  1. Follow the existing structure
  2. Add examples for new patterns
  3. Include before/after code samples
  4. Update the table of contents

License

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.

About

A comprehensive guide to refactoring TypeScript codebases from manual type definitions to auto-generated, type-safe schemas

Resources

License

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published