TL;DR: AI-generated TypeScript regularly compiles and still doesn't match your project's real settings: an implicit any only your tsconfig.json catches, an optional field read without a guard, a generic missing its keyof constraint. Turning on strict mode helps but doesn't close every gap on its own. Prompt for the exact flags you run, then verify with tsc --noEmit, not the model's word that it compiles.
Why Does AI-Generated TypeScript Compile and Still Fail Your Build?
Ask any general-purpose coding assistant for a TypeScript function and you'll almost always get something that parses: correct syntax, sensible names, a return type that looks reasonable. That's a much lower bar than passing your actual build, and the two get confused constantly, because "it compiled in the chat window" and "it compiles under your project's tsconfig" are different claims.
They're different for a simple reason: a model producing TypeScript is not running a compiler. It's predicting tokens that resemble correct TypeScript, which is usually close enough, right up until your project enforces a rule the model didn't account for, like a specific tsconfig.json you never pasted in, an optional field the model assumed was always present, or a generic parameter it left too loose to index safely. None of that shows up as broken-looking code. It shows up as a real error the moment tsc actually runs against your settings, and every example below is exactly that: a snippet that reads fine and a compiler that disagrees.
A lot of that gap is inherited, not accidental. A large share of the TypeScript a model has seen during training comes from tutorials, Stack Overflow answers, and starter templates written against whatever a project's tsconfig happened to be at the time, which is frequently looser than what you actually run. The model isn't wrong to reproduce those patterns; it has no way of knowing your settings differ unless you say so. That's the whole argument for treating your tsconfig as part of the prompt rather than an assumed backdrop: the code that's statistically typical is not the same as the code your build will accept.
Every snippet in this post was written to a scratch file and checked with tsc --noEmit, using TypeScript 7.0.2 and the config below. Where a snippet is shown broken on purpose, the error under it is pasted verbatim from that run, not reconstructed from memory.
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"skipLibCheck": true
}
}
Which tsconfig Settings Actually Decide What strict Means?
strict isn't one setting. It's a switch that flips several narrower ones together, and prompting for generically strict TypeScript without knowing which of them you actually run is how a model satisfies the word without satisfying your build. Run tsc --help --all against the compiler you actually use: on TypeScript 7.0.2, eight flags list their own documented default as "true, unless strict is false", specifically noImplicitAny, noImplicitThis, strictBindCallApply, strictBuiltinIteratorReturn, strictFunctionTypes, strictNullChecks, strictPropertyInitialization, and useUnknownInCatchVariables.
The two that produce almost every AI-generated type error worth naming are noImplicitAny, which flags a parameter or variable with no annotation and no inferred type, and strictNullChecks, which makes null and undefined part of the type system instead of silently assignable to anything. A model unaware you run both will hand you code that's fine under a loose config and broken under yours.
| Feature | Loose config (strict omitted or false) | Strict config (strict: true) |
|---|---|---|
| Parameter with no type annotation | Compiles; parameter is any | TS7006: implicitly has an 'any' type |
| Optional property read without a guard | Compiles; may be undefined at runtime | TS18048: possibly 'undefined' |
| Generic indexed without a keyof constraint | Compiles; index resolves to any | TS7053: no index signature was found |
| Explicit `: any` annotation | Compiles | Still compiles — strict does not forbid it |
That last row is the trap worth remembering on its own.
Why Doesn't Strict Mode Stop the Model From Writing any?
Because noImplicitAny only catches an implicit any: a parameter or variable the model never annotated, left for the compiler to infer. It says nothing about an explicit one. This is easy to demonstrate directly.
Deliberately broken, to show the real error: an unannotated parameter under strict: true.
function parseConfig(input) {
return {
port: input.port,
host: input.host,
};
}
implicit-any.ts(1,22): error TS7006: Parameter 'input' implicitly has an 'any' type.
Now the same function, with the model doing exactly one thing differently: writing any on purpose instead of omitting the type.
function parseConfig(input: any) {
return {
port: input.port,
host: input.host,
};
}
That version compiles clean under the identical strict config, exit code 0. Nothing about port or host is checked; every property access is silently allowed; and the file still passes your build. strict: true never looked at it, because there's nothing implicit left to flag.
How Do You Prompt So Optional Fields Don't Crash at Runtime?
strictNullChecks is the other half of the pair, and it's the one that catches a pattern that shows up constantly in AI-generated interfaces: a field marked optional, then read as if it's guaranteed.
Deliberately broken: reading an optional property with no guard.
interface User {
name: string;
nickname?: string;
}
function shout(user: User): string {
return user.nickname.toUpperCase();
}
user.ts(7,10): error TS18048: 'user.nickname' is possibly 'undefined'.
That same file compiles without complaint if strictNullChecks is off, and then fails at runtime instead of at build time, the moment a real User shows up without a nickname. The fix a model should reach for is narrowing before use, not silencing the error:
interface User {
name: string;
nickname?: string;
}
function shout(user: User): string {
if (user.nickname === undefined) {
return user.name.toUpperCase();
}
return user.nickname.toUpperCase();
}
Telling the model to handle optional fields in the abstract rarely produces this. Telling it that every optional property must be narrowed with a guard before it's read, with the fallback path shown, usually does, because it turns a style preference into a checkable instruction.
Why Does a Discriminated Union Still Break Even Under Strict Settings?
This one is worth isolating because it's not gated by strict at all; it's how TypeScript's structural typing works regardless of your config, and it trips up generated code just as often as the strict-only errors above.
Deliberately broken: accessing .value before checking which branch of the union you're in.
type Result<T> =
| { success: true; value: T }
| { success: false; error: string };
function unwrap<T>(result: Result<T>): T {
return result.value;
}
result.ts(6,17): error TS2339: Property 'value' does not exist on type 'Result<T>'.
Property 'value' does not exist on type '{ success: false; error: string; }'.
That error appears identically whether strict is true or false, because it's a plain structural fact: half the union has no value property, and TypeScript won't let you read one without first narrowing which half you're looking at. A model that hasn't been told your result type is a discriminated union will often write exactly this, especially when it's generalizing from a language where the equivalent type is looser. The fix is a guard on the shared discriminant field:
type Result<T> =
| { success: true; value: T }
| { success: false; error: string };
function unwrap<T>(result: Result<T>): T {
if (result.success) {
return result.value;
}
throw new Error(result.error);
}
Naming the discriminant field in your prompt (success, ok, kind, whatever your actual type uses) and asking for a guard on it before any variant-specific property is read removes almost all of this class of error before it happens.
What Do You Have to Tell the Model About Generics?
Generics are where an unconstrained type parameter quietly becomes an any in disguise, and it's a pattern that reads as generic, therefore flexible, therefore fine, right up until you index into it.
Deliberately broken: a pluck helper with no constraint on the key parameter.
function pluck<T>(items: T[], key: string) {
return items.map((item) => item[key]);
}
pluck.ts(2,30): error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'unknown'.
No index signature with a parameter of type 'string' was found on type 'unknown'.
The compiler has no idea what T will be at the call site, so it has no idea whether key is even a legal property on it, and it refuses to pretend. Constraining the key parameter to keyof T fixes it and gets you a genuinely typed return value instead of a permissive one:
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
This is worth stating directly in a prompt rather than assuming the model will infer it: generic helper functions that take a property key must constrain that parameter to keyof T, not string. Left unstated, a model reaches for string far more often, because it's the shorter thing to write and it still reads as reasonable.
A Prompt Template for Types That Actually Check
Put together, the additions above are short enough to keep in every prompt that asks for typed code, not just the ones where you remember to add them:
Write TypeScript against this tsconfig (paste yours, or state "strict: true,
target ES2022" if you don't have one yet):
- Never use `any`, explicit or implicit. If you can't determine a type,
use `unknown` and narrow it, and say so in a comment.
- Every optional property must be narrowed with a guard before it's read;
show the fallback path, don't just suppress the error.
- Any discriminated union must be narrowed on its discriminant field
(name the field) before a variant-specific property is accessed.
- Generic helpers that index by property key must constrain that
parameter with `keyof T`, not `string`.
- After writing the code, state whether you expect it to pass
`tsc --noEmit` against the config above, and why, in one sentence.
That last line doesn't make the model's answer authoritative. It's there so a wrong guess is visible and checkable, instead of buried in code that looks finished.
Here's what output that actually follows this template looks like, compiled clean against the strict config above:
type ParseResult<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function parsePort(raw: string | undefined): ParseResult<number> {
if (raw === undefined) {
return { ok: false, error: "missing PORT" };
}
const value = Number(raw);
if (Number.isNaN(value)) {
return { ok: false, error: `PORT is not a number: ${raw}` };
}
return { ok: true, value };
}
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
function report(rawPort: string | undefined): string {
const result = parsePort(rawPort);
if (result.ok) {
return `listening on ${result.value}`;
}
return result.error;
}
No any, a narrowed discriminated union, a constrained generic. tsc --noEmit on this file exits 0.
How Do You Verify the Model's Claim That It Compiles?
You run the compiler. That sounds obvious and it's the step that most often gets skipped, because a plausible-looking answer with a confident closing sentence reads as finished. It isn't finished until tsc --noEmit says so.
Asking the model directly, "are you sure this typechecks?", is not a substitute, and it's worth knowing why: the follow-up question doesn't give the model access to a compiler it didn't have a moment ago. It can only produce another prediction, phrased as confidence, about code it never executed. That answer can be right by coincidence as often as it's wrong, and neither outcome tells you anything you couldn't get faster by just running tsc yourself.
Two situations, two different moves:
A chat-only assistant (a browser tab, no shell access) cannot run your compiler at all. Anything it tells you about whether its own code compiles is a prediction, not a result. Copy the output into your own project, run tsc --noEmit yourself, and paste the exact error back if it fails. Don't paraphrase the error; the model reasons better from the real message than from your summary of it.
An agentic coding tool that can execute shell commands, Claude Code, Cursor's agent mode, or anything else that follows an AGENTS.md-style instruction file, is different: it genuinely can run the compiler itself. Say so directly in the instructions: run tsc --noEmit after every change, and don't report the task done until it exits 0. That turns "I believe this typechecks" into something the tool actually checked before telling you so.
One CLI detail worth knowing if you script this: on TypeScript 7.0.2, running tsc with a filename on the command line while a tsconfig.json also sits in that directory refuses outright, error TS5112: tsconfig.json is present but will not be loaded if files are specified on commandline. Use '--ignoreConfig' to skip this error. Run bare tsc (or tsc --project .) so it actually reads your config, instead of silently checking the file against compiler defaults that don't match what your build enforces.
Stop rewriting prompts. Start shipping.
Works with ChatGPT, Claude, Gemini, Grok, Midjourney, Ideogram, Veo3 & Kling. 4.8★ on the Chrome Web Store.
Create An AccountNone of this is really about TypeScript being unusually strict; it's schema validation for the shape of code instead of the shape of data, and a model that hasn't seen your actual schema will guess at it the same way it guesses at any missing constraint. For the review pass that catches what a typed prompt still misses, prompting for a genuinely useful code review covers the next step. If you're pairing this with an editor-level rules file, writing .cursorrules that actually help is the equivalent for Cursor specifically. And if a strictness brief like the template above is worth keeping instead of retyping, what actually belongs in a CLAUDE.md is where to put it permanently. Emotional framing won't help here either, this is one of the few prompt-quality questions with a pass/fail answer instead of a matter of tone; see does emotional prompting work for the cases where it does and doesn't.