GitHub Copilot for JavaScript

GitHub Copilot Programming LanguagesAcademy lesson 28Cluster 3 · Lesson 3 of 13Intermediate16 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for JavaScriptGitHub Copilot Programming Languages3Intermediate/github-copilot/languages/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.

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 Node

And 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, inlined

The 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

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

Copilot promptGenerate the endpointCopilot Chat, with package.json open

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.isFinite rather than isFinite. The global coerces, so isFinite("12") is true and 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 0

Testing

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.

Copilot promptPin the test runnerCopilot Chat

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 checkJs before 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.all with a map that has no concurrency limit. A thousand items becomes a thousand simultaneous connections, which is how a suggestion takes down a downstream service.
  • A finally block 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

Accepting a JavaScript suggestion
  1. Run ESLint and the test suitenode --test && npx eslint . — seconds, and it clears the mechanical layer.
  2. Confirm the runtimeHuman judgementDoes this file run in Node or a browser, and does every API in the suggestion exist there?
  3. Trace every promiseHuman judgementAwaited? Caught? Does the function that creates it wait for it?
  4. 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.
  5. Read every new dependencyHuman judgementName, publisher, publish date, and whether it is in the lock file.
  6. 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.json open and name the major version of any framework in your prompt. “Express 5” and “Express” produce different code.
  • Adopt JSDoc plus checkJs if 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 install on 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.

Primary sources