Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

TypeScript Generics and Constraints: Building Type-Safe Reusable Functions

Welcome back. Last lesson, you used checks and discriminated unions to determine which type a value has on a particular path. This lesson tackles a different problem: how to write one function that works with many types without forgetting the type its caller supplied.

You’ll define generic functions, let TypeScript infer type arguments, and use constraints when an implementation needs a particular capability. These are useful distinctions in interviews: a function may accept the right inputs yet still have a return type that is too vague to be safe.


Preserve the connection between input and output

Imagine a utility that returns the first item in an array. Writing separate versions for users, orders, and strings repeats the same implementation. But this apparent shortcut loses the information callers need:

function firstItem(items: any[]): any {
  return items[0];
}

const name = firstItem(["Ada", "Grace"]);
name.notARealMethod(); // No type error, despite the likely runtime failure

A union such as string[] | number[] would avoid any, but its return type would still be string | number: it would not express that a call with strings returns a string. A generic type parameter expresses that relationship:

function firstItem<T>(items: readonly T[]): T | undefined {
  return items[0];
}

const name = firstItem(["Ada", "Grace"]); // string | undefined
const count = firstItem([3, 5]);          // number | undefined

Read T as “the element type for this call.” TypeScript infers it from the argument; you normally do not have to write firstItem<string>(...). The return type includes undefined because the array might be empty. readonly T[] says this function accepts arrays it will not modify; it does not change the element type.

Watch the opening example in Learn TypeScript Generics In 13 Minutes from Web Dev Simplified. Notice the difference between making a function accept many types and preserving the specific type of each call. Our version adds undefined to account for an empty array.

Learn TypeScript Generics In 13 Minutes

Watch how an array helper progresses from a single-purpose function to a generic one, with its output type inferred from each input.

Watch the problem for the limitations of any and a union return type. Continue with the generic function for the type parameter and inference at the call site. Focus on where the same element type appears in both the input and output.

Inference needs information to work with. If an empty array gives TypeScript no useful element type, a caller can supply the type explicitly:

type User = { id: number; name: string };

const firstUser = firstItem<User>([]); // User | undefined

A type argument is a compile-time instruction, not a value passed to the JavaScript function. It does not fill the array or prevent undefined. Likewise, if an array genuinely contains both users and strings, T may itself be a union; generics preserve the information available, not information the program does not have.

The same idea can connect a callback’s input to its output. Here two type parameters represent two different roles:

function mapRows<Input, Output>(
  rows: readonly Input[],
  select: (row: Input) => Output
): Output[] {
  return rows.map(select);
}

const labels = mapRows(
  [{ id: 1, name: "Ada" }],
  user => user.name
); // string[]

Input is inferred from the rows. That gives user its object type inside the callback; the callback’s return value then determines Output. If you wrote user.missingField, TypeScript would flag it where the mistake occurs.


Constrain a type only when the implementation needs to

An unconstrained T could be a number, null, or almost anything else. Consequently, this cannot compile:

function reportLength<T>(value: T): T {
  // console.log(value.length); // Error: T need not have length
  return value;
}

The implementation requires a numeric length. State that minimum requirement with extends:

function reportLength<T extends { length: number }>(value: T): T {
  console.log(value.length);
  return value;
}

const text = reportLength("hello");                 // string
const ids = reportLength([10, 20]);                 // number[]
const record = reportLength({ length: 2, id: 7 }); // retains id
// reportLength(42); // Error: number has no length property

Here, extends means that T must satisfy the required structure. It does not mean the function returns only { length: number }. It can use the guaranteed length while preserving the caller’s more specific type—including id in the final call. The original value is returned, so that promise is honest.

Read the TypeScript Handbook’s Generic Constraints section for the corresponding loggingIdentity example. Then look at Using Type Parameters in Generic Constraints: a constraint can relate one type parameter to another, not just require a fixed property.

Documentation - Generics

Read the TypeScript Handbook’s examples to see both what a constraint permits inside a function and what it rejects at a call site.

In “Generic Constraints,” begin with the length example. Include the calls immediately below it: compare the rejected number with the accepted object, then stop at “Using Type Parameters in Generic Constraints.” In that next section, read the explanation and the getProperty function and calls directly beneath it. Stop before “Using Class Types in Generics”; focus on why one key is accepted and another is rejected.

There is an important limit to what a constraint promises. Consider a function declared to return T after receiving a T extends { length: number }. It cannot manufacture { length: 6 } and return it as T: the caller might have supplied an array and expect array methods on the result. Meeting the constraint is not the same as being the caller’s particular T.

function requireMinimumLength<T extends { length: number }>(
  value: T,
  minimum: number
): T {
  if (value.length < minimum) {
    throw new RangeError("Value is too short");
  }
  return value;
}

const items = requireMinimumLength([1, 2, 3], 2);
items.slice(0, 1); // Safe: a successful return is the original array

This function can promise T because every successful path returns its T input. If you instead want to create a replacement, give the function a return type that accurately describes what it can create. The Handbook examines the unsafe version in Working with Constrained Values.


Relate an object’s keys to its values

For a full-stack utility, suppose you want to select a field from an object. Typing the key merely as string would allow misspellings. Typing the return merely as a union of every field type would lose which field was requested. Use one type parameter for the object and a second, constrained one for the key:

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

const user = { id: 7, name: "Ada", active: true };

const id = getField(user, "id");       // number
const name = getField(user, "name");   // string
const active = getField(user, "active"); // boolean

// getField(user, "email"); // Error: "email" is not a key of user

keyof Obj describes the allowed property names. Key extends keyof Obj requires the selected key to be one of them. Finally, Obj[Key] describes the value type at that particular key. This is more precise than returning Obj[keyof Obj], which would be the union of all the object’s property types.

TypeScript infers Obj from user and Key from "name". If the key variable could be either "id" or "name", the result can likewise be number | string; that is the accurate promise for that call. And, as in the previous lesson, these types do not validate JSON from an API. They describe values already trusted to match their declared shape.

Before adding type parameters to a utility, ask what relationship each one preserves. getField needs both parameters to connect a selected key to its result. By contrast, a function that only prints a string gains nothing from <T extends string> if it never preserves or returns anything about the caller’s particular T.

The Handbook’s Working with Constrained Values and Guidelines for Writing Good Generic Functions make these two points explicit. The latter’s examples show why unnecessary constraints or extra type parameters can make inference worse rather than better.

TypeScript: Documentation - More on Functions

Read the TypeScript Handbook’s counterexample to returning the constraint as T, then its guidance for keeping generic signatures useful to callers.

In “Working with Constrained Values,” start at the minimumLength function introduced as a common error. Read the function above that explanation and the array-and-slice consequence immediately below it; stop before “Specifying Type Arguments.” Then go to “Guidelines for Writing Good Generic Functions.” Read the guidelines, including “Push Type Parameters Down,” “Use Fewer Type Parameters,” and “Type Parameters Should Appear Twice.” Stop before “Optional Parameters.”


Key takeaways

A generic type parameter lets a reusable function preserve a relationship between its arguments and result. Let callers benefit from inference when possible, and include real possibilities such as an empty array in the return type.

A constraint states what the implementation may safely assume. It does not entitle the function to return an arbitrary value matching that constraint as the caller’s specific type. When a property name determines a result type, Key extends keyof Obj together with Obj[Key] preserves that finer relationship.

Next, you’ll use TypeScript interfaces, utility types, and readonly properties to model frontend data contracts. The same question will carry forward: what does the type actually guarantee to code that consumes the data?

Can't find a good explanation? Sign up and we'll make it for you

Sign up