Agentic Coding

AI-Assisted Refactoring Techniques for Teams

Learn practical AI-assisted refactoring techniques — rename, extract, restructure, and modernise TypeScript code safely with real commands and a regression-proof review process.

May 31, 2026 10 min read
AI-Assisted Refactoring Techniques for Teams

Refactoring is the work most teams intend to do and repeatedly defer. The code works, the deadline is real, and touching something that works feels like risk. AI coding assistants don't eliminate that risk, but they change the economics. A refactor that used to take a day of careful edits across twenty files now takes a few hours of AI-assisted work with focused human review. At Laxaar, we've used this workflow to modernise legacy TypeScript codebases, eliminate dead code, and restructure modules that had grown past the point of readability.

This tutorial covers four practical refactoring techniques (extract function, rename across a codebase, dead code removal, and API modernisation), each with a concrete process for using AI assistance safely.

What you'll build

Step 1: Audit the codebase and prioritise refactoring targets

Start with evidence. Refactoring without a prioritised target list produces cosmetic changes that don't reduce maintenance burden. Three metrics tell you where to look: file length, cyclomatic complexity, and test coverage gaps.

Run a quick size audit:

find src -name '*.ts' -not -path '*/node_modules/*' \
  | xargs wc -l \
  | sort -rn \
  | head -20

Files over 400 lines are candidates for extraction. Over 800 lines is a problem.

For complexity, use ts-complexity:

npx ts-complexity --threshold 10 src/**/*.ts

Functions with cyclomatic complexity above 10 are hard to test and hard to reason about. They're your highest-priority targets.

Pass the combined output to your AI assistant:

Here are our 10 longest files and 5 most complex functions (pasted below).
For each, suggest whether the right refactor is: extract function, split module,
rename for clarity, or delete dead code. Give a one-line rationale for each.
Don't write any code yet.

This triage step is worth doing before writing a single line. The AI's suggestions won't all be right, but they'll surface patterns you've stopped noticing because you're too close to the code.

Pick 3-5 targets for the current refactoring session. Don't attempt the whole list at once. Refactoring too many things simultaneously makes rollback painful and review impossible.

Step 2: Extract functions from oversized modules

Extract function is the most common refactoring. A function that does five things should be five functions. The AI is good at identifying the logical boundaries, often better than humans who've been staring at the same code for months.

Take a real example of an oversized function:

// src/services/orderProcessor.ts — BEFORE
export async function processOrder(orderId: string): Promise<ProcessResult> {
  const order = await db.order.findUnique({ where: { id: orderId }, include: { items: true, user: true } });
  if (!order) throw new NotFoundError('Order not found');
  if (order.status !== 'pending') throw new InvalidStateError('Order already processed');

  // Calculate totals
  let subtotal = 0;
  for (const item of order.items) {
    const product = await db.product.findUnique({ where: { id: item.productId } });
    if (!product) throw new NotFoundError(`Product ${item.productId} not found`);
    if (product.stock < item.quantity) throw new InsufficientStockError(product.id);
    subtotal += product.price * item.quantity;
  }

  const tax = Math.round(subtotal * 0.1);
  const shipping = subtotal > 10000 ? 0 : 599;
  const total = subtotal + tax + shipping;

  // Charge payment
  const charge = await stripe.paymentIntents.create({
    amount: total,
    currency: 'usd',
    customer: order.user.stripeCustomerId,
    payment_method: order.user.defaultPaymentMethodId,
    confirm: true,
  });

  if (charge.status !== 'succeeded') throw new PaymentError(charge.id);

  // Update inventory and order
  for (const item of order.items) {
    await db.product.update({ where: { id: item.productId }, data: { stock: { decrement: item.quantity } } });
  }
  await db.order.update({ where: { id: orderId }, data: { status: 'confirmed', total, chargeId: charge.id } });

  return { orderId, total, chargeId: charge.id };
}

Prompt:

The processOrder function in src/services/orderProcessor.ts does too many things.
Extract it into focused sub-functions. Each sub-function should have a single responsibility,
accept only the parameters it needs, and be independently testable.
Keep processOrder as the coordinator. Don't change any behavior.

The AI should produce something like:

// src/services/orderProcessor.ts — AFTER

async function validateAndFetchOrder(orderId: string) {
  const order = await db.order.findUnique({ where: { id: orderId }, include: { items: true, user: true } });
  if (!order) throw new NotFoundError('Order not found');
  if (order.status !== 'pending') throw new InvalidStateError('Order already processed');
  return order;
}

async function calculateOrderTotals(items: OrderItem[]): Promise<OrderTotals> {
  let subtotal = 0;
  for (const item of items) {
    const product = await db.product.findUnique({ where: { id: item.productId } });
    if (!product) throw new NotFoundError(`Product ${item.productId} not found`);
    if (product.stock < item.quantity) throw new InsufficientStockError(product.id);
    subtotal += product.price * item.quantity;
  }
  const tax = Math.round(subtotal * 0.1);
  const shipping = subtotal > 10000 ? 0 : 599;
  return { subtotal, tax, shipping, total: subtotal + tax + shipping };
}

async function chargeCustomer(user: User, amount: number): Promise<Stripe.PaymentIntent> {
  const charge = await stripe.paymentIntents.create({
    amount,
    currency: 'usd',
    customer: user.stripeCustomerId,
    payment_method: user.defaultPaymentMethodId,
    confirm: true,
  });
  if (charge.status !== 'succeeded') throw new PaymentError(charge.id);
  return charge;
}

async function fulfilOrder(orderId: string, items: OrderItem[], total: number, chargeId: string) {
  for (const item of items) {
    await db.product.update({ where: { id: item.productId }, data: { stock: { decrement: item.quantity } } });
  }
  await db.order.update({ where: { id: orderId }, data: { status: 'confirmed', total, chargeId } });
}

export async function processOrder(orderId: string): Promise<ProcessResult> {
  const order = await validateAndFetchOrder(orderId);
  const { total } = await calculateOrderTotals(order.items);
  const charge = await chargeCustomer(order.user, total);
  await fulfilOrder(orderId, order.items, total, charge.id);
  return { orderId, total, chargeId: charge.id };
}

Before accepting this, run the TypeScript compiler and your existing tests:

npx tsc --noEmit && npx vitest run

If tests pass and types check out, the extraction is safe. If something breaks, the test failure tells you exactly which behavior changed.

Step 3: Rename symbols safely across the codebase

Renaming is error-prone at scale. Change a function name in one file and miss it in three others, and the TypeScript compiler catches this only if your types are tight enough. AI assistants can plan a rename and produce the grep commands to verify it.

Say you want to rename getUserByEmail to findUserByEmail across the codebase to match your new naming convention (queries use find*, mutations use create*/update*/delete*).

Prompt:

I want to rename getUserByEmail to findUserByEmail across the entire codebase.
List every file that imports or calls getUserByEmail using grep.
Then produce the sed commands or exact edits needed for each file.
Don't make any changes yet — just the plan.

The AI should produce:

# Find all usages first
grep -rn "getUserByEmail" src/ --include="*.ts"

Review the grep output yourself. Don't trust the AI's count; run the command and count the results yourself. Then for each file, ask the AI to produce the specific edit rather than a bulk sed command. Bulk replaces across large codebases hit false positives (a comment or string that mentions the old name) that break things silently.

Use your IDE's rename refactoring for symbol renames when possible. It understands TypeScript's type system and catches cases that grep misses (dynamic property access, type aliases, interface implementations). Use the AI to verify the rename plan and check for any cases the IDE might miss.

After renaming, run:

grep -rn "getUserByEmail" src/ --include="*.ts"

Zero results. Then compile and test.

Step 4: Remove dead code with AI-assisted analysis

Dead code is the maintenance burden nobody talks about. It's the function that was replaced 18 months ago but never deleted, the feature flag that was shipped and never cleaned up, the type that's imported but never used. Removing it makes the codebase smaller and clearer.

TypeScript's --noUnusedLocals and --noUnusedParameters flags catch some of this at compile time. Enable them in tsconfig.json if you haven't:

{
  "compilerOptions": {
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

Run npx tsc --noEmit and collect the output. Paste it to the AI:

Here's the TypeScript compiler output for unused locals and parameters.
For each warning, tell me whether it's safe to delete, needs a code change
(e.g. a parameter that's used in a subtype), or should be suppressed with a comment.
Produce the minimal edits to resolve each warning without changing behavior.

For exports (which TypeScript doesn't flag as unused because they're public API), use ts-prune:

npx ts-prune --error

This lists exported symbols that are never imported. Review this list carefully, since some exports are intentionally public even if unused in the codebase (they're part of the public API contract). Mark those with // ts-prune-ignore-next. Delete the rest.

One category of dead code that's easy to miss: commented-out code blocks. These are almost always safe to delete. The AI can find them:

grep -rn "^[[:space:]]*//" src/ --include="*.ts" | grep -v "@" | head -30

Prompt: "Here's a sample of commented-out code. Which blocks appear to be dead code vs intentional documentation comments?"

Step 5: Modernise deprecated API usage

Every major library update leaves behind deprecated APIs that still work but accumulate technical debt. React's class components, Mongoose's callback-style queries, the old useEffect-for-data-fetching pattern: these are all things that AI can help you migrate systematically.

Say you're moving from Mongoose callbacks to async/await:

// BEFORE — callback style
User.findOne({ email }, function(err, user) {
  if (err) return next(err);
  if (!user) return res.status(404).json({ error: 'Not found' });
  res.json(user);
});

Prompt:

Find all Mongoose callback-style queries in src/ and convert them to async/await.
The pattern to find: Model.method(query, function(err, result) { ... })
Convert each to: const result = await Model.method(query); with try/catch or
propagate errors via next(err). Show me the edits file by file.

The AI should produce a list of edits. Apply one file at a time, run the tests after each:

npx vitest run src/routes/users.test.ts

Don't batch all the files into one commit. If something breaks, you need to know which file introduced the problem. Small commits with clear messages make rollback trivial.

One opinionated take worth stating: don't modernise deprecated APIs just because they're deprecated. Modernise when the new API gives you a concrete benefit (better error handling, improved performance, reduced bundle size) or when the deprecation is close to removal. Chasing every deprecation warning is busy work.

Common pitfalls

Refactoring without a test baseline. If you don't have tests before you start, you have no signal that a refactoring preserved behavior. Write at least smoke tests for the code you're changing before the AI touches it. The AI-assisted testing workflow covers this in detail.

Accepting multi-file changes without reviewing the diff. The AI can produce changes across ten files in one response. Review each file's diff individually before committing. A change that looks correct in isolation may break an invariant that spans files.

Treating AI-suggested extractions as architecture decisions. The AI proposes module boundaries based on patterns in the code. It doesn't know your team's conventions, your deployment constraints, or which modules are co-deployed. Always validate suggested extractions against your actual architecture.

Skipping the compile step between refactors. Run tsc --noEmit after every meaningful change. TypeScript's type checker catches the rename misses and interface mismatches that tests won't catch until runtime.

Frequently Asked Questions

How do I know if a refactoring is safe to accept from an AI?

The test suite is the source of truth. If tests pass before and after, and the TypeScript compiler is happy, the refactoring preserved observable behavior. The deeper question is whether the new structure is better; that's a human judgment call, not a compiler check.

Can AI refactoring tools handle large codebases with hundreds of files?

With context limitations, most AI assistants work best on 1–5 files at a time. For large codebases, break the refactoring into scoped sessions: one module per session, one type of change per session. The agentic coding workflow pattern covers how to structure multi-session refactoring work.

Should we refactor before or after adding tests?

Before you add tests, write at least characterisation tests (tests that capture the current behavior, even if it's wrong). Then refactor. The Laxaar approach: never refactor untested code without at least one integration test that covers the changed path.

How do I handle a refactoring that the AI gets wrong on the first attempt?

Paste the compiler error or test failure back to the AI with the original code and the incorrect edit side by side. Ask for a targeted fix, not a new attempt at the whole refactoring. Smaller corrections converge faster than full rewrites.

What's the right order for a large-scale modernisation?

Bottom-up: start with leaf modules (utilities, types, helpers) that have no dependencies on the rest of the codebase. They're easiest to test in isolation. Work toward the entry points last. This order means earlier refactors are already in place when you reach the code that depends on them.

How do we track refactoring work across a team?

Use your issue tracker and treat refactoring tasks like feature work, with acceptance criteria and a definition of done. "Files under 400 lines, complexity below 10, tests green" is a measurable done. Vague refactoring tasks ("clean up the auth module") never ship.


The Laxaar team has guided refactoring efforts across legacy codebases in fintech, healthcare, and e-commerce. If you're carrying technical debt that's slowing down your team, see our engineering services or contact us to talk through a practical path forward.

RefactoringAI CodingTypeScript
Grow your business with us

Take your business to the next level.

Tell us what you're building. We'll come back inside one business day with a fixed scope, timeline, and team — or an honest “this isn't a fit”.

ENGINEERING PHILOSOPHY

Code is useless if it's not comprehensible to those who maintain it. We write code the next person can actually understand.