GitHub Copilot for JavaScript
JavaScript is the one language GitHub names in its own documentation as a standout. The Copilot FAQ says it plainly:
JavaScript is well-represented in public repositories and is one of GitHub Copilot’s best supported languages.
That is the theme of this lesson. In Python the problem is that the language checks nothing. In JavaScript the problem is that the language checks nothing and the suggestion is so well-formed that you stop looking.
JavaScript quick referenceVerified August 21, 2026
- Type system
- Dynamically typed with implicit coercion; JSDoc annotations are the only type signal a plain .js file can give Copilot.
- Package management
- npm (also pnpm, yarn)
- Manifest Copilot should see
- package.json
- Testing
- node:test, Vitest, Jest
- Formatting
- Prettier
- Linting
- ESLint
- Static analysis
- TypeScript in checkJs mode over JSDoc
- Common frameworks
- Express, React, Next.js, Fastify
- Typical Copilot work
- Express routes, DOM handlers, async data fetching, test scaffolding, build config
- First thing to review
- Browser and Node APIs mixed in one file — the suggestion runs in the wrong runtime
Fastest honest checknode --test && npx eslint .
Key takeaways
- The biggest JavaScript-specific failure is runtime confusion: a suggestion that mixes browser APIs into Node code, or the reverse. Both look correct in the editor.
- Async correctness is the second: a promise nobody awaited, a
forEachwith an async callback, an error that never reaches your handler. - npm is the largest package registry in the world, which makes a hallucinated or typosquatted package name a genuine supply-chain risk rather than a typo.
- JSDoc annotations plus
checkJsgive plain JavaScript most of the review benefit of TypeScript without changing the build. - The fastest honest check is
node --test && npx eslint ..
The context problem JavaScript has and Python does not
A .py file runs in one place. A .js file might run in a browser, in Node, in
a service worker, in an edge runtime, or in all of them from the same package.
Nothing in the file says which.
Copilot infers the runtime from surrounding context — imports, globals, the shape of the file — and when that context is thin it guesses. The result is the single most characteristic JavaScript failure:
// In a Node module. Both of these are wrong here.
const data = localStorage.getItem("cache");
const res = await fetch("/api/orders"); // relative URL, no origin in NodeAnd the mirror image in browser code:
import fs from "node:fs"; // will not resolve in a bundler
const key = process.env.API_KEY; // undefined, or worse, inlinedThe second example is worth pausing on. Some bundlers replace process.env.X at
build time, which means a suggestion that reads a secret from the environment in
browser code can succeed — by embedding the secret in the shipped bundle.
The fix is context, not prompting. Keep package.json open so "type": "module" and the dependency list are visible. Say “Node” or “browser” in the
prompt. In a mixed repository, keep the runtime split at the directory level so
neighbouring files reinforce the right answer.
Modules: the other thing nothing in the file tells you
ESM and CommonJS both appear heavily in public code, so both appear in
suggestions. require() in a file that package.json declares as "type": "module" throws at runtime; import in a CommonJS file fails to parse.
Copilot follows the file it can see. If the file already has one import
statement, the rest will match. If the file is empty, it is a coin flip weighted
by whatever the surrounding project looks like — so create the file with its
first import already written, or say which system you use.
Related and more subtle: top-level await works in ESM and not in CommonJS. A
suggestion using it is implicitly asserting a module system.
Core workflows
Async and promises. Copilot writes async/await well. What it omits is the
failure path. A generated await fetch(...) typically has no try/catch, no
timeout, and no check of res.ok — and fetch does not reject on a 404 or a
500, it resolves with ok: false. Code that only handles the network error
silently treats an HTTP error page as a successful response body.
Array methods. Strong, with one recurring trap: Array.prototype.sort()
converts elements to strings and sorts lexicographically unless you pass a
comparator. This is not folklore; here it is, run in Node 22:
[9, 10, 100, 2].sort() -> [ 10, 100, 2, 9 ]
[9, 10, 100, 2].sort((a,b)=>a-b) -> [ 2, 9, 10, 100 ]Any generated code that sorts numbers and does not pass a comparator is wrong, and the output is plausible enough to survive a skim.
Event handling and the DOM. Suggestions here are fluent and frequently insecure by default — see the security section. Also check for listeners added without a corresponding removal in code that runs more than once.
Error handling. JavaScript lets you throw anything, and generated code
sometimes throws strings. catch (e) then gets a value with no .message and no
stack. Ask for Error subclasses with a cause where you need to wrap.
JSDoc: types without a build step
If you are not using TypeScript, JSDoc is the highest-leverage change you can make to Copilot’s output in JavaScript.
/**
* @param {number[]} samples
* @returns {{ count: number, p50: number, p95: number, p99: number }}
*/
export function percentiles(samples) { /* ... */ }Two things happen. The suggestion improves, because the model now knows the
shapes. And — if you add "checkJs": true to a tsconfig.json and run
tsc --noEmit — the annotations become enforceable, without changing a single
line of shipped code or adding a compile step.
That combination gives plain JavaScript most of the review value that types provide, which is the single best answer to “we cannot migrate to TypeScript right now.”
Practical project: a small Express API
Practical example
An Express 5 endpoint with validation and tests
A single POST endpoint that computes latency percentiles, with input validation, error handling, and tests that run on the built-in Node test runner.
- Status
- Tested implementation
- Runtime
- Node.js v22.23.2, Express 5.2.1, node:test, ESLint 9
- Command
node --test && npx eslint .- Result
- tests 6, pass 6, fail 0; ESLint exited 0 with no findings
- Run on
- August 21, 2026
Files
copilot-js-demo/ ├── src/ │ ├── app.js │ └── server.js ├── test/ │ └── app.test.js ├── eslint.config.js └── package.json
The split between app.js and server.js is the important structural decision,
and it is one Copilot will not make for you. app.js builds the app and returns
it; server.js is the only file that calls listen. Without that split, tests
either bind a fixed port or import a module with a side effect.
The prompt
Create an Express 5 route: POST /api/latencies.
The body is JSON with a “samples” field: an array of finite non-negative numbers.
Return 400 with a JSON error when samples is missing, not an array, empty, or contains anything other than a finite non-negative number.
On success return count, p50, p95 and p99 using nearest-rank percentiles.
Export a createApp() factory that does not call listen, so tests can import it.
Limit the JSON body to 16kb.
Implementation
import express from "express";
export function createApp() {
const app = express();
app.use(express.json({ limit: "16kb" }));
app.post("/api/latencies", (req, res) => {
const { samples } = req.body ?? {};
if (!Array.isArray(samples) || samples.length === 0) {
return res.status(400).json({ error: "samples must be a non-empty array" });
}
if (!samples.every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0)) {
return res
.status(400)
.json({ error: "samples must contain only finite, non-negative numbers" });
}
res.json(percentiles(samples));
});
app.use((err, _req, res, _next) => {
res.status(400).json({
error: err.type === "entity.parse.failed" ? "invalid JSON" : "bad request",
});
});
return app;
}
export function percentiles(samples) {
const sorted = [...samples].sort((a, b) => a - b);
const at = (p) =>
sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)];
return { count: sorted.length, p50: at(50), p95: at(95), p99: at(99) };
}Three details worth naming, because they are the ones a suggestion gets wrong:
[...samples]before sorting.sort()mutates in place; sorting the caller’s array is a side effect nobody asked for.Number.isFiniterather thanisFinite. The global coerces, soisFinite("12")istrueand a string sneaks through the validator.- The explicit error handler. Express 5 forwards a rejected promise to the error handler where Express 4 did not; keeping an explicit handler makes the behaviour the same either way, and turns malformed JSON into a 400 rather than a stack trace.
Tests
import assert from "node:assert/strict";
import test from "node:test";
import { createApp, percentiles } from "../src/app.js";
async function withServer() {
const server = createApp().listen(0); // port 0 = ephemeral
await new Promise((resolve) => server.once("listening", resolve));
return { url: `http://127.0.0.1:${server.address().port}`, close: () => server.close() };
}
test("percentiles sorts numerically, not lexicographically", () => {
assert.deepEqual(percentiles([9, 10, 100, 2]), { count: 4, p50: 9, p95: 100, p99: 100 });
});
test("POST /api/latencies rejects non-numeric samples", async (t) => {
const { url, close } = await withServer();
t.after(close);
const res = await fetch(`${url}/api/latencies`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ samples: [1, "2", 3] }),
});
assert.equal(res.status, 400);
});listen(0) and t.after(close) are the two things to insist on. Generated
Express tests routinely hard-code port 3000, which fails the moment two test
files run concurrently, and routinely forget to close the server, which leaves
the runner hanging.
Run
npm install
node --test
npx eslint .Observed output:
# tests 6
# pass 6
# fail 0Testing
Node’s built-in node:test needs no dependency and no config, which makes it the
lowest-friction target for generated tests. Copilot will default to Jest unless
told otherwise, because Jest dominates public code — so say which runner you
want.
Write tests using the built-in node:test runner and node:assert/strict.
Do not use Jest, Vitest, Mocha, Chai or Supertest.
Start the server on port 0 and close it with t.after().
Beyond the runner, the JavaScript-specific test advice is about async: a test
that forgets to await passes instantly and asserts nothing. If a generated test
suite runs suspiciously fast, check that every async assertion is awaited and
that the test function itself is async.
npm, package.json and the lock file
The npm registry is the largest package ecosystem in existence, and that scale changes the risk calculus in a way that is specific to JavaScript.
In a small ecosystem, a hallucinated package name simply fails to install. In
npm, the name space is so large and so loosely governed that a plausible name is
often available, and sometimes already taken by someone who registered it
precisely because it is plausible. A suggestion that says npm install node-fetch-retry-utils is not obviously wrong the way a suggestion citing a
non-existent standard-library function is.
The habits that matter:
Read the package name character by character. Typosquats differ by one
letter, a hyphen, or a scope. @types/express and types-express are not the
same thing.
Check the registry page before installing. Weekly downloads, last publish date, repository link, maintainer. A package with three downloads and no repository is not a dependency, it is a liability.
Commit the lock file, and use npm ci in CI. The lock file is the only
artefact that records exactly what was installed. npm install may resolve
differently on a different day; npm ci installs precisely what the lock file
says or fails.
Prefer what you already have. A large share of suggested dependencies exist
to do something the standard library or an existing dependency already does.
Node has had fetch, a test runner, a watch mode and structured clone built in
for several major versions; suggestions drawn from older public code will reach
for node-fetch, mocha and nodemon anyway.
Ask Copilot to justify the dependency. “Why is this package needed rather than the standard library?” is a cheap question with a high hit rate. Reading is a task the model is good at; the answer will often be that it is not needed.
Front-end frameworks, briefly
Framework-specific technique belongs in its own guides, but two framework facts change how you review generated JavaScript, so they belong here.
The framework is a bigger determinant of the answer than the language. React, Vue, Svelte and Angular have incompatible mental models for the same task. Ask for “a component that fetches orders” without naming the framework and you get whichever is most probable given your open files — and if your open files are thin, whichever is most common in public code.
Framework versions shift more than language versions. React’s function components and hooks superseded class components; the ecosystem’s data-fetching conventions have changed repeatedly. Public code contains every era. A suggestion using a pattern your team retired two years ago is not the model being wrong, it is the model being average.
The practical rule is the same as elsewhere in this cluster: name the framework and its major version in the prompt until it lives in your repository instructions, and keep one existing component open as the example of your conventions. One real component from your codebase settles more questions than a paragraph of description.
The same applies on the server: Express, Fastify, Koa and Hapi have different middleware contracts, and a suggestion written for one is not portable to another even though the code looks similar enough to paste.
Node-specific server concerns
A handful of things that only come up in Node, and that generated code routinely omits.
Timeouts. fetch has no default timeout. Neither does most database client
configuration. A generated service that calls another service will, by default,
wait forever. Ask for AbortSignal.timeout() explicitly.
Graceful shutdown. Generated servers call listen and stop there. In any
containerised deployment you also need a SIGTERM handler that stops accepting
connections and drains in-flight requests, or every deploy drops requests.
Body limits. express.json() has a default limit, but a suggestion that
configures it explicitly — as the worked example above does — is documenting a
decision rather than inheriting one.
Environment configuration. Generated code reads process.env.THING inline,
scattered through the codebase, with no validation. Reading and validating
configuration once at startup means a misconfigured deployment fails immediately
rather than at the first request that touches that code path.
Logging. Suggestions default to console.log. In a service you want
structured logs with a level, and — this is the part that matters for security —
you want to be sure the object being logged does not contain a token, a password
or a full request body.
Clustering and concurrency. Node is single-threaded per process. A generated CPU-bound loop will block the event loop and stall every other request. If a suggestion does real computation, ask where it runs.
Formatting and linting
Two tools, two jobs, and both worth having before you start accepting suggestions in volume.
Prettier removes formatting from review entirely. This matters more with generated code than with hand-written code, because a suggestion arrives with whatever style the model inferred, and a diff where half the noise is indentation is a diff where the real change hides.
ESLint is where the JavaScript-specific safety net lives. The rules worth
having on for reviewing generated code are the ones that catch its
characteristic mistakes: no-floating-promises and require-await (via
typescript-eslint, which works on JSDoc-annotated JavaScript too),
no-unused-vars, eqeqeq, no-implicit-globals, and the security-oriented
plugins if your codebase warrants them.
ESLint’s flat config (eslint.config.js) is the current format and the one the
worked example above uses. Suggestions frequently produce the older .eslintrc
form, because a decade of public code uses it — another instance of the same
version-drift pattern this lesson keeps returning to.
Debugging and refactoring
For debugging, the highest-value thing you can paste into chat is a stack trace plus the relevant source. Async stack traces in modern Node are good enough to be useful context.
For refactoring, the honest constraint is the same as Python’s: without types, a rename is a text substitution. Two mitigations specific to JavaScript:
- Add JSDoc and turn on
checkJsbefore a large refactor. It converts “hope” into “the checker found eleven call sites.” - Prefer ESM named exports over default exports in code you expect to refactor —
named exports fail loudly at import time when they disappear; a default export
quietly becomes
undefined.
Reading generated async code
Async correctness is the category where fluent output is most misleading, so it deserves a method rather than a warning.
Work through a generated async function asking four questions in order.
Who awaits this? Every async function returns a promise. Find the caller.
If the caller does not await it and does not attach a .catch(), the work
happens outside the request lifecycle, errors surface as unhandled rejections,
and in current Node an unhandled rejection ends the process. The most common
form is an async function called for its side effect — “fire and forget” — which
is a legitimate pattern only when someone has explicitly decided to forget it.
What happens on rejection? Not “is there a try/catch”, but what the catch
block does. catch (e) { console.error(e) } inside a request handler swallows
the failure and returns a 200 with an empty body. The caller cannot tell the
difference between “no results” and “the database was down.”
Is the concurrency real? await inside a for loop is sequential. That is
often correct — sequential is what you want when each iteration depends on the
last, or when you are being polite to a rate-limited API — but it is frequently
not what the prompt asked for. If you asked for parallel work and got a loop with
an await in it, the suggestion did not do what you think. Promise.all over a
map is the parallel form, and Promise.allSettled is the version that does not
abandon the remaining work when one item fails.
Does anything block the loop? A synchronous JSON parse of a large payload, a crypto operation, a tight computation. Node has one thread for your code; a blocking call stalls every other in-flight request, and the symptom is latency somewhere else entirely.
Two smaller patterns worth recognising, because both are common and both look fine:
Promise.allwith amapthat has no concurrency limit. A thousand items becomes a thousand simultaneous connections, which is how a suggestion takes down a downstream service.- A
finallyblock that releases a resource but is attached to the wrong promise, so the release happens before the work does.
None of this is exotic. All of it is invisible on a skim, which is precisely why a language with fluent suggestions needs a deliberate reading method rather than a general intention to be careful.
JavaScript-specific risks
Asynchronous bugs. A promise created and not awaited runs, may reject
unhandled, and completes after the response has been sent. array.forEach(async x => ...) is the canonical form: forEach ignores the returned promise, so the
loop finishes before any of the work does. Use for...of with await, or
Promise.all with map.
Unhandled rejections. In current Node an unhandled rejection terminates the
process by default. A generated background task without a .catch() is a crash
waiting for a bad response.
Browser and Node API confusion. Covered above; the most common single category.
Package version mismatches. Express 4 versus 5, ESLint’s flat config versus
.eslintrc, node-fetch versus built-in fetch. Public code is full of the
older form of each, so suggestions skew old. Check package.json.
Prototype and equality assumptions. == versus ===, typeof null being
"object", NaN !== NaN. Modern suggestions mostly get these right; the ones
that do not are usually drawn from older code, and their presence is a signal to
read the whole suggestion more carefully.
Unsafe DOM handling. See below.
Weak validation. A hand-rolled if (!body.email) check is not validation. If
the endpoint is public, ask for a schema validator that is already in your
dependencies.
Review workflow
- Run ESLint and the test suitenode --test && npx eslint . — seconds, and it clears the mechanical layer.
- Confirm the runtimeHuman judgementDoes this file run in Node or a browser, and does every API in the suggestion exist there?
- Trace every promiseHuman judgementAwaited? Caught? Does the function that creates it wait for it?
- Check res.ok on every fetchHuman judgementfetch does not reject on 4xx or 5xx. Code that only catches network errors treats an error page as data.
- Read every new dependencyHuman judgementName, publisher, publish date, and whether it is in the lock file.
- Grep for innerHTML, eval, execHuman judgementThree greps covering most of the serious surface.
Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.
Best practices
- Keep
package.jsonopen and name the major version of any framework in your prompt. “Express 5” and “Express” produce different code. - Adopt JSDoc plus
checkJsif you are not on TypeScript. It is the cheapest large improvement available. - Insist on the test runner you actually use; the default assumption is Jest.
- Never accept a numeric
sort()without a comparator. - Treat every suggested dependency as a decision, not a detail.
Common mistakes
- Trusting fluent output. JavaScript suggestions read well; that is a property of the corpus, not evidence of correctness.
- Accepting async code without tracing where the promise is awaited.
- Letting a browser API into a Node file because the editor did not complain.
- Running
npm installon a package name you have not looked up. - Assuming
express.json()errors reach your handler without an error middleware.
Where to go next
GitHub Copilot for TypeScript is the direct continuation — written to cover only what types change, not to repeat this lesson. GitHub Copilot with VS Code covers the editor features referenced here, and Code completion explained covers the inline suggestion mechanics.
Sources
Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.