GitHub Copilot for TypeScript

GitHub Copilot Programming LanguagesAcademy lesson 29Cluster 3 · Lesson 4 of 13Intermediate15 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for TypeScriptGitHub Copilot Programming Languages4Intermediate/github-copilot/languages/typescript/

This lesson assumes you have read GitHub Copilot for JavaScript. Almost everything there still applies — the runtime confusion, the async traps, the npm supply chain — and none of it is repeated here.

What is here is the part that is genuinely different: TypeScript gives you a compiler, and a compiler changes the economics of AI-generated code more than any other single factor in this cluster.

What actually changes when you add types

Three things, in increasing order of importance.

Suggestions get more specific. Given function settle(invoice: Invoice): Payment[], the model knows the input shape, the output shape, and — if Invoice is in an open file — every field it can reference. It stops guessing field names. This is the benefit everyone expects, and it is the smallest of the three.

Wrong suggestions stop compiling. A hallucinated method on your interface, a misspelled property, a function called with arguments in the wrong order: all compile errors, all found in under a second, none requiring you to read anything. This is where the real value is. It moves an entire category of defect from “a human might notice” to “the toolchain guarantees.”

The type becomes a specification you can hand to the model. This is the one people underuse. Writing the types first and asking for an implementation that satisfies them is a fundamentally different interaction from describing what you want in prose. The types are unambiguous, machine-checked, and they stay in the codebase as the contract afterwards.

Copilot promptTypes first, implementation secondCopilot Chat, with the type file open

Here are the types. Implement fetchLatencyReport so it satisfies them exactly.

Do not widen any type, do not add any, and do not use a type assertion. If the signature cannot be satisfied as written, tell me which constraint is the problem instead of working around it.

Validate the parsed response at runtime with a type predicate — the compiler cannot check what comes off the wire.

That last paragraph is doing real work, and it is the sentence most worth stealing from this lesson. Without it you get a JSON.parse result asserted to your interface with as, which type-checks perfectly and tells you nothing about what the server actually sent.

Strict mode, and why it is not optional here

"strict": true turns on a group of checks, of which two matter most for reviewing generated code.

strictNullChecks makes null and undefined distinct from other types. Without it, every type silently includes null, and the most common category of runtime error in the language becomes invisible to the compiler. Generated code frequently assumes a value is present; with strict null checks it has to prove it.

noImplicitAny forces a parameter with no annotation to be an error rather than a silent any. Without it, a suggestion that omits an annotation switches type checking off for that value and everything derived from it, and does so silently.

Two more worth turning on specifically because of how generated code behaves:

  • noUncheckedIndexedAccess makes arr[0] have type T | undefined. Generated code indexes arrays constantly and almost never checks the bounds. This setting converts that habit into a compile error.
  • exactOptionalPropertyTypes distinguishes “property absent” from “property present and undefined”, which is exactly the distinction that generated object literals blur.

Practical example: a typed REST client

Practical example

A typed API client with a runtime validator and an exhaustive error union

Define the contract as types, implement against it, and watch the compiler reject two realistic mistakes without a single test running.

Status
Tested implementation
Runtime
TypeScript 6.0.3, strict with noUncheckedIndexedAccess and exactOptionalPropertyTypes
Command
tsc --noEmit -p .
Result
Exit 0 on the finished client; each of the two seeded defects produced a compile error, quoted below
Run on
August 21, 2026

Files

copilot-ts-demo

copilot-ts-demo/ ├── src/ │ └── client.ts └── tsconfig.json

The contract

export interface LatencyReport {
  count: number;
  p50: number;
  p95: number;
  p99: number;
}

export type ClientError =
  | { kind: "network"; cause: unknown }
  | { kind: "http"; status: number; body: string }
  | { kind: "malformed"; received: unknown };

export type Result<T> = { ok: true; value: T } | { ok: false; error: ClientError };

Result<T> is a discriminated union rather than a thrown exception. That is a deliberate choice with a specific payoff for generated code: a thrown value in TypeScript is unknown by design, so catch (e) gives the compiler nothing to check. A union gives it everything.

Implementation

function isLatencyReport(value: unknown): value is LatencyReport {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  return (["count", "p50", "p95", "p99"] as const).every(
    (k) => typeof v[k] === "number" && Number.isFinite(v[k]),
  );
}

export async function fetchLatencyReport(
  baseUrl: string,
  samples: readonly number[],
  fetchImpl: typeof fetch = fetch,
): Promise<Result<LatencyReport>> {
  let response: Response;
  try {
    response = await fetchImpl(`${baseUrl}/api/latencies`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ samples }),
    });
  } catch (cause) {
    return { ok: false, error: { kind: "network", cause } };
  }

  if (!response.ok) {
    return {
      ok: false,
      error: { kind: "http", status: response.status, body: await response.text() },
    };
  }

  const parsed: unknown = await response.json();
  if (!isLatencyReport(parsed)) {
    return { ok: false, error: { kind: "malformed", received: parsed } };
  }

  return { ok: true, value: parsed };
}

Three decisions worth naming, because a suggestion left to itself makes the opposite call on each:

  • const parsed: unknown rather than const parsed: LatencyReport. response .json() returns any — annotating it as unknown forces the validation to happen. This one line is the difference between a type system that describes your hopes and one that checks your data.
  • fetchImpl: typeof fetch = fetch makes the client testable without a mocking library or a global patch.
  • readonly number[] says the client does not mutate the caller’s array.

The exhaustiveness guard

export function describe(error: ClientError): string {
  switch (error.kind) {
    case "network":  return "The service could not be reached.";
    case "http":     return `The service returned HTTP ${error.status}.`;
    case "malformed":return "The service returned a body that is not a latency report.";
    default: {
      const exhaustive: never = error;
      return exhaustive;
    }
  }
}

The never assignment is the load-bearing line. It is what makes adding a case to ClientError a compile error everywhere that case is not handled.

Two defects the compiler caught

Both were introduced into this exact project and checked with tsc --noEmit -p ..

A caller that forgot to narrow the union. This is the single most common mistake against a Result type, and one a suggestion makes routinely:

const result = await fetchLatencyReport("http://localhost:3000", [1, 2, 3]);
return result.value.p95;
src/usage.ts:5:17 - error TS2339: Property 'value' does not exist on type
'Result<LatencyReport>'.
  Property 'value' does not exist on type '{ ok: false; error: ClientError; }'.

Without the union, that line reads a property off a possibly-absent object and fails at runtime, in production, on the error path — the path with the least test coverage.

A new error variant that nothing handles. Adding { kind: "timeout"; afterMs: number } to ClientError:

src/client.ts:70:13 - error TS2322: Type '{ kind: "timeout"; afterMs: number; }'
is not assignable to type 'never'.

The compiler is telling you exactly which switch you forgot. On a codebase with twenty such switches, that is twenty errors and no reading.

Generics, inference and where suggestions go wrong

Copilot writes basic generics well and over-engineers advanced ones.

The common failure is a generic parameter that is never used in a way that constrains anything — function process<T>(input: T): T where the body ignores T entirely. It looks sophisticated and does nothing. If a suggested generic parameter appears exactly once in the signature, it is almost always a mistake.

The second failure is inference lost to a widened return type. A function that returns string[] when it could return a tuple, or Record<string, unknown> when the object literal had known keys, throws away information the caller needed. as const on a literal, or an explicit tuple type, recovers it.

Where generated generics genuinely help: constraining with extends so the body can use a property (<T extends { id: string }>), and mapped or utility types over an existing interface (Partial<T>, Pick<T, K>, Omit<T, K>) rather than a duplicated interface that will drift.

tsconfig, module resolution and the build

The most frustrating class of TypeScript problem with generated code is not a type error at all. It is a suggestion that is type-correct and will not build, and the cause is almost always module resolution.

moduleResolution: "NodeNext" requires explicit file extensions in relative imports — and, confusingly, the extension you write is the output one, so ./client.ts is imported as ./client.js. moduleResolution: "Bundler" does not want extensions at all. Public code contains both conventions in large quantities, so suggestions arrive in whichever the surrounding file suggests, and in a new empty file they arrive in whichever is more common.

The same applies to verbatimModuleSyntax and type-only imports. With it on, import { type Foo } and import type { Foo } are meaningfully different from a plain value import, and a suggestion that gets it wrong produces a runtime import of a type that does not exist at runtime.

None of this is hard once the file has one correct import in it. The lesson is the same as everywhere else in this cluster: create the file with its first import written, and keep tsconfig.json open. An empty file is the worst possible context.

Two more build-adjacent points:

Declaration files. If a dependency ships no types, a suggestion may invent them or fall back to any without saying so. Check whether @types/… exists for it before accepting a wrapper the model wrote from imagination.

skipLibCheck. Almost every project has it on, and it means type errors inside your dependencies are ignored. That is usually the right trade, but it is worth knowing that a clean compile does not mean your dependency graph type-checks — only that yours does.

React and Node, briefly

Framework technique belongs in its own guides, but two typed-context facts change how generated TypeScript should be reviewed.

In React, the props interface is the prompt. Writing the Props type before asking for the component is the single highest-leverage habit, for the same reason the Result type worked in the example above: it is an unambiguous specification the model cannot misread. Generated components without a props type tend to accept an untyped object and destructure hopefully.

Two React-specific things to check in any generated component: hook dependency arrays — the compiler does not check them, and a missing dependency is a stale closure rather than an error — and event handler types, where any creeps in because the correct DOM event type is verbose.

In Node, the boundary types are the ones that lie. Everything covered in the boundary section above lands hardest on the server: request bodies, environment variables, database rows. process.env.PORT is string | undefined, and a suggestion that does Number(process.env.PORT) without checking produces NaN on a misconfigured deployment and binds to a random port.

For both, the general rule holds: the framework and its major version belong in the prompt until they live in your repository instructions.

Debugging typed code

Debugging in TypeScript splits into two genuinely different activities, and Copilot is good at both for different reasons.

Debugging a type error is a reading problem, and reading is where the model is strongest. A deeply nested inference failure produces an error message hundreds of characters long, full of intermediate types you did not write. Pasting the whole message into chat and asking what it means is reliably good — better, in most cases, than staring at it — because the message is precise, complete context of exactly the kind the model usually lacks.

Ask for the explanation before the fix. “What is this error telling me?” gets you understanding; “fix this error” gets you an as assertion, because the shortest way to make a type error disappear is to assert it away.

Debugging a runtime error is the ordinary JavaScript activity, and everything in that lesson applies. The TypeScript-specific addition is source maps: without them the stack trace points at compiled output and the line numbers are meaningless. If a generated build configuration omits sourceMap or inlineSources, the first production incident is where you find out.

One pattern worth adopting: when the runtime behaviour contradicts the types, you have found a boundary that is not validated. The types say the field is a number; the value is a string; therefore something asserted rather than checked. That contradiction is a reliable pointer to exactly the line to fix.

The escape hatches, and how to review them

This is the section that matters most in this lesson.

A type system does not make generated code correct. It makes wrong code visible — unless the code contains an instruction to stop looking. TypeScript has four of those, and they are the first thing to grep for in any generated diff.

any. Switches off checking for that value and everything derived from it. Sometimes legitimate at a true boundary; almost never legitimate in the middle of a function. unknown is the honest version: it says “I do not know what this is” without also saying “so stop checking.”

as assertions. Tell the compiler a value has a type it has not proved. json as LatencyReport compiles and asserts nothing about the data. The double assertion as unknown as T is a stronger signal still — it exists specifically to defeat the check that the single assertion would have failed.

Non-null !. user!.email asserts the value is present. If the model added it to quiet a strictNullChecks error, it has converted a compile-time question into a runtime crash.

@ts-ignore and @ts-expect-error. The first silences whatever error is on the next line, forever, including errors that appear later for different reasons. @ts-expect-error at least fails when the error goes away, which makes it the strictly better one when a suppression is genuinely needed.

You can enforce this rather than remembering it. typescript-eslint’s no-explicit-any, no-unnecessary-type-assertion, no-non-null-assertion and ban-ts-comment rules turn each of these into a lint failure, which means the review happens before you read the diff.

Over-engineered types, and when to push back

There is a failure mode specific to TypeScript that is worth naming because it does not look like a failure.

Ask for a type and you will sometimes get a conditional type with three nested infer clauses, a recursive mapped type, and a template literal type computing property names. It compiles. It is clever. It is, in most codebases, a liability: the error messages it produces when misused are unreadable, and the next person to change it — including you in three months — will not be able to.

The general shape of the problem is that the model optimises for a type that expresses the constraint, not for one a human can maintain. Those are different objectives, and only one of them was in the prompt.

Two useful counter-prompts:

Copilot promptAsk for the simpler typeCopilot Chat

Simplify this type. Prefer an explicit union or an interface over a conditional or mapped type, even if it means repeating a few members.

If the complexity is genuinely necessary, explain which requirement forces it and what the error message looks like when a caller gets it wrong.

The second half of that prompt is the important part. “What does the error look like when someone misuses this?” is the question that separates a type worth having from a type worth deleting, and it is one the model answers well because it is a reading task.

The inverse also happens: a type that is too loose. Record<string, any> for a config object with eight known keys, string for a value that is one of four literals, object where an interface belonged. These are easier to spot and easier to fix, and they are worth fixing, because every widened type is a place the compiler stopped helping you review generated code.

The heuristic that covers both: the type should be exactly as specific as the thing it describes. Looser and the compiler stops catching mistakes; tighter and cleverer, and the compiler starts producing errors nobody can act on.

Types at the boundary: the thing TypeScript cannot do

TypeScript is erased at runtime. It checks your code; it cannot check your data.

Every place data enters your program from outside — an HTTP response, a database row, a message queue, JSON.parse, process.env, a form submission — the compiler’s guarantees stop. Generated code routinely papers over this with an assertion, because the assertion compiles and a validator is more work.

Three acceptable answers, in ascending order of rigour:

  1. A hand-written type predicate, as in the worked example above. No dependency, and it makes the check explicit.
  2. A schema validation library, which gives you the validator and the type from a single declaration.
  3. Generated types from a schema you already have — an OpenAPI document, a database schema, a protobuf definition — so the boundary type cannot drift from the source.

What is not acceptable is as. Ask for validation explicitly, every time, at every boundary.

Testing and refactoring

Types reduce the number of tests you need but do not eliminate them. The division of labour is clean: the compiler checks shapes, the tests check behaviour. A generated test suite that only asserts on shapes is duplicating the compiler’s work and testing nothing.

For refactoring, TypeScript is where Copilot is at its best in this entire cluster. A rename, an extracted interface, a changed signature — the compiler enumerates every affected call site. Combine that with agent mode and a large mechanical refactor becomes a loop: change, compile, fix what the compiler names, repeat until clean. That loop is verifiable in a way the same refactor in Python or Ruby simply is not.

The one caution: a refactor that removes type errors by adding assertions has not refactored anything. Check the diff for new escape hatches before you accept it.

Review workflow

Accepting a TypeScript suggestion
  1. Run tsc --noEmitSeconds. Eliminates every shape error before you read a line.
  2. Grep the diff for any, as, ! and @ts-Human judgementEach occurrence is a place the model gave up. There should be a reason for every one.
  3. Check the boundaries are validatedHuman judgementJSON.parse, fetch responses, environment variables, database rows. An assertion is not validation.
  4. Check the unions are exhaustiveHuman judgementIs there a never guard, or will a new variant fail silently?
  5. Read the error pathHuman judgementThe compiler proves the happy path's shapes. It says nothing about whether failure is handled.
  6. Run ESLint and the tests

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

  • Turn on strict, plus noUncheckedIndexedAccess and exactOptionalPropertyTypes. Without them the compiler cannot do the job this lesson is asking it to do.
  • Write the types first and ask for an implementation that satisfies them.
  • Ban the escape hatches with lint rules rather than with willpower.
  • Validate at every boundary with a predicate or a schema, never with as.
  • Model error states as a union, not as thrown unknown.
  • Use never exhaustiveness guards on any union you expect to grow.

Common mistakes

  • Treating a clean compile as a clean review. It proves shapes, not behaviour.
  • Accepting as on parsed JSON. That is where types stop and data begins.
  • Letting any in at a boundary and losing checking through the whole call chain.
  • Adding ! to silence a null check instead of handling the null.
  • Writing generics that constrain nothing because they look more general.

Where to go next

GitHub Copilot for JavaScript covers the runtime, async and npm concerns this lesson deliberately skipped. GitHub Copilot with VS Code covers the editor integration, and the Language Guide pillar puts TypeScript’s compiler in context against the other eleven languages here.

Sources

Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.

Primary sources