Generic Functions and Types in TypeScript

Published Updated

Generics look intimidating because most examples start with abstract library code full of single letters. The idea underneath is small: a type parameter is a blank the caller fills in, and its job is to keep the caller's type intact on the way out of a helper instead of flattening it.

This guide covers what a type parameter actually buys you, how the compiler infers one at the call site, how constraints narrow what callers may pass, generic types and defaults, and the test for whether a generic is earning its place at all.

What a Type Parameter Buys You

The clearest way to see the value is to write the same helper twice, once without a type parameter:

// loses the type: everything downstream is unchecked
function firstItemLoose(items: any[]): any {
  return items[0];
}

// keeps the type: the result matches what went in
function firstItem<Item>(items: Item[]): Item | undefined {
  return items[0];
}

const users = [{ id: "u1", email: "ada@example.com" }];

const loose = firstItemLoose(users);
const kept = firstItem(users);

console.log(kept?.email);
// prints: ada@example.com

Both calls run identically. The difference is what the compiler knows afterwards: loose is any, so a typo such as loose.emial passes silently, while kept carries the object type and the same typo is an error.

The | undefined in the return type is the honest part of the signature. Indexing an array can miss, and while TypeScript types items[0] as Item by default, the flag noUncheckedIndexedAccess makes the compiler itself insist on Item | undefined here.

Inference at the Call Site

Neither call above named a type, because the compiler worked it out from the argument. That is the normal case, with a caveat: appearing in the parameter list does not guarantee a useful candidate, because the arguments still have to contribute one.

function wrapInList<Item>(item: Item): Item[] {
  return [item];
}

function wrapConstrained<Item extends string>(item: Item): Item[] {
  return [item];
}

const labels = wrapInList("draft");
const literals = wrapInList<"draft">("draft");
const kept = wrapConstrained("draft");

// hover in an editor shows:
// const labels: string[]
// const literals: "draft"[]
// const kept: "draft"[]

Inference widened "draft" to string, because the result is a mutable array and a literal element type would be a promise the array cannot keep. Writing the type argument yourself overrides that. So does a constraint such as extends string, or passing "draft" as const.

Inference needs a source, though. A helper whose type parameter appears only in the return position has nothing in the arguments to read, so the compiler falls back to the parameter's default or its constraint, reaches for a candidate from the surrounding context, or lands on unknown when none of those exist:

function parseConfig<Shape>(raw: string): Shape {
  return JSON.parse(raw) as Shape;
}

const config = parseConfig<{ port: number }>('{"port": 3000}');

Worth noticing what that helper really does: the assertion tells the compiler to trust the caller, and no runtime check happens. This shape of generic is common in older SDKs and is a frequent source of false confidence. The runtime data guide covers the honest alternative.

Constraining What Callers May Pass

An unconstrained type parameter could be anything, so the body cannot assume any properties exist. extends narrows the set of permitted arguments and unlocks the body at the same time:

type WithId = { id: string };

function indexById<Item extends WithId>(items: Item[]): Record<string, Item> {
  return Object.fromEntries(items.map((item) => [item.id, item]));
}

const byId = indexById([
  { id: "a1", title: "First types" },
  { id: "a2", title: "Narrowing" },
]);

console.log(byId.a1?.title);
// prints: First types

The constraint does two jobs at once. Inside the body, item.id is legal because every allowed Item has it. At the call site, an array of objects without an id is rejected.

The result also keeps the full object type, so byId.a1?.title is checked, which a Record<string, WithId> return would have thrown away. The ?. is there because noUncheckedIndexedAccess types every Record lookup as Item | undefined, and byId.a1.title reports TS18048 under that flag.

A constraint can also refer to another type parameter, which is how helpers stay honest about property names:

function getField<Source, Key extends keyof Source>(source: Source, key: Key): Source[Key] {
  return source[key];
}

const article = { id: "a1", title: "First types", wordCount: 980 };

console.log(getField(article, "wordCount"));
// prints: 980

getField(article, "wordcount");
// tsc output:
// error TS2345: Argument of type '"wordcount"' is not assignable to parameter of type '"id" | "title" | "wordCount"'.

The return type Source[Key] is an indexed access type: the type of that specific property, so the wordCount call returns number rather than a union of every field type. Both facts come from the same constraint.

Generic Types and Interfaces

Type parameters are not limited to functions. A type alias, an interface, or a class can take one, which is how container shapes stay reusable:

type Paginated<Item> = {
  items: Item[];
  page: number;
  pageSize: number;
  totalItems: number;
};

type ApiResult<Data> =
  | { ok: true; data: Data }
  | { ok: false; error: string };

function unwrap<Data>(result: ApiResult<Data>): Data | null {
  return result.ok ? result.data : null;
}

console.log(unwrap({ ok: true, data: { port: 3000 } }));
// prints: { port: 3000 }

A generic union such as ApiResult is one of the most useful shapes in application code, because it names the success and failure states once and every endpoint reuses them. Narrowing on result.ok then works exactly as it does for a non-generic discriminated union.

Default Type Parameters

A type parameter can carry a default, which applies when a caller writes neither the type argument nor anything to infer from:

type Handler<Payload = void> = (payload: Payload) => void;

const onReady: Handler = () => console.log("ready");
const onSave: Handler<{ id: string }> = ({ id }) => console.log("saved", id);

onReady();
onSave({ id: "a1" });
// prints: ready
// prints: saved a1

Defaults keep a widely used type usable in its simplest case without a second name. They are common on React and event-handler types for exactly that reason. A default does not restrict what callers may pass: pair it with a constraint when both are wanted, as in <Payload extends object = Record<string, unknown>>.

When a Generic Is Not Worth It

The useful test is whether the type parameter appears more than once in the signature. A parameter that shows up in exactly one position is not relating anything, so it can usually be replaced by a plain type:

// the parameter connects nothing: Value is used once
function logValue<Value>(value: Value): void {
  console.log(value);
}

// the same function, simpler
function logUnknown(value: unknown): void {
  console.log(value);
}

Both compile, and the second says what it means. The heuristic is not absolute, since a parameter used once can still matter when it is being captured for a later inference, but as a first pass on application code it catches most decorative generics.

The other warning sign is a signature nobody can explain at review time. Conditional types, mapped types, and nested inference are genuinely useful in library and design-system layers where one definition serves hundreds of call sites. In a feature module, they usually cost more reading time than the duplication they removed.

Pitfalls and Debugging

Property does not exist on type Item. The body is assuming a field the type parameter does not promise. Add a constraint that includes the field, rather than asserting the value into a shape, which discards the check you were trying to gain.

Inference produced unknown or a surprising default. Usually the arguments contribute no useful candidate: the parameter appears only in the return type, or an empty array or object literal infers never[] or instead of the shape you had in mind. Pass the type argument explicitly, or restructure the signature so the parameter appears in an argument.

A constraint that is really a value check. Item extends WithId is enforced by the compiler, so it holds only for code the compiler checked. Data arriving from a network call can reach the same helper through an assertion or an any and break the assumption at runtime.

Type arguments on a call that already infers. Writing them everywhere makes signatures noisy and freezes types that would otherwise track the arguments. Reserve explicit type arguments for the cases where inference genuinely picks the wrong thing.

Frequently Asked Questions

When do you need to pass a type argument explicitly?

When the call gives the compiler nothing useful to infer from. A type parameter that appears only in the return type is the usual case; an empty array or object literal is another, since it infers never[] under strictNullChecks or , not the type you meant. You also pass one when you want a wider type than the argument implies.

Why can't you access a property on a generic value?

Because an unconstrained type parameter could be filled in with anything, including a value that has no such property. Adding a constraint such as T extends WithId, where WithId is an object type declaring an id field, tells the compiler every permitted argument has the field, and the property access is then allowed inside the body.

Is a generic the same as an any type?

No, and the difference is what happens after the call. A parameter typed any discards the caller's type, so the result is unchecked from then on. A type parameter remembers it, so passing a User array back out still gives a User array. Both accept a wide range of inputs; only one preserves what came in.

How many type parameters is too many?

There is no compiler limit, and library code sometimes justifies several. In application code, a signature that needs three or more, plus conditional types and a comment explaining itself, is usually a sign the helper is doing more than one job. Splitting it normally beats making the signature cleverer.

Sources

  1. [1]
    Generics
    (typescriptlang.org)
  2. [2]
    Keyof Type Operator
    (typescriptlang.org)
  3. [3]
    Indexed Access Types
    (typescriptlang.org)