TypeScript from the ground up

Lists and generics

Typed lists that keep their element type through every map and filter, and the one idea behind them: a function that says "for any type" without losing track of which.

Part 6 of 13

Intermediate16 min readBack to Courses

A list has an element type

string[] is a list of text, and the type travels with it. names.map(name => name.toUpperCase()) gives back a list of text; names.filter(...) keeps the element type; names.push(42) is refused at the line that wrote it.

This is where JavaScript quietly goes wrong and TypeScript does not. The methods you already use keep their types, so a mistake shows up where it is made rather than two functions later when something calls .length on a number. The type belongs to the variable, not to the array object at run time — the methods are the same methods; what changed is that each one is checked.

For any type, and still one type

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

const word = first(["Ada", "Grace"]);   // string | undefined
const count = first([1, 2, 3]);         // number | undefined
const doubled = count === undefined ? undefined : count * 2;

What the angle brackets say

<T> is a name for a type that will be decided by the caller — T for type, and any name works. The function promises: whatever you hand me, what comes back is the same thing, or nothing at all if the list was empty.

The compiler fills the name in at each call. That is why first([1, 2, 3]) is a number | undefined and not any — the empty list case is a real possibility, and the union makes you handle it.

Generics are not for one list

You will meet <T> again in Promise<T>, Array<T> and in library functions that wrap whatever they are given. The pattern is always the same: a promise that stays true no matter what type arrives.

The rule of thumb: write a generic when the relationship between the input and the output is what you want to say — "same type out", "the element type of this list". When there is no relationship, a plain type is the honest one.

Constraints keep a generic honest

A bare <T> promises nothing about T, so the function can only treat it as a value that exists. The moment the body needs item.name, the promise has to say so: <T extends { name: string }>.

The constraint is a minimum, not a fixed type. Any object with a name fits, including one with ten other fields, and the caller keeps its own specific type at the call site.

Readonly lists and methods that copy

sort, reverse and splice change the list they are called on; map, filter and slice return a new one. The type system does not stop a mutation, so the habit is to know which is which before calling a method in the middle of a render.

When a function should promise not to change its input, type the parameter readonly T[]. That refuses push and sort at the line that tried, while still allowing map and filter.

How the type name gets filled in

At a call, the compiler compares the argument with the parameter and solves for the name. first(["Ada", "Grace"]) gives T the type string; first([1, 2, 3]) gives it number. It does this from the argument alone, without running anything, and then checks the body once against whatever T was assumed to be — which is why the body may only use what every T has.

An empty list gives the solver nothing to work with, so T is inferred from an empty array rather than from your intent — never under strict settings, any without them. That is the empty-list gap from the inference chapter, seen from the other side: this time it is the caller that has to say what will go in.

What goes wrong

A generic that appears once, as in function log<T>(value: T): void, is a plain parameter with extra ceremony. The name is not connected to anything else, so nothing is gained by it. Aim for a signature where T appears at least twice and the link between the appearances is the thing you wanted to say.

A constraint can also be wrong in both directions. <T extends object> lets the body pass the value around but not read item.name; a constraint that demands ten members rejects callers that would have worked. Write it from the body outwards: start with the members the body actually reads, and add nothing else.

Three generic mistakes. An empty list still infers as any[] until you annotate it, so every element added escapes the check. A generic that appears only once in a signature is usually a plain type wearing angle brackets. And sort mutates: calling it on a list you did not create reorders someone else’s data, and no type will warn you.

Try these next

  • Add a constraint so a function can read item.id, and call it with two different object shapes.
  • Type a parameter as readonly string[] and try to push to it.
  • Write a function that returns a new sorted list without changing the argument.

A function for any list

Make `first` generic — a list of anything in, its first item out — so both calls below keep the type they were given.

Live Preview
Console output will appear here...

3 left to do

  • The function declares a type parameter
  • The parameter is a list of that type
  • The return type is that same type

The type name goes between angle brackets before the parentheses, and is used in both the parameter and the return type: function first<T>(items: T[]): T | undefined.

A generic with a minimum

Constrain the type parameter so `labelOf` can read `name`, then call it with a book and with a person.

Live Preview
Console output will appear here...

3 left to do

  • The type parameter has a minimum
  • The minimum includes a name
  • The parameter uses the type parameter

<T extends { name: string }> says every T has at least a name; the body can then read it, and each caller keeps its own shape.

A generic function is a promise about a relationship, not a way to avoid types. If the input and the output have nothing to do with each other, any dressed in angle brackets says less than one honest union.

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does the <T> in function first<T>(items: T[]) mean?