3 min

Generics in TypeScript

Use generic type parameters to preserve relationships between inputs and outputs without giving up inference.

How can we type a function so that it accepts different types without forgetting which type a particular call receives?

The answer is generics. Generics let us describe a relationship between types, allowing functions to process inputs and produce outputs of varying shapes while preserving the information that connects them.

Our first generic

The starting point for us will be to describe the identity function:

const identity = value => value;

TypeScript gives us a few options for typing this. We can use unknown, any, or a generic type parameter. The runtime behavior of identity stays the same, but the information TypeScript has about its result changes.

Returning unknown

We can accept an unknown value and return it. At runtime, passing in a string still gives us that string back. But the return type tells TypeScript only that the result is unknown.

Identity returning unknownTypeScript 5.9.3 · strict
const identity = (value: unknown): unknown => value;

const result = identity('thomas');

console.log(result);
console.log(result.toUpperCase()); // Intentional type error: result is unknown.

The last line intentionally produces a type error: the compiler won’t allow us to call .toUpperCase() on a value whose type is unknown. We could check whether the result is a string, but the caller already supplied a string. We have lost that relationship in the signature.

Returning any

Using any makes the error disappear, but it also allows an operation that does not make sense for the supplied value:

Identity returning anyTypeScript 5.9.3 · strict
const identity = (value: any): any => value;

const result = identity('thomas');

console.log(result.toUpperCase());
console.log(result.toFixed(2)); // Type-checks, but throws when Run is pressed.

This code type-checks, but once it’s executed, the call to .toFixed(2) fails because the string "thomas" does not have that number method. any has removed the check that would have caught the mistake.

Preserving the type

How can we retain the input type without discarding it or turning off checking? This is where a type parameter helps:

const identity = <T>(value: T): T => value;

The above code block expresses the following:

  • <T> declares a type parameter for the function.
  • value: T gives the input that type.
  • : T gives the output the same type.

For identity, we want the result to preserve the input’s type. Using T for both the parameter and return type describes that relationship. TypeScript can infer T from the argument, so we usually don’t need to specify it ourselves.

Identity preserving its input typeTypeScript 5.9.3 · strict
const identity = <T>(value: T): T => value;

const result = identity('thomas');
const year = identity(2019);

console.log(result.toUpperCase());
console.log(year.toFixed(0));

// console.log(result.toFixed(2));

TypeScript now knows that result has the methods available on a string, and year has the methods available on a number. Now we’re finally getting somewhere!

Uncomment the last line to try .toFixed(2) on the string and see the compiler catch the mistake.

A practical example: the first item

Typing identity makes the relationship between an input and its output easy to see. Let’s extend that idea to a function that retrieves the first item from an array while preserving its type.

The first function selects the first item from an array. In its signature, T describes the array’s element type. The input is readonly T[], and the result is T | undefined because the array might be empty.

Get the first item without losing its typeTypeScript 5.9.3 · strict
const first = <T>(items: readonly T[]): T | undefined => items[0];

const titles = ['Generics', 'Curried Functions'];
const posts = [
  { title: 'Generics', draft: true },
  { title: 'Curried Functions', draft: true }
];

const firstTitle = first(titles);
const firstPost = first(posts);

console.log(firstTitle?.toUpperCase());
console.log(firstPost?.title);
console.log(first([]));

// Uncomment to see TypeScript reject a number method on a post title:
// console.log(firstPost?.title.toFixed(2));

When we pass in an array of strings, the result is a string or undefined. When we pass in an array of post objects, the result retains the title and draft fields, or it is undefined.

We can use one implementation for both without forfeiting any information about what a particular array contains. Adding another field to the post objects makes that field available on firstPost.

If you extend the code block with an assignment that has first take in an array of numbers, TypeScript will inform you of the return type being number | undefined, and any attempt to call .toUpperCase() on this assignment fails loudly.

Generic reflection

And thus concludes our exploration of generics in TypeScript. To recap, we learned how to strongly type identity so that TypeScript preserves the input value’s type, and then extended this idea to the first function so it preserves an array’s element type. In both cases, generics let us write one function that works across many types without forgetting which type a particular call receives.

Small, reusable functions that keep their types intact are what functional programming is built from, and with generics in hand, we’ve taken our first steps toward strongly typed functional programming in TypeScript!

Thomas Greco writes about software engineering, life, and what he’s learning and building.