Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~26 min· topic 1 of 7

Topic 7.1

Why Generics

In one line

A generic function has a blank for the type. The caller fills the blank, and TypeScript remembers it, so what goes in is exactly what comes out, with no copying and no any.

Think of it like this

A school lunchbox with a clear window and a removable tray. The box itself does not care what is inside. Put a sandwich in and you get a sandwich out. Put fruit in and you get fruit out. The label on the window changes with the food, but the box is built once. A generic is that box: one definition, and the type of what it holds is filled in each time you use it.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Generic
Code (a function, interface, type alias or class) written with a blank for the type, so it works for many types and keeps track of which one.
Type parameter
The blank itself, declared in angle brackets after the name, like <T>. It stands for a type that is decided later.
Type variable
Another name for a type parameter. It is a variable whose value is a type.
Type argument
The real type that fills the blank, as in identity<string>. Also what TypeScript works out for you.
Inference
TypeScript working out a type by itself, here by reading the arguments of a call.
Identity function
A function that returns exactly what it was given. The smallest example of a generic.
any
A type that switches checking off. A value of type any can be used in any way, and mistakes are only found when the code runs.
Erased
Removed during compilation. Type parameters, like all types, do not exist in the JavaScript that runs.

Step by step

01The same code, three times

Without generics, one function per type. The bodies are identical. Only the types change.

copies.tswhole filets
function identityNumber(value: number): number {
  return value;
}
function identityString(value: string): string {
  return value;
}
function identityBoolean(value: boolean): boolean {
  return value;
}
// ...and again for every new type

02The shortcut that loses everything: any

One function, any input. But the output type is any too, so TypeScript can no longer tell you anything about it.

The checker can't warn you if you call a text method on a number. The mistake survives until a user hits it.

any.tswhole filets
function identityAny(value: any): any {
  return value;
}
const n = identityAny(42);
n.toUpperCase(); // no error. Crashes when it runs.

03Declare a type variable

Put <T> after the function name. Now T exists as a type inside the signature and the body. Use it in the parameter and in the return type to say "these are the same type".

generic.tswhole filets
function identity<T>(value: T): T {
  return value;
}

04Each call fills the blank

TypeScript looks at the argument and sets T. Each call gets its own T, so the same function returns "hello" for text and 42 for a number. Nothing is copied. TypeScript substitutes the type.

Each call fills the blankdiagram
Rendering diagram…

05Why the same T matters

The power is the repeated T. The signature (value: T): T is a promise that the output is the same kind as the input. With any there is no link between them.

You can give the blank by hand: identity<string>("hello"). If the argument does not match, you get an ordinary type error, because T is now fixed to string.

06What you can't do inside

Because T could be anything, the body may only do what is valid for everything. value.length fails. Returning something else, like String(value), fails too: it says T could be a number and the string is not a number.

This restriction is a feature: it proves the function can't cheat. You will loosen it on purpose with constraints in Topic 7.3.

Try it yourself

  1. 1

    Widen with let

    In the first example change const b to let b. Predict the new type, then look. Because a let can be reassigned, TypeScript widens "hello" to string.

  2. 2

    Make the two lists differ

    In the second example change first([10, 20, 30]) to first(["a", "b"]). What does safe become? Is toUpperCase still an error? (It is still "possibly undefined", but the second message goes away.)

  3. 3

    Name the blank anything

    Rename T to Item everywhere in identity. Nothing changes: T is only a name. Then try returning value.length and read the error.

Code & diagrams

any forgets the type, a type parameter keeps it

The panel shows a is any, but b is the exact text type "hello" and c is 42. Change const b to let b and ask again: it widens to string.

Console

(nothing was printed)

Expected console output

hello hello 42
The bug any hides, and a generic reports

Both calls are mistakes (numbers have no toUpperCase). any lets the first one through, so it crashes at runtime. The generic reports the second one before running: the result is number | undefined, so it is possibly undefined and a number has no toUpperCase. The code still runs and shows the crash the first line caused.

Console

(nothing was printed)

Expected console output

Uncaught TypeError: risky.toUpperCase is not a function
Filling the blank by hand

Once you write the type argument, T is fixed. identity<number> wants a number, so the text on line 6 is an error. The code still runs, because types are erased.

Console

(nothing was printed)

Expected console output

hello true

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Cheat on the return type

In identity<T>, change the body to return String(value);.

terminal
$ Types panel
── what you'll see ──
index.ts(7,3): error TS2322: Type 'string' is not assignable to type 'T'.
'T' could be instantiated with an arbitrary type which could be unrelated to 'string'.

Break #2

Forget to declare T

Delete the <T> after the function name, keeping (value: T): T.

terminal
$ Types panel
── what you'll see ──
index.ts(6,26): error TS2304: Cannot find name 'T'.
index.ts(6,30): error TS2304: Cannot find name 'T'.

Myth vs fact

Myth

T is a special keyword.

Fact

It's an ordinary name chosen by habit. function identity<Thing>(value: Thing): Thing is identical. Short names are conventional for short generics; use descriptive names (TItem, Key) when there are several or the meaning is not obvious.

Myth

Generics are the same as any.

Fact

any turns checking off and forgets the type. A generic keeps the exact type and checks it. The only thing they share is that both accept many kinds of input.

Myth

Generic code runs slower or leaves something behind at runtime.

Fact

Types are erased. The JavaScript output for a generic function is the same plain function. There is no type information left to cost anything.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Inference picks the narrowest type that fits each argument, and keeps a literal such as "hello" when the type parameter is used at the top level of the return type. That is why identity("hello") gives "hello" but wrap("hi") (which returns { value: T }) widens to { value: string }. A let declaration widens the literal afterwards.

  • ▸

    Generics are parametric: a function that knows nothing about T can't behave differently by type, which is why the body is so restricted. This is the same idea as "free theorems" in functional languages: the signature <T>(x: T) => T has essentially one possible implementation.

  • ▸

    unknown is the safe cousin of any (Topic 1.6): use it when you really don't know the type. Reach for a generic when the output depends on the input, and unknown when it does not.

Remember this

  1. 1

    The problem this solves: A function that works for any kind of value would need one copy per type, or the type any, which turns the checks off. Generics keep one function and still track the type. Start with the smallest function there is, the identity function: it returns whatever it was given. For numbers you would write function identity(value: number): number. For text you would write a second copy with string. Then one for booleans, users, dates. Copying is slow and every copy can drift. The problem generics solve is repeating the same logic just because the type changed.

  2. 2

    The lazy fix is any: function identity(value: any): any. It is one function and it accepts everything, but it loses information. any means "switch the checker off for this value". The result of identity("hello") is any, not string, so .toUpperCase() and .toFixed() are both allowed, and the wrong one crashes at runtime. You wrote a type and got no checking.

  3. 3

    The real fix is a type parameter: function identity<T>(value: T): T. The <T> after the name declares a type variable called T. It is a variable, but its value is a type. It is a blank that the caller fills in. The rest of the signature says: whatever type T turns out to be, the parameter has that type, and the return value has that same type. The name T is only a habit (short for "type"). Any name works, and longer names such as Item or TKey are fine.

  4. 4

    Who fills the blank? Usually nobody types anything: TypeScript reads the argument and works it out. For identity("hello") it sees the text "hello" and sets T to "hello". For identity(42), T is 42. This is type argument inference. You can also give it by hand, identity<string>("hello"): the part in angle brackets is the type argument. Topic 7.2 covers when to leave it out and when to write it.

  5. 5

    Notice the type is "hello" (the exact text), not string. That is not a mistake: a const result can never change, so TypeScript keeps the precise value (Topic 1.3 explained this). If the result is stored in a let, it widens to string. This is the first thing you learn from // ^?: **generics keep exact information that any throws away.**

  6. 6

    A generic function never looks inside T. Inside the function body T could be a number, an object, null, anything, so you can only do what is safe for every type: pass it around, put it in an array, return it. value.length would be an error because not every T has a length. Topic 7.3 shows how to narrow what T may be.

  7. 7

    Generics are erased like all types: after compilation identity<T> is plain function identity(value) { return value; }. There is nothing generic at runtime, and you can't write new T() or typeof T. Generics are a compile-time tool for relating types to each other: "the output type depends on the input type".

Explain it without notes

01

What problem do generics solve, and why isn't any the answer?

02

What is a type parameter, and what is the difference between it and a type argument?

03

Why does identity("hello") return type "hello" and not string?

04

Do generics exist at runtime?

Practice

01

Write a generic function last that returns the last item of an array (or undefined if it is empty). Call it with numbers and with text and check both result types.

02

Write a generic wrap that takes any value and returns an object { value }. What is the type of wrap("hi")?

03

This function loses information. Rewrite it with a type parameter and show the type of the result is no longer any. ``ts function second(items: any[]): any { return items[1]; }

Trade-offs

  • ↔

    A generic signature is more abstract than a concrete one. Beginners read <T>(items: T[]): T | undefined more slowly than (items: number[]): number | undefined. Use generics when the same logic really is used with several types, not "just in case".

  • ↔

    A type parameter that appears only once in the signature adds nothing: function log<T>(x: T): void is no better than function log(x: unknown): void. A type parameter earns its place by linking two or more places (input to output, or input to input).

Done when you can

  • I can explain, with the lunchbox idea, what a generic is.

  • I can say why any loses information and a type parameter does not.

  • I can write function identity<T>(value: T): T and read it aloud.

  • I can say what T is: a name for a type that the caller fills in.

  • I can use // ^? to see what T was inferred as.

  • I can say why generics do not exist at runtime.