A typo on a parsed payload compiles cleanly under strict — diagnose why
The project runs with strict and noImplicitAny on. total is misspelled as totl, and formatOrder is handed a number where an Order is declared — yet tsc reports nothing and the bug is found only in production.
Diagnose why the compiler is silent, and state the fix for the boundary.
interface Order { id: string; total: number }
declare function formatOrder(order: Order): string;
async function report(url: string) {
const res = await fetch(url);
const data = await res.json();
const sum = data.totl * 1.2; // no error?
return formatOrder(data.total); // no error either?
}
Diagnose the cause.
Follow the any upstream. res.json() is declared to return any, so data and everything derived from it are any too, and the checker stops flagging typos and wrong arguments. noImplicitAny never fires — this any is explicit.
- ✗Expecting
noImplicitAnyto catch ananythat a library declared explicitly - ✗Annotating
dataasOrderand mistaking that annotation for a runtime check - ✗Blaming
strictfor being off rather than tracing where theanyentered
- →Which lint rule would have surfaced this
anyat review time? - →Why does typing
dataasunknownforce the bug to the surface immediately?
The cause
Neither strict nor noImplicitAny is at fault. Both are on and both are working. The culprit is an explicit any in the library's own typings:
// lib.dom.d.ts
interface Body {
json(): Promise<any>; // ← here it is
}
data is therefore any. And any is contagious: every operation on it produces any again.
const data = await res.json(); // any
const sum = data.totl * 1.2; // any * number → number; nobody checks the typo
formatOrder(data.total); // any is assignable to Order — no complaint
noImplicitAny only catches an implicit any (an unannotated parameter). This one is declared explicitly, so the flag stays silent by design.
The fix
Type the boundary as unknown and validate it. unknown accepts the same values but forces a narrowing before use — so the compiler starts objecting again.
async function report(url: string): Promise<string> {
const res = await fetch(url);
const data: unknown = await res.json(); // ✅ checking is back on
if (!isOrder(data)) throw new Error('Bad order payload');
const sum = data.totl * 1.2; // ❌ error TS2339: no property totl on Order
return formatOrder(data); // ✅ data has narrowed to Order
}
function isOrder(value: unknown): value is Order {
if (typeof value !== 'object' || value === null) return false;
const c = value as Record<string, unknown>;
return typeof c.id === 'string' && typeof c.total === 'number';
}
⚠️ await res.json() as Order is not a fix: as is erased and checks nothing — it merely moves the same lie from any onto Order. A runtime check is what is required.