Mert Özen Further With Every Line
Frontend TypeScript Intermediate — #01

Introduction to Generics in TypeScript: Taking the Type as a Parameter

Mert Özen Nov 3, 2026 8 min 1 views
Introduction to Generics in TypeScript: Taking the Type as a Parameter

Why generic functions exist, how a type parameter ties input to output, how TypeScript infers T, and how generics differ from any and unknown, with small examples.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

We assume you've read the Basics series and that everyday TypeScript, from type inference and narrowing to function types and tsconfig, is already in your hands. In the Intermediate series we build reusable types on top of that foundation: generics, classes, utility types, type guards, modules and declaration files. The first stop is generics. Look at this code:

function echoAny(value: any): any {
  return value;
}

function echo<T>(value: T): T {
  return value;
}

const a = echoAny(42);
const b = echo(42);

console.log(a.toUpperCase());
console.log(b.toUpperCase());

Both functions hand back the value they receive, untouched. Make a guess: does this code compile? If it errors, on which line? And what happens when the line that does compile runs? We'll answer once we've seen what a type parameter does.

Two Functions Doing the Same Job

Say you want a function that returns the first element of an array. With the function types we've already seen, the first thing that comes to mind is this:

function firstString(items: string[]): string | undefined {
  return items[0];
}

function firstNumber(items: number[]): number | undefined {
  return items[0];
}

const firstUser = firstString(["Ada", "Alan"]);
const firstScore = firstNumber([90, 75]);

The two bodies are identical, letter for letter. The only thing that changes is the type. Tomorrow you'll need the same function for a boolean[] or a list of users, and you'll copy and paste every time. JavaScript didn't have this problem, because it had no types; a single function worked with every array. We want both: a single function and type safety.

The any Shortcut and the Information It Loses

The second idea that comes to mind is to make the type any and move on:

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

const user = firstItem(["Ada", "Alan"]);
user.toFixed(2); // Compiles; throws a TypeError when it runs

The function now accepts any array, but the price is steep. An array of strings went in and any came out. TypeScript has forgotten that user is a string. That's why it doesn't object to user.toFixed(2), and when the code runs it stops with TypeError: user.toFixed is not a function. As we saw earlier, any means switching off type checking for that value. We didn't solve the problem; we just made it invisible.

What we really lost is the relationship between input and output. The sentence we want to say is actually very simple: "Whatever type of elements the array you give me holds, you get an element of that type back."

Type Parameters: Passing the Type to the Function Too

Generics let us write exactly that sentence:

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

const firstUser = first(["Ada", "Alan"]); // string | undefined
const firstScore = first([90, 75]); // number | undefined

The <T> right after the function name declares a type parameter. A regular parameter carries a value into the function; a type parameter carries a type. T is a placeholder that gets replaced with a real type every time the function is called. items: T[] says "an array of elements of type T", and the T | undefined in the return type says "a value of that same T, or undefined." Because both places are tied to the same letter, the relationship is now written into the code.

You can think of it as a mold. You write the function once, but you leave an empty slot in it. Call it with a string[] and the slot fills with string; call it with a number[] and it fills with number. There's one function body, but each call gets its own type.

Calling it T isn't a rule, just a convention (the first letter of "Type"). When you need a second type parameter, U is common, and K and V are used for key and value. If you want the meaning to be clearer, longer names like TItem are perfectly valid too.

How Does TypeScript Find T?

You may have noticed that when we called first, we never said "let T be string here." TypeScript worked out T by itself from the argument you passed. This is called type argument inference, and it's why you use most generic functions without writing a single type:

function echo<T>(value: T): T {
  return value;
}

const a = echo("Ada"); // "Ada"
let b = echo("Ada"); // string
const c = echo<string>("Ada"); // string
const d = echo({ name: "Ada", age: 36 }); // { name: string; age: number; }

The difference between the first two lines should look familiar. We've already seen the widening difference between let and const, and the same rule is at work here. With const, T stays the literal type "Ada"; with let, it widens to string. On the third line we supplied T ourselves. A type written in angle brackets after the function name like this is called an explicit type argument.

Inference doesn't always give the result you have in mind. When TypeScript sees more than one candidate for T, it prefers the candidate from the first argument:

function pair<T>(x: T, y: T): [T, T] {
  return [x, y];
}

const p1 = pair(1, 2); // [number, number]
// Error: Argument of type 'string' is not assignable to parameter of type 'number'.
const p2 = pair(1, "two");
const p3 = pair<number | string>(1, "two"); // [string | number, string | number]

In the pair(1, "two") call, T is fixed as number from the first argument, and since the second argument doesn't fit, you get an error. If you really want a mix, you pass T explicitly as number | string. A practical rule: trust inference first, and write an explicit type argument only when inference gets it wrong or has too little to go on.

How Do Generics Differ from any?

Now we can return to the opening question. The result of echoAny(42) is any, so the a.toUpperCase() line compiles; when it runs, it throws TypeError: a.toUpperCase is not a function. In the echo(42) call, T is inferred as the literal type 42, so b has type 42. TypeScript rejects the second line before it ever runs:

error TS2339: Property 'toUpperCase' does not exist on type '42'.

To put the difference in one sentence: any throws type information away, a generic carries it through. Both say "I accept any type," but a generic adds "and I'll give you the same type back."

What about unknown? We met it earlier as the safe any. It's safe here too, but it causes the same loss of information:

function echoUnknown(value: unknown): unknown {
  return value;
}

const u = echoUnknown(42);
// Error: 'u' is of type 'unknown'.
u.toFixed(2);

Even though the value is 42, you can't call toFixed, because the return type only says "something." You'd have to narrow it at every use. The generic version keeps that information on its own. In short: any is unsafe, unknown is safe but forgetful, and a generic is both safe and has a memory.

Arrow Functions and Generic Function Types

In an arrow function, the type parameter goes in front of the parameter list. You can also define the type of a generic function as a type alias:

const last = <T>(items: T[]): T | undefined => items[items.length - 1];

type Mapper = <T, R>(items: T[], fn: (item: T) => R) => R[];

const mapAll: Mapper = (items, fn) => items.map(fn);

const lengths = mapAll(["Ada", "Grace"], (name) => name.length); // number[]

Mapper is a function type expression with two type parameters. In the mapAll(["Ada", "Grace"], …) call, TypeScript first infers T as string from the array, then gives that type to the callback's name parameter. Since the callback returns name.length, R becomes number. We got number[] without writing a type anywhere. The array methods you used in the JavaScript series, such as map, filter and find, are typed as generics in exactly this way; hover over them in your editor and you'll see the <U> in their signatures.

Common Pitfalls

The first pitfall: trying to use T without knowing what's inside it.

function getLength<T>(value: T): number {
  // Error: Property 'length' does not exist on type 'T'.
  return value.length;
}

This error surprises people at first, but it makes sense. T can be any type; it could be number, or boolean. Those have no length, so TypeScript makes no assumptions. There is a way to require T to have a certain shape, and that's exactly the topic of the next post.

The second pitfall: using a type parameter in only one place. A generic's value comes from tying two places together. If T appears in a single parameter and relates to nothing else, you don't need a generic:

// Unnecessary: T appears only once
function printValue<T>(value: T): void {
  console.log(value);
}

// Does the same job
function printAnything(value: unknown): void {
  console.log(value);
}

The third pitfall is sneakier: using T only in the return type.

function parseJson<T>(text: string): T {
  return JSON.parse(text);
}

const user = parseJson<{ name: string }>('{"title":"oops"}');
console.log(user.name.toUpperCase()); // Compiles; throws a TypeError when it runs

This function looks generic, but it's really a type assertion in disguise. JSON.parse returns any, so TypeScript believes whatever you tell it. The incoming data has no name, so user.name is undefined and the code stops with TypeError: Cannot read properties of undefined (reading 'toUpperCase'). Because types are erased at runtime, data from the outside world has to be genuinely validated; we'll look at that in detail when we talk about types in asynchronous code.

A Small Experiment

★☆☆ Write the function function wrapInArray<T>(value: T): T[] { return [value]; }. Predict the type of w in const w = wrapInArray("Ada");, then check it by hovering in your editor. Does it keep the literal type and become "Ada"[], as with echo("Ada"), or is it string[]?

★★☆ Predict whether these compile: pair("a", "b"), pair("a", 1) and pair<string | number>("a", 1). In the call that errors, what did TypeScript base T on?

★★★ Write a swap function with two type parameters that swaps the elements of a two-element tuple: it takes [A, B] and returns [B, A]. Confirm that the type of swap(["Ada", 36]) is [number, string].

In this post we put no conditions on T; it accepted every type, and in return we knew nothing about it. In the next post we'll draw boundaries around T: we'll see how to say "only types that have a length" with extends, how default type parameters work, and how const and NoInfer steer inference.