Why Does any Lose the Type You Passed In?

Type a parameter as any and TypeScript stops checking everything that comes out of the function:

1
2
3
4
5
6
function firstElement(arr: any[]): any {
  return arr[0];
}

const n = firstElement([1, 2, 3]);
n.toUpperCase(); // compiles, crashes at runtime

You know you passed an array of numbers. The compiler doesn’t, because any tells it to stop looking. n comes back as any, so calling a string method on a number compiles cleanly and throws TypeError: n.toUpperCase is not a function when it runs.

A generic names the type instead of discarding it:

1
2
3
4
5
6
7
function firstElement<T>(arr: T[]): T {
  return arr[0];
}

const n = firstElement([1, 2, 3]); // n: number
const s = firstElement(["a", "b"]); // s: string
n.toUpperCase(); // error: Property 'toUpperCase' does not exist on type 'number'

T is a type parameter, a placeholder that gets filled in at each call site. firstElement([1, 2, 3]) fills T with number, the return type becomes number, and the bad .toUpperCase() call fails in your editor instead of in production. You write the function once and every element type keeps its checking.

The rest of this guide is about using that without making the signature harder to read than the bug it prevents.

How Does TypeScript Infer the Type Parameter?

You didn’t write firstElement<number>([1, 2, 3]) above, and you rarely need to. TypeScript matches the argument against the parameter’s declared shape (T[]) and solves for T. It’s the same inference that types const x = 5 as a number, applied to type parameters.

With nothing to infer from, T falls back to unknown:

1
2
3
4
5
6
function makeEmptyArray<T>(): T[] {
  return [];
}

const arr = makeEmptyArray(); // T is unknown, inferred as `unknown[]`
const nums = makeEmptyArray<number>(); // explicit type argument, nums: number[]

When the call has no value to infer from, pass the type argument yourself in angle brackets.

Inference can also go wrong when two arguments feed the same T and disagree. TypeScript picks one candidate and reports the other argument as the problem, which can produce an error that names a type you never wrote:

1
2
3
4
5
6
function longest<T>(a: T, b: T): T {
  return a;
}

longest([1, 2], "abc");
// error: Argument of type 'number[]' is not assignable to parameter of type '"abc"'

When a generic call gives you a confusing error or an unexpected type, hover the call in your editor and read what T was inferred as. That usually explains it faster than reading the message.

How Do You Constrain a Generic With extends and keyof?

An unconstrained T can be anything, so inside the function you can only do what every type supports, which is almost nothing beyond passing the value around. Most real generic functions need to assume something about T, and extends is how you say what:

1
2
3
4
5
6
7
8
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const dev = { name: "Tobias", role: "backend", salary: 95000 };

getProperty(dev, "role"); // "backend", typed as string
getProperty(dev, "pay"); // error: Argument of type '"pay"' is not assignable to parameter of type '"name" | "role" | "salary"'

K extends keyof T limits K to the property names of whatever object you pass, and T[K] reads off that property’s type. The list of valid keys comes from the object at the call site, not from a hardcoded union. A typo like "pay" is a compile error instead of a silent undefined.

The constraint can also be a plain shape:

1
2
3
4
5
6
7
8
function logLength<T extends { length: number }>(value: T): T {
  console.log(value.length);
  return value;
}

logLength("hello"); // 5
logLength([1, 2, 3]); // 3
logLength(42); // error: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'

Strings and arrays both have a length, so both pass. A number doesn’t, and the call is rejected before the function body ever runs. Because the return type is T and not { length: number }, logLength("hello") still hands you back a string.

How Do Generic Interfaces and Classes Work?

A generic interface is a shape with a type parameter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
interface Box<T> {
  value: T;
  unwrap(): T;
}

const numberBox: Box<number> = {
  value: 5,
  unwrap() {
    return this.value;
  },
};

A generic class shares its type parameter across every field and method:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }
}

const numbers = new Stack<number>();
numbers.push(1);
numbers.push(2);
numbers.peek(); // 2, typed as number | undefined

new Stack<number>() and new Stack<string>() are both fully typed, and the class exists once. The same pattern shows up wherever you use dependency injection: you write a Repository<T> once, then inject Repository<User> and Repository<Order> where they’re needed instead of maintaining a near-identical class per entity.

Multiple type parameters work the same way, and each can carry its own constraint:

1
2
3
4
5
function pair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}

pair("id", 42); // [string, number]

Two parameters are also how a Result<T, E> type carries a success value and an error type side by side. If you’re deciding whether functions should throw or return failures as data, exceptions vs error values walks through that choice.

How Do Default Type Parameters Work?

A type parameter can have a default, like a function parameter with a default value:

1
2
3
4
5
6
class ApiResponse<T = unknown> {
  constructor(public data: T, public status: number) {}
}

const raw = new ApiResponse({ ok: true }, 200); // T inferred as { ok: boolean }
const typed: ApiResponse<{ id: number }> = new ApiResponse({ id: 1 }, 200);

Inference still wins when there’s an argument to infer from, as raw shows. The default applies when nothing fills T, for example when you write ApiResponse as a type annotation with no type argument. That makes defaults useful on wrappers like response types and event payloads, where many call sites don’t care about the exact data type. Default to unknown, not any: it still forces a check before anyone reads data.

When Should You Use a Union Type Instead of a Generic?

Ask one question before adding <T>: does the return type depend on what was passed in? If the function returns the same type whatever the input, a union is enough and a generic adds nothing:

1
2
3
4
// no need for a generic here — the return type doesn't track the input
function describe(value: string | number): string {
  return typeof value === "string" ? value.toUpperCase() : value.toFixed(2);
}

firstElement, getProperty and Stack<T> are different. Each returns something whose type is decided by what came in, and that link between input and output is what a generic buys you. If you can write the function with a union and lose nothing, use the union.

Stacking type parameters because a function could be generic costs readability and buys nothing. A helper with three type parameters that is only ever called with string and number is harder to read than two overloads. Add <T> when a real caller needs a type to flow through, and delete it when every call site pins the same concrete type.

Why Can’t You Use T at Runtime?

Type parameters are erased at compile time. The JavaScript that runs has no T in it, so you can’t construct one, check instanceof T, or branch on it:

1
2
3
4
function create<T>(): T {
  // return new T(); // error: 'T' only refers to a type, but is being used as a value here
  throw new Error("not implemented");
}

If a function needs to build a T or inspect it at runtime, pass a real value as an argument: a constructor, a schema, or a type tag. Generics are a compile-time contract, not a runtime guard, so data coming from outside your program (an API response, JSON.parse) still needs validating even when it’s typed ApiResponse<User>.

To see every error in this guide for yourself, install TypeScript with whichever of npm, Yarn or pnpm your project uses, paste the examples into a .ts file, and run npx tsc --noEmit --strict. Then open the most-used any in your own codebase and check whether a <T> would carry the type through.