skip to content

A codebase fetches JSON with `async function getJson<T>(url: string): Promise<T> { const res = await fetch(url); return res.json(); }`. Why is this signature a type-safety hole, and what would you return instead?

level: seniorimportance: should knowfreq 52%

answer

  1. T appears only in the return type
  2. inferred from nothing but the annotation
  3. Response.json() is typed any
  4. the any funnel into a generic
  5. unknown forces the caller to check

basics

~20 s

Nothing decides T except the caller's annotation, and the body compiles only because the DOM lib types Response.json() as Promise<any>. The signature is a disguised cast. Return Promise<unknown> and force a guard, or take a validating parser that infers T.

solid answer

~40 s

`T` here is inferred from nothing: it appears only in the return type, so the caller's type argument or annotation invents it outright. Inside, `res.json()` is declared as `Promise<any>` in the DOM library, and `any` is assignable to every `T`, which is the only reason the body type-checks — the `any` is laundered into a precise-looking generic. The result is an `as T` wearing a helper's clothes, applied at every call site and never reviewed. I would declare the return type `Promise<unknown>`, which forces each caller through a guard or parser, or better, make the helper take the validator: `getJson<T>(url: string, parse: (raw: unknown) => T)` so `T` is inferred from something that actually checks the bytes. Then the type parameter is earned rather than asserted.

go deeper

for a junior

Recognise that a type argument you write yourself is a claim, not a check — nothing compares the server's JSON to User. Know that unknown forces you to check before use.

for a middle

Explain the mechanism: T appears only in the return position so nothing infers it, and Response.json() is typed Promise<any>, which is assignable to every T and silences the body.

for a senior

Show how you would repair a shared helper already used in a hundred places: return unknown or thread a parser through so T is inferred from a real validator, and handle non-ok responses at the boundary.

for a principal

Frame the trust boundary for the organisation — where generated contracts let you skip runtime validation, where payloads must be parsed, and who owns the failure when a server-side rename ships without a client change.

## Why the signature compiles ```ts async function getJson<T>(url: string): Promise<T> { const res = await fetch(url); return res.json(); } const user = await getJson<User>('/api/me'); // user: User, checked by nobody ``` Two things make this work, and both are worth naming in an interview. First, **`T` is an unconstrained, uninferable type parameter**. It appears in no parameter position, so there is no argument for the compiler to infer it from. The only input is what the caller writes — `getJson<User>(...)` or `const u: User = await getJson(...)`. A type parameter that only appears in the return type is not generic programming; it is a request form for a cast. Second, **`Response.json()` is declared as `json(): Promise<any>`** in TypeScript's DOM library. `any` is assignable to every type, so returning it satisfies `Promise<T>` for any `T` whatsoever. The `any` never surfaces in a diagnostic — it is absorbed silently and re-emerges as a fully-typed value. This is the classic `any` funnel: one weakly-typed API at the boundary, one generic wrapper, and the entire codebase downstream believes it has checked data. ## Why this is worse than an ordinary cast An `as` is at least visible at the point of the lie. This helper moves the lie into a shared utility and then multiplies it: every feature team calls it, each supplies a different `T`, none of them writes a check, and the code review that would have flagged `as User` never happens because the call site looks impeccable. The compiler now vouches for `user.profile.email` in a hundred places on the strength of a server contract nobody validated. When the API renames a field or an error envelope comes back with the 200, the `TypeError` lands in a component that never heard of `getJson`. ## The fixes, in increasing order of honesty **Return `unknown`.** The minimal change, and it costs no dependency: ```ts async function getJson(url: string): Promise<unknown> { const res = await fetch(url); return res.json(); } const raw = await getJson('/api/me'); if (!isUser(raw)) throw new Error('bad /api/me payload'); raw.email; // narrowed, and the narrowing was earned ``` `unknown` is the honest type of bytes you have not inspected: you cannot read a property, call it, or pass it where a `User` is wanted until you narrow it. The type system now *forces* the guard rather than suggesting one. The cost is that every call site must handle failure — which is the point, not a regression. **Take the parser as a parameter.** Keeps the generic, but earns it: ```ts async function getJson<T>(url: string, parse: (raw: unknown) => T): Promise<T> { const res = await fetch(url); return parse(await res.json()); } const user = await getJson('/api/me', parseUser); ``` Now `T` is inferred *from the parser's return type*, so the only way to get a `User` out is to pass something that produces one from `unknown`. The signature can no longer be satisfied by wishful thinking, and a schema library's parse function drops straight into that slot. **Check the response before the body, too.** `fetch` does not reject on 4xx/5xx, so a helper that goes straight to `.json()` will happily hand you a parsed error envelope typed as your success shape. Inspect `res.ok` and the status before parsing. ## When the generic is defensible If the types are *generated* from the same source as the server's contract — an OpenAPI or GraphQL codegen pipeline, an internal RPC layer where both ends build from one schema — then the annotation is not a guess, and validating every payload may be paying twice. Even then the honest framing is "we validate at the schema-generation boundary and trust it here", which is a decision with an owner. What is not defensible is hand-written `T`s over a hand-maintained API, which is the situation this helper is almost always found in. ## The interview point The question is not really about `fetch`. It is whether you can spot a type parameter that carries no evidence, and whether you know that `any` at a library boundary silently validates any generic signature you write over it. Recognising that `Promise<T>` here means exactly `Promise<any> as Promise<T>` is the whole answer.

  • If `T` is not supplied and cannot be inferred, why does the compiler not just reject the call?
    Because the signature is legal — a type parameter is allowed to appear only in the return position, and TypeScript falls back to a default inference rather than erroring. The problem is not a missing diagnostic; it is that the declaration asks the caller to name a type and promises to deliver it, while the body has no way to keep that promise.
  • Would constraining the parameter, say `<T extends object>`, make the helper safer?
    No. A constraint restricts which types the caller may name; it does not verify the payload against any of them. `getJson<User>` still returns whatever the server sent, typed as `User`. Constraints are about what the type parameter may be, never about what the runtime value is — no constraint can close a hole that exists because nothing checks the bytes.
  • What breaks if the endpoint returns a 500 with a JSON error body?
    `fetch` resolves for any HTTP status, so `res.json()` parses the error envelope and the helper narrows it to the success type. Callers then read fields that do not exist. A boundary helper must inspect `res.ok`/`res.status` and the content type before parsing, and represent failure in its return type rather than pretending every response is the happy path.
  • Is returning `Promise<unknown>` ever the wrong call?
    When the types are generated from the same schema the server serves — OpenAPI or GraphQL codegen, or an internal RPC layer — the annotation is not a guess, and re-validating every payload can be redundant work. That is a deliberate trust decision with an owner. It is not a licence for hand-written type arguments over a hand-maintained API.

saying these in an interview costs you the question

  • Believes the generic makes the response type-checked
  • Thinks T is inferred from the JSON the server returns
  • Adds an extends constraint and calls it validated
  • Assumes fetch rejects on a 500 response
  • Says any and unknown are interchangeable at the boundary

context