GitHub Copilot for 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.
TypeScript quick referenceVerified August 21, 2026
- Type system
- Statically checked at compile time, erased at runtime; `any` and assertions can switch the checking off locally.
- Package management
- npm (also pnpm, yarn)
- Manifest Copilot should see
- package.json plus tsconfig.json
- Testing
- Vitest, node:test, Jest
- Formatting
- Prettier
- Linting
- ESLint with typescript-eslint
- Static analysis
- tsc --noEmit
- Common frameworks
- React, Next.js, NestJS, Express
- Typical Copilot work
- Typed API clients, interface and generic definitions, discriminated unions, typed tests
- First thing to review
- Assertions and `any` used to silence the compiler instead of fixing the type
Fastest honest checktsc --noEmit
Key takeaways
- Types work in both directions. A precise type improves the suggestion going in, and rejects a wrong one coming out. Only the second half is unique to TypeScript.
tsc --noEmitis the single cheapest, highest-value review step available in any language in this cluster. Run it before you read the diff.- The failure mode is not wrong types — the compiler catches those. It is the
escape hatches:
any,as, non-null!, and@ts-ignore. Grep for them in every generated diff. - Strict mode is not a style preference here. Without
strict, most of what makes TypeScript useful for reviewing generated code is switched off. - Discriminated unions and exhaustive
switchstatements convert “did the model handle every case?” from a reading problem into a compile error.
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.
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:
noUncheckedIndexedAccessmakesarr[0]have typeT | undefined. Generated code indexes arrays constantly and almost never checks the bounds. This setting converts that habit into a compile error.exactOptionalPropertyTypesdistinguishes “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/ ├── 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: unknownrather thanconst parsed: LatencyReport.response .json()returnsany— annotating it asunknownforces 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 = fetchmakes 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:
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:
- A hand-written type predicate, as in the worked example above. No dependency, and it makes the check explicit.
- A schema validation library, which gives you the validator and the type from a single declaration.
- 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
- Run tsc --noEmitSeconds. Eliminates every shape error before you read a line.
- 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.
- Check the boundaries are validatedHuman judgementJSON.parse, fetch responses, environment variables, database rows. An assertion is not validation.
- Check the unions are exhaustiveHuman judgementIs there a never guard, or will a new variant fail silently?
- Read the error pathHuman judgementThe compiler proves the happy path's shapes. It says nothing about whether failure is handled.
- 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, plusnoUncheckedIndexedAccessandexactOptionalPropertyTypes. 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
neverexhaustiveness guards on any union you expect to grow.
Common mistakes
- Treating a clean compile as a clean review. It proves shapes, not behaviour.
- Accepting
ason parsed JSON. That is where types stop and data begins. - Letting
anyin 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.
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.