A model is better at changing code that already exists than at writing code that does not. A generated greenfield module compiles, reads nicely, and is quietly wrong in a way nobody notices for a month. A rename applied across four hundred call sites is either correct or loudly broken, and you find out in seconds.
That asymmetry is the whole practical story. Generation has no ground truth. Refactoring has one: the code that was there before, and the tests that passed against it. Every technique below is really an attempt to give the model more ground truth and give the reviewer a smaller thing to check.
This is not about which assistant to install. It is about the mechanics of the work: how much of your repository to put in front of a model, which transformations are safe to hand over, what a bad diff looks like when it is trying to look good, and the point at which you close the suggestion and write the function yourself.
Why refactoring beats greenfield generation almost every time
Ask for a new authentication module and you get something shaped like authentication. Sensible function names, a plausible token flow. Whether it matches your session model, your error contract, your database access pattern or your logging conventions is a coin flip, and each mismatch stays invisible until something downstream depends on it.
Ask instead to convert a callback-based module to async and await, and the target is fixed. The behaviour should be identical. The tests that passed before should pass after. Anything else is a defect, and you have a mechanical way to see it.
The same holds for the work most engineers actually spend their week on: splitting a file that grew to two thousand lines, pulling a repeated block into a function, changing a signature and fixing every caller, replacing a deprecated library call, adding types to a module written before anyone cared about types. Tedious, high volume, low judgement, mostly verifiable. That is exactly the profile a model handles well and a senior engineer resents by hand.
There is a second, less obvious reason. Existing code carries your conventions. When the model can see how the rest of the repository handles errors, names variables, structures a service or validates input, its output drifts toward that style without anyone writing a style prompt. Generation from nothing has no such gravity.
How much context to give, and what to leave out
The instinct is to paste everything. It produces worse output, not better. A large context dilutes the parts that matter and invites the model to pattern match against code that has nothing to do with the task.
A useful context bundle for a refactor is small and deliberate:
- The file being changed, in full. Not an excerpt. Truncated files are where invented helper functions come from, because the model assumes the thing it needs must exist somewhere above the cut.
- The type or interface definitions the file depends on. If it touches a domain object, the model needs the real shape of that object, not its guess at the shape.
- One or two real call sites. These teach the calling convention better than any description.
- The test file, if one exists. It states the contract more precisely than prose and it tells the model what it is not allowed to break.
- The constraint, written out. Public signature must not change. No new dependencies. Keep the existing error type. Must work on the Node version in the engines field.
What to leave out: unrelated modules, generated files, lock files, vendor directories, and long design documents that describe intent rather than behaviour. If you are tempted to include a file because it might be relevant, name the constraint from it instead. One sentence saying that the repository uses a Result type rather than thrown exceptions does more work than the three hundred lines that define it.
When output goes wrong in a way that looks like a missing fact, add one specific file rather than widening the net. Codebase-wide indexing helps a model find where something is defined. It does not decide what matters here, and it will happily surface three similar implementations and copy the deprecated one.
Which refactors are safe to automate and which need a person
The dividing line is behaviour. If the transformation is supposed to leave observable behaviour exactly as it was, a machine can do it and a test suite can check it. If the transformation is supposed to change behaviour, no test tells you whether it changed it correctly, because the tests encode the old behaviour and will now fail on purpose.
| Transformation | Automate? | What actually verifies it |
|---|---|---|
| Rename a symbol across the codebase | Yes, and prefer your IDE or a codemod over a model | Compiler or linter. Watch for names in strings, config, and dynamic property access |
| Extract a function or component | Yes | Existing tests plus a read of the extracted boundary. Check what got captured by closure |
| Convert callbacks to promises or async and await | Yes, file by file | Tests. Read the error paths closely, this is where semantics shift |
| Add types to untyped code | Yes, with review | Type checker. Reject any output that reaches for the escape hatch type to make errors go away |
| Replace a deprecated API across call sites | Yes, if the mapping is mechanical | Tests plus one careful read of the vendor migration notes yourself |
| Split a large module into smaller ones | Partly. Decide the seams yourself, let the model move the code | Tests, import graph, and a check for circular imports |
| Change an algorithm for performance | No | Benchmarks and a human who understands the input distribution |
| Change error handling or retry policy | No | Nothing automatic. This is a design decision with production consequences |
| Alter a database access pattern or transaction boundary | No | Nothing automatic. Correctness depends on concurrency you cannot see in the file |
| Rewrite a module nobody understands any more | Not until it has characterization tests | See the next section |
A rule that holds up: the model may move code, rename code, and restate code. Deciding what the code should do stays with the person whose name is on the pull request. Teams that want a way to count whether any of this pays back should read our piece on measuring the return on AI coding tools rather than trusting a feeling of speed.
No test harness, no automated refactor
An automated refactor without tests is not a refactor. It is an untracked rewrite that happens fast.
Before you let anything touch a module in bulk, three things should be true. The suite runs on one command. It runs in a time nobody minds waiting for, because a suite people skip is a suite that does not exist. And it fails when behaviour changes, which is not the same as having high line coverage. Coverage counts lines executed. A test that calls a function and asserts nothing executes plenty of lines and protects nothing.
The cheapest way to find out whether your suite actually constrains behaviour is to break something on purpose. Invert a condition, drop a null check, return early. If the suite stays green, it will stay green through a bad refactor too.
Characterization tests for code you do not understand
The hard case is legacy code with no tests, which is usually the code most worth refactoring. You cannot write tests for intended behaviour, because nobody knows the intent any more. So write tests for actual behaviour instead, including the parts that look like bugs.
The mechanic is straightforward. Call the function with realistic inputs. Capture whatever comes back, snapshot style. Assert that it keeps coming back. You are not claiming the output is right, only that it is what today's production code does. If the function returns an empty string for a null argument where you would have expected a thrown error, pin the empty string. A refactor that quietly turns it into an exception will break a caller you have never read.
Getting inputs is the part people skip. Sampled production payloads with the sensitive fields replaced are worth far more than inputs you imagine, because real data contains the shapes that caused the defensive branches in the first place. A model is genuinely useful here: give it the function and ask it to enumerate input classes that reach each branch, then turn those into cases. Enumerating branches is mechanical, which is why it works.
Once the characterization tests are green, the refactor becomes safe in the ordinary sense. Change the code, watch the pins. Fix the bug afterwards, as a separate commit with its own test, so that the behaviour change is visible in history instead of buried inside a two thousand line diff.
The failure modes you are actually reading for
Generated code fails differently from human code. Human mistakes usually look like mistakes. These do not, which is why review needs a checklist rather than a general sense of unease.
Plausible but wrong API usage
The call has the right shape and the wrong contract. An options object with a key the library ignores. A method that exists but on a different class. A function called with arguments in an order that was correct two major versions ago. It passes the type checker when the types are loose, and it fails at runtime on a path your tests do not cover. Check anything unfamiliar against the real documentation, not against how confident the code looks.
Silently dropped edge cases
The most expensive one. A rewrite keeps the happy path perfectly and loses the empty array check, the timezone handling, the special case for the one customer whose identifiers are formatted differently. These branches often have no comment explaining why they exist, so they read like noise worth cleaning up. When a refactor deletes a conditional, find out what it was for before you accept it.
Invented helper functions
A call to formatCurrency or getTenantId that reads exactly like something your codebase would contain, and does not. Usually the tooling catches it. When the name collides with something real that does something slightly different, it does not, and that is the bad version.
Subtly changed error handling
Watch for a thrown error becoming a returned null, a caught exception being swallowed instead of rethrown, a promise rejection turned into a resolved empty value, a retry added where the operation is not idempotent, or a catch block widened until it hides the failure you needed to see. Converting callbacks to async and await is where this happens most.
Imports that do not exist
Packages that were never installed, deep paths into a module's internals, a named export that is actually a default export. Cheap to catch with a build step, which is the point: put the cheap checks in the pipeline so review time goes to the expensive ones.
Confident comments
Comments generated alongside code describe what the author of the comment believed, not what the code does. When they disagree, the comment is the one that lies for longer. Read code and comments as separate claims.
Reviewing a large generated diff without rubber-stamping it
A four hundred line diff you did not write is not reviewable in one pass. Nobody reads it line by line at the same attention they would give a colleague's twenty line change, and pretending otherwise is how this goes wrong at scale.
What works is turning one big review into several small mechanical ones:
- Let the machine go first. Compiler, type checker, linter, formatter, tests, dependency audit. If the diff has not passed all of them, it is not ready for a human, and reading it early wastes the only scarce resource here.
- Read the shape before the lines. Which files appeared, which disappeared, which grew. A refactor that was supposed to touch three files and touched eleven is answering a question nobody asked.
- Sort hunks by risk, not by file order. Anything touching money, permissions, personal data, migrations or external calls gets read first and slowly. Import reordering and formatting get skimmed.
- Diff the behaviour, not only the text. For a pure refactor the useful question is what a caller could observe differently. Signatures, return types, thrown errors, logged fields, query counts, ordering guarantees.
- Ask for the reasoning separately. Have the model explain why it made a specific change, then check the explanation against the code. A wrong explanation of correct code is a warning. A confident explanation of a change that is not in the diff means you are reading a different version than you think.
- Keep commits small on purpose. One transformation per commit. Mechanical move in one, behaviour change in another. Reviewability is a property you build during the work, not something you can add at the pull request.
The organisational version of this matters too. If review capacity is fixed and generated volume goes up, the bottleneck moves to review and quality drops quietly. Teams that extend an engineering team with dedicated developers usually feel this first, because output grows before review habits do. The fix is boring: smaller changes, clear ownership, and a rule that nobody merges a diff they cannot explain.
Migrations and codemods are the strongest case
The best use of generated code is the change that is repetitive, mechanical, and applied a hundred times. A library's breaking change across every call site. A logging format swapped everywhere. Class components converted to hooks. An internal API renamed across a monorepo.
Do it in a specific order. Take five representative call sites and do them by hand, until you know the transformation precisely, including the two variants that do not fit. Write the rule down as a transformation rather than an instruction, because replace calls of form A with form B, and when the second argument is an object, hoist it is checkable in a way that update the old calls is not. Then apply it in slices you can review, by directory or by module, not in one commit titled migrate everything.
Better still, when the pattern is regular, have the model write a codemod rather than edit the files. A script that works on an abstract syntax tree is deterministic, reviewable in one place, rerunnable after a merge conflict, and it either matches a node or does not. Reviewing sixty lines of transformation logic beats reviewing nine hundred lines of applied output. Keep a list of the sites it could not handle and do those by hand. The remainder is where the interesting cases hide.
Frontend and backend migrations in web application work follow the same pattern regardless of stack, and the tooling side of it inside a JavaScript codebase is covered separately in our rundown of AI tools for MERN stack teams.
When to throw the output away and write it yourself
Knowing when to stop is a skill, and the sunk cost of three rounds of prompting is the thing that stops people using it. Close the suggestion when:
- You have corrected it twice and it is still wrong. Two failed attempts usually mean the task depends on context the model does not have, and a third round produces a fourth variation on the same misunderstanding.
- You cannot explain the code you are about to merge. This is not negotiable. It is your name on the commit, and the incident review will ask you what it does.
- The change is genuinely novel. Concurrency, a state machine with real invariants, a security boundary, anything where correctness argument matters more than shape.
- It is short and you already know the answer. Describing a ten line function precisely takes longer than typing it.
- The code is load bearing and rarely touched. Payment capture, permission checks, data retention. Slow and deliberate is the right speed there.
- The output keeps growing. Each attempt adds abstraction rather than removing it. A refactor that makes the code bigger has usually misread the goal.
The other half of the skill is knowing that the throwaway attempt was not wasted. A rejected draft often shows you an edge case you had not thought about, or tells you the interface is hard to describe, which usually means it is hard to use. Take the finding, drop the code. For a wider view of how the day to day job has shifted around these tools, our post on generative AI in web development covers the ground this one deliberately does not.
Sitting on a codebase where the refactor everyone agrees on keeps getting postponed because nothing is safe to change? A Scoping Sprint ($2,300, two weeks) ends with a concrete plan for that migration, including where the tests have to go first, a prototype, and a fixed quote. Or just start a conversation.


