skip to content

Generic API Patterns & Pitfalls

Applying all of the above to real API shapes: builders and factories, generic UI components, and the misuse patterns reviewers flag. Interviewers use these to see whether your generics buy safety or just add angle brackets.

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

explore

questions

12

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

In a .tsx file, how do you type the props of a reusable List component so that its renderItem callback receives the element type of the items array instead of any, and where does that type come from at each usage?

level: middleimportance: must knowfreq 62%

basics

~20 s

Declare the type parameter on the component function and thread it through the props: function List<T>(props: { items: T[]; renderItem: (item: T) => ReactNode }). TypeScript infers T from the items passed at each usage site.

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 a .tsx file, `const identity = <T>(value: T) => value;` fails to compile although the identical line is fine in a .ts file. Why, and what are two ways to write it so it parses?

level: juniorimportance: should knowfreq 45%

basics

~20 s

In .tsx files the parser reads the leading angle bracket as the start of a JSX element, not a type parameter list. Disambiguate with a trailing comma, <T,>, add a constraint such as <T extends unknown>, or use function syntax.

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

In TypeScript, how do you type a fluent builder so the type of the object being built grows with each chained call, and what goes wrong if every chaining method returns the builder's own type unchanged?

level: middleimportance: should knowfreq 40%

basics

~20 s

Make the builder generic in what it has accumulated so far and have each chaining method return a new instantiation, such as Builder<T & Record<K, V>>, rather than Builder<T>. Returning the same type throws away whatever the call just added.

open as a page

How do you type the `ok()` and `err()` constructor helpers of a generic `Result<T, E>` union so a function can return both without annotating T and E at every call site?

level: middleimportance: should knowfreq 45%

basics

~20 s

Give each helper a type parameter only for the branch it actually fills: ok<T>(value: T): Result<T, never> and err<E>(error: E): Result<never, E>. Because never is assignable to anything, both results fit the function's declared Result type.

open as a page

A generic custom hook ends with `return [value, setValue];` and has no return type annotation. Why does the caller's destructured setValue come out with a union type, and how do you type the hook so the pair destructures correctly?

level: middleimportance: should knowfreq 42%

basics

~20 s

An array literal is inferred as an array of the union of its element types, so both destructured names get value-or-setter. Annotate the return type as a tuple, such as [T, (next: T) => void], or return the literal with as const.

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

A TypeScript builder's `.set()` is declared to return `Builder<T & Record<K, V>>`, but at runtime it hands back the same object. What does that force the implementation to do, and what should you watch for?

level: seniorimportance: should knowfreq 30%

basics

~20 s

A value cannot change its own type parameter, so the implementation needs a type assertion to hand the object back as the new instantiation. Keep that assertion at one boundary, and remember the accumulated type is erased, so build() still validates at runtime.

open as a page

You pass a generic component `function List<T>(props: ListProps<T>): string` to a helper declared as `function wrap<P>(component: (props: P) => string): (props: P) => string`. The wrapped result no longer infers the item type at each usage. Which TypeScript rule causes that, and what is the standard fix?

level: seniorimportance: should knowfreq 30%

basics

~20 s

The helper's parameter is an ordinary, non-generic function type, so passing a generic function instantiates its type parameter once — usually to unknown — and the result is a single concrete component. The standard fix is to assert the wrapped value back to the original signature.

open as a page

You are designing the configuration API of a TypeScript library. How do you decide between a type-accumulating fluent builder and a single typed options object, and what does the builder cost your users?

level: principalimportance: nice to knowfreq 20%

basics

~20 s

Default to a typed options object: one type, one error location, easy to serialize and extend. Reach for an accumulating builder only when later calls must depend on earlier ones — and accept worse diagnostics, slower editors, and a harder deprecation story.

open as a page