5 AI-Powered TypeScript Refactoring Workflows That Save Hours
Transform legacy code faster with these practical AI-assisted refactoring patterns. From callbacks to async/await, class components to hooks, and more.
While I was working on a legacy codebase last month, I realized something that changed how I approach refactoring entirely. What would have taken me three days of tedious, error-prone manual work was done in under four hours—with AI doing the heavy lifting while I reviewed and approved changes.
The secret wasn't just having TypeScript in the project. It was knowing exactly how to prompt AI tools like Claude Code and Cursor to handle specific refactoring patterns. In this post, I'll share 5 practical workflows I use regularly that consistently save hours of development time.
If you want to understand why TypeScript works so well with AI tools, check out Why TypeScript Works Better with AI Coding Tools. This post focuses on the how—the actual prompts and patterns that get results.
1. Callbacks to async/await
This is probably the most common refactoring task I encounter. Legacy codebases are full of nested callbacks that make code hard to read and debug. AI tools excel at this transformation because the pattern is mechanical but tedious.
The Problem
// Before: Callback hell
function fetchUserData(userId: string, callback: (err: Error | null, data?: UserData) => void) {
db.getUser(userId, (err, user) => {
if (err) return callback(err)
api.fetchPosts(user.id, (err, posts) => {
if (err) return callback(err)
api.fetchComments(posts[0].id, (err, comments) => {
if (err) return callback(err)
callback(null, { user, posts, comments })
})
})
})
}The AI Prompt
Here's what I tell Claude Code or Cursor:
"Refactor this callback-based function to use async/await. Preserve all error handling behavior. The function should return a Promise and throw on errors instead of using error-first callbacks."
The Result
// After: Clean async/await
async function fetchUserData(userId: string): Promise<UserData> {
const user = await db.getUser(userId)
const posts = await api.fetchPosts(user.id)
const comments = await api.fetchComments(posts[0].id)
return { user, posts, comments }
}The AI understands the error propagation pattern because TypeScript's types tell it that db.getUser returns a User and api.fetchPosts expects a user ID. For deeper patterns on async/await, see my post on async/await tips to be aware of at all times.

2. Class Components to Functional Hooks
React class components work fine, but functional components with hooks are the modern standard. The mapping from lifecycle methods to hooks is predictable, but tedious to do by hand—especially when you have dozens of components.
The Problem
// Before: Class component with lifecycle methods
class UserProfile extends React.Component<Props, State> {
state = {
user: null,
loading: true,
error: null,
}
componentDidMount() {
this.fetchUser()
}
componentDidUpdate(prevProps: Props) {
if (prevProps.userId !== this.props.userId) {
this.fetchUser()
}
}
componentWillUnmount() {
this.abortController?.abort()
}
fetchUser = async () => {
this.abortController = new AbortController()
try {
this.setState({ loading: true })
const user = await api.getUser(this.props.userId, {
signal: this.abortController.signal,
})
this.setState({ user, loading: false })
} catch (error) {
if (error.name !== 'AbortError') {
this.setState({ error, loading: false })
}
}
}
render() {
const { user, loading, error } = this.state
if (loading) return <Spinner />
if (error) return <ErrorMessage error={error} />
return <Profile user={user} />
}
}The AI Prompt
"Convert this React class component to a functional component with hooks. Map componentDidMount to useEffect, componentDidUpdate to useEffect with dependencies, and componentWillUnmount cleanup to useEffect's return function. Keep the same behavior for abort handling."
The Result
// After: Functional component with hooks
function UserProfile({ userId }: Props) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const abortController = new AbortController()
async function fetchUser() {
try {
setLoading(true)
const userData = await api.getUser(userId, {
signal: abortController.signal,
})
setUser(userData)
setLoading(false)
} catch (err) {
if (err.name !== 'AbortError') {
setError(err as Error)
setLoading(false)
}
}
}
fetchUser()
return () => abortController.abort()
}, [userId])
if (loading) return <Spinner />
if (error) return <ErrorMessage error={error} />
return <Profile user={user} />
}The AI correctly maps componentDidMount + componentDidUpdate to a single useEffect with [userId] as a dependency, and moves the cleanup into the effect's return function. I cannot stress this enough—the TypeScript types guide the AI to maintain type safety throughout the transformation.
3. any to unknown with Type Guards
This refactoring pattern is where AI really shines. The any type is a code smell that hides potential bugs. Converting to unknown with proper type guards is the right approach, but writing those guards manually is tedious.
The Problem
// Before: Dangerous 'any' type
async function handleApiResponse(response: any) {
if (response.success) {
return response.data.user.name
}
throw new Error(response.error.message)
}The AI Prompt
"Replace the 'any' type with 'unknown' and add runtime type guards. Generate a type predicate function that validates the response structure. Use the existing access patterns to infer the expected shape."
The Result
// After: Type-safe with runtime validation
interface ApiSuccessResponse {
success: true
data: {
user: {
name: string
}
}
}
interface ApiErrorResponse {
success: false
error: {
message: string
}
}
type ApiResponse = ApiSuccessResponse | ApiErrorResponse
function isApiSuccessResponse(response: unknown): response is ApiSuccessResponse {
return (
typeof response === 'object' &&
response !== null &&
'success' in response &&
response.success === true &&
'data' in response &&
typeof response.data === 'object' &&
response.data !== null &&
'user' in response.data &&
typeof response.data.user === 'object' &&
response.data.user !== null &&
'name' in response.data.user &&
typeof response.data.user.name === 'string'
)
}
function isApiErrorResponse(response: unknown): response is ApiErrorResponse {
return (
typeof response === 'object' &&
response !== null &&
'success' in response &&
response.success === false &&
'error' in response &&
typeof response.error === 'object' &&
response.error !== null &&
'message' in response.error &&
typeof response.error.message === 'string'
)
}
async function handleApiResponse(response: unknown): Promise<string> {
if (isApiSuccessResponse(response)) {
return response.data.user.name
}
if (isApiErrorResponse(response)) {
throw new Error(response.error.message)
}
throw new Error('Unknown response format')
}The AI generates exhaustive type guards based on how the code accesses the response. For even better runtime validation, combine this with Zod schemas as described in Build Type-Safe Form Validators in TypeScript with Zod. You can also leverage TypeScript generics to make these patterns reusable.
4. CommonJS to ESM Modules
Modern JavaScript tooling prefers ESM (ECMAScript Modules) over CommonJS. The tree-shaking benefits alone make this refactoring worthwhile. AI handles the syntax conversion and catches dynamic require patterns that need special attention.
The Problem
// Before: CommonJS
const express = require('express')
const { Router } = require('express')
const path = require('path')
const utils = require('./utils')
module.exports = {
createApp,
startServer,
}
module.exports.config = {
port: 3000,
}The AI Prompt
"Convert this file from CommonJS to ESM. Replace require() with import statements, module.exports with export statements. Flag any dynamic requires that need special handling."
The Result
// After: ESM
import express, { Router } from 'express'
import path from 'path'
import * as utils from './utils.js'
export { createApp, startServer }
export const config = {
port: 3000,
}The AI correctly handles default exports vs named exports, combines imports from the same module, and adds the .js extension for relative imports (required in ESM). If your file has dynamic require() calls, the AI will flag them:
// Dynamic require that needs manual attention
// const plugin = require(pluginPath)
// Consider: const plugin = await import(pluginPath)
5. God Components to Extracted Hooks
This is the most valuable refactoring pattern for long-term maintainability. "God components" that do everything are hard to test and modify. AI can identify logical groupings and extract them into custom hooks.
The Problem
// Before: 300-line component doing everything
function Dashboard() {
// User state
const [user, setUser] = useState<User | null>(null)
const [userLoading, setUserLoading] = useState(true)
// Notifications state
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
// Theme state
const [theme, setTheme] = useState<'light' | 'dark'>('light')
useEffect(() => {
fetchUser().then(setUser).finally(() => setUserLoading(false))
}, [])
useEffect(() => {
const interval = setInterval(async () => {
const notifs = await fetchNotifications()
setNotifications(notifs)
setUnreadCount(notifs.filter(n => !n.read).length)
}, 30000)
return () => clearInterval(interval)
}, [])
useEffect(() => {
document.body.className = theme
localStorage.setItem('theme', theme)
}, [theme])
const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light')
const markAsRead = async (id: string) => { /* ... */ }
// ... 200 more lines of render logic
}The AI Prompt
"Extract logical groups of state and effects into custom hooks. Create useUser for user fetching, useNotifications for notification polling, and useTheme for theme management. Each hook should be self-contained with its own state, effects, and handlers."
The Result
// After: Clean separation with custom hooks
function useUser() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchUser().then(setUser).finally(() => setLoading(false))
}, [])
return { user, loading }
}
function useNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
useEffect(() => {
const interval = setInterval(async () => {
const notifs = await fetchNotifications()
setNotifications(notifs)
setUnreadCount(notifs.filter(n => !n.read).length)
}, 30000)
return () => clearInterval(interval)
}, [])
const markAsRead = useCallback(async (id: string) => {
await api.markRead(id)
setNotifications(prev =>
prev.map(n => n.id === id ? { ...n, read: true } : n)
)
setUnreadCount(prev => prev - 1)
}, [])
return { notifications, unreadCount, markAsRead }
}
function useTheme() {
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
(localStorage.getItem('theme') as 'light' | 'dark') || 'light'
)
useEffect(() => {
document.body.className = theme
localStorage.setItem('theme', theme)
}, [theme])
const toggleTheme = useCallback(() => {
setTheme(t => t === 'light' ? 'dark' : 'light')
}, [])
return { theme, toggleTheme }
}
// Clean component
function Dashboard() {
const { user, loading } = useUser()
const { notifications, unreadCount, markAsRead } = useNotifications()
const { theme, toggleTheme } = useTheme()
if (loading) return <Spinner />
return (
<div className={theme}>
<Header user={user} onThemeToggle={toggleTheme} />
<NotificationPanel
notifications={notifications}
unreadCount={unreadCount}
onMarkRead={markAsRead}
/>
<MainContent user={user} />
</div>
)
}The AI identifies which state variables belong together based on how they're used in effects and handlers. Make no mistake about it—this kind of architectural refactoring would take significant time to do manually, but AI can propose the split in seconds.
Bonus: Batching Multiple useState with useImmer
You might have noticed something in the React examples above—we're still using multiple useState calls. In section 2, the refactored component has three separate state setters. In section 5, the useNotifications hook has two. This works, but there's a hidden performance issue.
Each setState call is a potential render cycle. When you call setLoading(true) followed by setUser(userData) followed by setLoading(false) in rapid succession, you're asking React to reconcile three times. React 18's automatic batching helps in synchronous code, but in async callbacks (like after await), each setter can still trigger its own render.
The AI Prompt
"Consolidate these multiple useState calls into a single useImmer state object. Batch related state updates to minimize renders."
The Result
// Before: Multiple useState = multiple potential renders
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
// Async callback triggers 3 separate updates
setLoading(true)
const userData = await api.getUser(userId)
setUser(userData)
setLoading(false)// After: Single useImmer = guaranteed single render per batch
import { useImmer } from 'use-immer'
const [state, updateState] = useImmer({
user: null as User | null,
loading: true,
error: null as Error | null,
})
// All updates batched into ONE render
updateState((draft) => {
draft.loading = true
})
const userData = await api.getUser(userId)
updateState((draft) => {
draft.user = userData
draft.loading = false
})The beauty of Immer is that you write code that looks like mutation (draft.loading = false), but it produces immutable updates behind the scenes. No spread operator pyramids, no render waterfalls.
For a deep dive into this pattern—including a custom ESLint rule that catches multiple useState calls automatically and teaches AI agents to use useImmer from the start—check out Teaching AI Agents to Batch React State Updates with ESLint.
Tips for Effective AI Refactoring
After using these workflows on dozens of projects, here are the patterns I've found work best:
Be specific about behavior preservation. Instead of "refactor this code," say "refactor this code while preserving the error handling behavior and maintaining the same public API."
Review the TypeScript errors first. AI tools get immediate feedback from TypeScript. If the refactored code has type errors, the AI will often catch and fix them before you even see them.
Refactor in small batches. Don't ask AI to refactor your entire codebase at once. Work file by file or function by function so you can review changes properly.
Trust but verify. AI is excellent at mechanical transformations but can miss subtle business logic. Always run your tests after refactoring.
Conclusion
And that concludes the end of this post! I hope you found this valuable and look out for more in the future!
These five workflows represent the refactoring patterns I use most frequently. The key insight is that TypeScript's type system gives AI tools the context they need to perform reliable transformations. The more type information in your codebase, the better your AI-assisted refactoring results will be.
Working smart is the way to go—let AI handle the tedious syntax transformations while you focus on the architectural decisions that matter.
Continue Learning:
- Teaching AI Agents to Batch React State Updates with ESLint
- Why TypeScript Works Better with AI Coding Tools
- 10 TypeScript Utility Types That Will Make Your Code Bulletproof
- 5 Test Integrity Rules Every AI Agent Should Follow