skip to content

Generics That Do Nothing & Other Misuse

The review-time smell: a type parameter that appears exactly once is not polymorphism, it is a cast the caller controls. Knowing when NOT to reach for a generic is a strong signal of type-system maturity.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

4

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?

level: middleimportance: must knowfreq 70%

answer

  1. where does T appear in the signature?
  2. no argument mentions T
  3. the caller supplies the type, not the code
  4. same thing as an `as` cast
  5. derive T from a validator instead

basics

~20 s

Nothing. 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 s

That 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 lines
typescript
declare 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

Compare these two TypeScript signatures: `function getFirst<T>(arr: T[]): T` and `function getLength<T>(arr: T[]): number`. Why does the type parameter earn its place in only one of them, and how would you rewrite the other?

level: juniorimportance: should knowfreq 45%

basics

~20 s

A type parameter earns its place only when it links two positions in a signature. getFirst returns T, so it carries the element type back to the caller; getLength never uses T again, so it should just take unknown[].

open as a page

In TypeScript, when is `declare function setTheme<T extends 'light' | 'dark'>(theme: T): void` better written as `declare function setTheme(theme: 'light' | 'dark'): void`, and when does the generic version genuinely earn its keep?

level: middleimportance: should knowfreq 45%

basics

~20 s

In that exact shape the generic is pure noise — both signatures accept identical arguments, because T is used once. A constrained type parameter only earns its keep when another parameter or the return type is expressed in terms of T.

open as a page

A TypeScript service has an internal helper `query<T>(sql: string): Promise<T[]>` called from hundreds of places, each passing its own row type, and several of those row types no longer match the database. Why will the compiler never report this, and how would you get the codebase back to safety?

level: seniorimportance: should knowfreq 40%

basics

~20 s

T appears only in the return type, so each call site asserts rather than proves its row type and the checker has nothing to compare it against. The fix is a signature that forces the type to be produced — returning unknown or requiring a decoder — so every unsafe site becomes a compile error.

open as a page