TypeScript Generic Functions: Types and Constraints

Published Updated

TypeScript generics can look intimidating when 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.

The generics overview connects these patterns with function signatures and built-in utility types.

What Does a TypeScript Type Parameter Do?

A TypeScript type parameter lets a helper preserve the type of the value its caller supplies. Compare the same helper with any and with 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 accounts for an empty array. 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, so the inferred array can hold other strings too. 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}');

The assertion in that helper 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 shows how to validate the returned data.

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 adds undefined to this string-indexed Record lookup, producing Item | undefined, and byId.a1.title reports TS18048 under that flag.

A constraint can also refer to another type parameter, so a helper can accept only keys that exist on the supplied object:

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 Should You Avoid a TypeScript Generic?

Avoid a TypeScript generic when a plain type describes the function just as well. Check whether its type parameter connects more than one part of 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 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 picks the wrong thing.

Frequently Asked Questions

When do TypeScript generics need explicit type arguments?

Pass an explicit TypeScript type argument when inference cannot determine the type you need. A type parameter that appears only in the return type is the usual case; an empty array or object literal can also provide too little information. Depending on the context and compiler settings, inference may produce never[] or {} instead of the type you intended. You also pass one when you want a wider type than the argument implies.

Why can't TypeScript access a property on an unconstrained generic?

TypeScript blocks property access on an unconstrained type parameter because the caller might supply a value without that 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.

Are TypeScript generics the same as any?

TypeScript generics preserve relationships between input and output types, while any disables checks on the values that use it. 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 should a TypeScript function have?

Use as many TypeScript type parameters as the function needs to connect its inputs and outputs, but avoid parameters that add no useful relationship. 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)