A TypeScript HTTP helper is declared as `declare function fetchJson<T>(url: string): Promise<T>`, so callers write `fetchJson<User>('/api/me')`. What does that type parameter actually guarantee, and how would you type the helper instead?
answer
- where does T appear in the signature?
- no argument mentions T
- the caller supplies the type, not the code
- same thing as an `as` cast
- derive T from a validator instead
basics
~20 sNothing. T appears only in the return type, so there is no inference site and the caller simply picks it — an unchecked assertion, equivalent to as User. Return Promise<unknown>, or take a validator parameter so T is derived from real runtime code.
solid answer
~50 sThat type parameter guarantees nothing about the response. T is written only in the return position, so there is no argument for the compiler to infer it from; whatever the caller writes in the angle brackets is what the checker believes. `fetchJson<User>(url)` is the same unchecked assertion as `JSON.parse(text) as User`, just distributed across every call site where it is much harder to spot in review. When the server sends a different shape, the failure surfaces far away as an undefined property, and no type error ever appears. I would either return `Promise<unknown>` and force each caller through an explicit narrowing step, or make the type real by taking a validator: `getJson<T>(url: string, parse: (raw: unknown) => T): Promise<T>`. Now T is inferred from code that actually runs, and it appears twice instead of once.
code
typescript · 35 linesdeclare function readJson(url: string): Promise<unknown>;
interface User {
id: string;
name: string;
}
// Antipattern: T appears only in the return type, so the caller picks it.
declare function fetchJson<T>(url: string): Promise<T>;
async function unchecked(): Promise<string> {
const u = await fetchJson<User>("/api/me"); // u: User, never verified
return u.name; // may be undefined at runtime
}
// Fix: T is inferred from a parser that actually runs.
async function getJson<T>(url: string, parse: (raw: unknown) => T): Promise<T> {
return parse(await readJson(url));
}
function toUser(raw: unknown): User {
if (
typeof raw === "object" && raw !== null &&
typeof (raw as Record<string, unknown>).id === "string" &&
typeof (raw as Record<string, unknown>).name === "string"
) {
return raw as User;
}
throw new Error("Not a User");
}
async function checked(): Promise<string> {
const u = await getJson("/api/me", toUser); // u: User, inferred from toUser
return u.name;
}go deeper
Recall that a type argument you type yourself is a promise to the compiler, not a check. Say out loud that JSON arriving from a network is not verified just because the call site names a type.
Explain inference sites: T only gets a value from arguments, and a string URL supplies none, so the caller's annotation is taken on trust. Show the rewrite that passes a parse function so T is inferred.
Demonstrate boundary judgment — one validation layer where external data enters, types derived from the same source as the runtime check, and a plan for migrating existing call sites without a codebase-wide red build.
Own the policy question: which contracts are generated from a schema, who owns the schema, and what the team's rule is for asserting types at I/O boundaries so this pattern does not reappear under a new name.
## What the caller is really doing A generic function is checked by *inference*: the compiler looks at the arguments, matches them against the parameter types, and solves for each type parameter. In `fetchJson<T>(url: string): Promise<T>` the only parameter is a `string`, which mentions T nowhere. There is no inference site, so the type parameter has no relationship to anything in the call. Writing `fetchJson<User>('/api/me')` does not *check* that the result is a `User`; it *declares* it. The compiler has nothing to compare that claim against, so it accepts it. The honest desugaring of the pattern is: ```ts // what the signature promises const u: User = await fetchJson<User>("/api/me"); // what it actually does const u = (await someRawJson("/api/me")) as User; ``` Both are assertions. The difference is optics: `as User` reads as a claim a reviewer will challenge, while `fetchJson<User>(...)` reads as "typed API client" and sails through. ## Why the checker cannot object Types are erased. There is no `T` at runtime, so `fetchJson` cannot inspect the parsed value even if it wanted to; the body ends at `JSON.parse`, which is declared in the standard library as returning `any`. Everything downstream is the compiler taking the caller at their word. This is one of TypeScript's deliberate unsoundness escape hatches — the type system models data it never sees, and at an I/O boundary the data is genuinely unknown until something checks it. The consequences are the ones you should be able to name in an interview: - A renamed or removed field on the server produces **no** compile error anywhere; you find out when a property is `undefined` three layers away. - Optional versus required is not enforced: declaring `name: string` when the API sometimes omits it means the compiler will refuse to let you handle the `undefined` case, because as far as it knows there isn't one. - Refactors are validated against the lie, not the data, so the type system's main benefit at that boundary is gone precisely where you most wanted it. ## What T infers when you omit the type argument If a caller writes `await fetchJson('/api/me')` with no explicit type argument, T is an unconstrained type parameter with no inference candidates, and TypeScript resolves it to `unknown`. That is a small mercy: the un-annotated call is the *safe* one, because `unknown` forces narrowing before use. It also tells you what the signature should have said in the first place. ## Making the type parameter real The rule from this topic — a type parameter must appear at least twice — points straight at the fix: give T a second appearance in a position derived from code that runs. ```ts declare function readJson(url: string): Promise<unknown>; async function getJson<T>(url: string, parse: (raw: unknown) => T): Promise<T> { return parse(await readJson(url)); } ``` Now T is *inferred* from the `parse` argument rather than asserted by the caller. If `parse` is a hand-written type predicate or a schema library's parser, the returned type and the runtime check come from the same source, so they cannot drift apart independently. A schema library gives you the same shape: the schema is the value, the static type is derived from it, and the parse call is the only place a type claim is made. The minimal alternative, when you do not want to force validation into the helper, is: ```ts declare function fetchJson(url: string): Promise<unknown>; ``` Callers must then narrow — with a type predicate, an assertion function, or a parser — before touching a property. The unsafe call sites do not disappear, but they become visible: each one now needs a deliberate step you can review. ## When the pattern is defensible It is not always wrong. A generated client where the type argument is filled in by codegen from an OpenAPI or GraphQL schema has an external guarantee that hand-written annotations lack — the type and the server contract come from one source, and drift is caught by regenerating. Likewise an internal wrapper is fine if validation demonstrably happens elsewhere on every path. The failure mode is the *hand-written* type argument, chosen by whoever wrote the call site, checked by nobody. ## Reviewing for it The scan is quick: find every exported function whose type parameter appears only in the return type. Common offenders beyond HTTP clients are database query helpers (`query<Row>(sql)`), cache and key-value reads (`get<T>(key)`), configuration lookups, message-queue payload readers, and anything wrapping `JSON.parse`. In each case ask what evidence the function has for the type it is returning. If the answer is "whatever the caller typed", the signature should say `unknown` and let the caller do the narrowing where it can be seen.
- If the un-annotated call already infers `unknown`, why not keep the generic and just tell people not to pass a type argument?Because the knob still exists and someone will turn it — usually to silence an error at the point of use. A signature that returns `Promise<unknown>` removes the option entirely, which is the point. If you need an escape hatch for a hot path, make it an explicit `as` at the call site so review can see it.
- How is this different from a type predicate like `function isUser(x: unknown): x is User`?A predicate is also trusted rather than verified, but it puts the claim in one reviewable function next to the runtime checks that justify it, and it is used at a single narrowing point. The return-only generic scatters the same claim across every call site with no accompanying check anywhere.
- Does adding a constraint such as `<T extends object>` help?Barely. It stops callers asking for a primitive, but T is still chosen by the caller with no inference site, so any object shape they name is accepted unchecked. The constraint narrows the set of available lies; it does not create evidence.
- What would you do about hundreds of existing call sites that already pass a type argument?Introduce the safe function alongside, migrate module by module, and only then delete the old one. Widening the old return type to `unknown` in one commit turns the entire codebase red at once, which usually gets reverted rather than fixed.
Writing the type argument is like labelling a sealed box before you open it. The label makes everyone downstream confident about the contents, but nothing ever compared the label with what is inside.
saying these in an interview costs you the question
- The generic validates the response shape at runtime
- T is inferred from the JSON body
- It is safer than any because it is generic
- Passing the type argument is the same as validating with a schema
- If it compiles, the API response must match the type