On this page
Legacy JavaScript rarely fails because nobody knows a cleaner syntax. It fails because important behaviour lives in undocumented call sites, unusual data and historical workarounds. An AI assistant can rewrite a module neatly while removing the condition that keeps an old customer integration working.
Use AI to make changes that are small enough to explain and cheap enough to reverse. The objective is not a modern-looking file. It is a clearer implementation with evidence that supported behaviour still works, followed by separately approved changes to behaviour that should not survive.
Establish the behaviour you must preserve
Choose a narrow entry point: one formatter, one data adapter or one event handler. List its callers and the values they actually pass. Look for hidden contracts such as mutating an input object, preserving property order for a downstream consumer, or returning an empty string instead of null. These may be undesirable, but they are still dependencies until deliberately removed.
Ask the model to explain the function without rewriting it. Require references to the relevant branches and distinguish observed facts from guesses. Verify that explanation against code and examples. A fluent description can overlook a fall-through case just as easily as a developer can.
Create characterisation tests around representative production behaviour using sanitised fixtures. Include missing properties, empty collections and malformed-but-currently-accepted values. The article on AI-generated unit tests explains how to avoid copying implementation assumptions into every assertion. Label known bugs explicitly so preserving behaviour does not become permanent approval of those bugs.
Record the runtime, module format and build constraints. A transformation that works in a modern browser may break an older embedded browser, and replacing CommonJS exports can change how existing consumers load a module.
Separate discovery from editing
Give the assistant a read-only investigation task first. Ask it to find call sites, side effects, dependencies and existing tests, then propose a sequence of changes. Review the sequence before authorising any edits. This prevents a modest cleanup request from spreading into configuration, dependency and architecture changes.
Useful initial instructions are specific about scope:
Inspect normaliseOrder and its direct callers. Do not edit files.
List observable behaviour, side effects and uncertain assumptions.
Propose three independently testable refactoring steps.
Preserve exports, mutation behaviour and error handling.
Do not upgrade dependencies or change module format.Choose assistants from the AI coding assistants directory based on repository visibility, patch review and permission controls. Broad context can improve discovery, but broad write access is not required. Exclude secrets and unrelated customer data from the context you share.
Set a change budget for each patch. A practical first step might rename local variables and extract one pure calculation, without touching public names or control flow. If the model finds a larger architectural problem, log it for later rather than accepting an unplanned rewrite.
Make one mechanical transformation at a time
Start with changes whose effects you can reason about locally: extracting duplicate literals, naming a predicate or isolating a formatter. Run the relevant checks after each step. Small patches make it easier to identify which transformation introduced an unexpected difference and to discard only that change.
Be cautious with semantic lookalikes. Replacing a truthiness check with a nullish default changes how zero, false and empty strings behave. Converting a loop to a map can alter mutation and allocation patterns. Arrow functions have different binding rules from ordinary functions. The MDN JavaScript guide is a better authority on those semantics than a confident generated explanation.
Preserve evaluation order when getters, callbacks or network requests can have side effects. A seemingly harmless extraction may call a getter twice or delay a mutation until after an observer runs. Ask the model to identify order-sensitive expressions, then inspect them yourself rather than treating the answer as a guarantee.
Use a single-purpose diff as the review unit. Do not mix formatting an entire file with changing its branches. If project tooling reformats touched lines, keep that noise contained and explain it in the review notes.
Introduce types at the boundary
Types can make a refactor safer when they describe real data rather than an idealised sample. Collect several sanitised payloads and compare them with the documented API contract. Separate fields that are absent from fields explicitly set to null, and retain string identifiers even when they contain only digits.
Use the JSON to TypeScript tool to turn representative fixtures into a first draft of boundary interfaces. Review the output for optional properties, unions and nested variants before adding it to the project. A generated interface describes the supplied examples; it cannot establish every value the server may send.
For a JavaScript codebase, incremental checking through JSDoc and targeted tooling may be less disruptive than renaming every file. The TypeScript migration guidance describes how gradual adoption works. Keep compiler configuration changes separate from the behaviour-preserving patch so reviewers can understand each independently.
Do not silence uncertainty with broad casts or any types just to obtain a clean check. Document unresolved inputs and narrow them explicitly where they enter the system. The guide to types from real API data provides a fuller workflow for separating observed shapes from validated contracts.
Verify equivalence and plan reversal
Run old and new implementations against the same fixture corpus where practical. Compare returned values, thrown errors and externally visible side effects. This differential testing is particularly useful for parsers and adapters, but it only establishes agreement on the exercised inputs. It does not prove that the original implementation was correct.
For asynchronous code, include ordering, rejection and cancellation scenarios. A function returning the same eventual value can still break callers if it now throws synchronously instead of rejecting a promise. Check subscription cleanup and event listener removal when extracting lifecycle code.
Inspect the final diff with tests disabled in your mind: can you explain why each changed expression preserves the contract? Use the Git diff documentation to review changes without allowing whitespace noise to dominate. Tests provide evidence; careful code review addresses gaps in that evidence.
Keep a rollback path proportional to risk. An internal utility may need only a focused revert. A widely used adapter may need staged rollout, error monitoring and a temporary switch between implementations. Define what signal would trigger reversal before deployment, not during an incident.
Write the completion note in terms of verified outcomes: which behaviour was preserved, which checks actually ran, and what uncertainty remains. Do not report “fully modernised” when you have safely extracted one function. Small, explicit improvements create a reliable foundation for the next patch.
Key takeaways
- Discover callers and hidden behaviour before requesting edits.
- Separate investigation, mechanical refactoring and intentional behaviour changes.
- Keep each patch small enough to review and revert independently.
- Use generated types as reviewed drafts, not proof of runtime inputs.
- Combine regression tests, differential checks and a concrete rollback plan.
Reach designers & developers at work
Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.
Become a sponsor