TypeScript generics let you write a function, class or type once and reuse it with many types without falling back to any. You declare a type parameter such as <T>, and the compiler fills it in from how the code is called, so first([1, 2, 3]) returns a number and first(["a"]) returns a string. Conditional types take the same idea to the type level: they choose one type or another based on a condition, and with infer they can pull types out of other types.
This guide starts with classes, because generic classes build on them, then moves through generics, constraints, conditional types, infer and recursion. Examples target TypeScript 5.x with strict on.
TypeScript classes: the foundation for generic code
Fields, constructors and methods
A class declares its fields with types, initializes them in the constructor, and defines methods that use them:
class Person {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
print(): void {
console.log("name:", this.name);
}
}
const somchai = new Person("Somchai", 18);
const somsri = new Person("Somsri", 20);
somchai.print(); // name: SomchaiUnder strict, every field must be assigned in the constructor or given a default, so no object starts half-built.
Inheritance with extends and super
A subclass uses extends and must call super(...) before it touches this:
class Employee extends Person {
department: string;
constructor(name: string, age: number, department: string) {
super(name, age);
this.department = department;
}
override print(): void {
super.print();
console.log("department:", this.department);
}
}override on a method the parent doesn't have is an error, which catches renames. Enable noImplicitOverride to make the keyword mandatory.
Access modifiers: public, private, protected
| Modifier | Accessible from | Enforced at runtime? |
|---|---|---|
public (default) | anywhere | no restriction |
protected | the class and its subclasses | no, compile time only |
private | the class itself | no, compile time only |
#field (ECMAScript private) | the class itself | yes |
readonly | can be read anywhere it is visible, assigned only in the declaration or constructor | no |
class BankAccount {
readonly id: string;
protected balance = 0;
#pin: string;
constructor(id: string, pin: string) {
this.id = id;
this.#pin = pin;
}
}private and protected disappear after compilation, so anyone with a reference can still read them from plain JavaScript. If the value must stay hidden at runtime, use a # field.
Parameter properties
Parameter properties are a shorthand: put a modifier on a constructor parameter and TypeScript declares and assigns the field for you.
class Product {
constructor(
public readonly sku: string,
public name: string,
private price: number,
) {}
priceWithVat(): number {
return this.price * 1.07;
}
}One caveat: parameter properties generate JavaScript, so they don't work with Node.js's built-in type stripping, and the erasableSyntaxOnly option (TypeScript 5.8+) reports them as errors. If Node runs your .ts files directly, write the fields out explicitly.
Abstract classes
An abstract class can't be instantiated. It defines shared behavior and leaves some members for subclasses to implement:
abstract class Shape {
abstract area(): number;
describe(): string {
return `${this.constructor.name} with area ${this.area().toFixed(2)}`;
}
}
class Circle extends Shape {
constructor(private radius: number) {
super();
}
area(): number {
return Math.PI * this.radius ** 2;
}
}
// new Shape(); // Error: Cannot create an instance of an abstract class.
console.log(new Circle(2).describe());Use an abstract class when subclasses share real implementation. If you only need a contract, an interface is lighter. TypeScript interfaces vs type aliases covers how to choose between those two.
TypeScript generics in functions and classes
Generic functions
Without generics you either duplicate a function per type or accept any and lose type information. A type parameter keeps the link between input and output:
function first<T>(items: T[]): T | undefined {
return items[0];
}
const n = first([10, 20, 30]); // number | undefined
const s = first(["a", "b"]); // string | undefined
const u = first<string>([]); // explicit type argumentYou rarely need to write the type argument yourself. TypeScript infers T from the arguments. Pass it explicitly only when inference has nothing to work with, as with the empty array above.
Generic classes
Generic classes suit containers and repositories, where the logic is identical for every element type:
class Stack<T> {
#items: T[] = [];
push(item: T): void {
this.#items.push(item);
}
pop(): T | undefined {
return this.#items.pop();
}
get size(): number {
return this.#items.length;
}
}
const numbers = new Stack<number>();
numbers.push(1);
// numbers.push("2"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'.Generic type aliases and interfaces
Types can take parameters too. A typical case is an API response wrapper:
interface ApiResponse<T> {
data: T;
error: string | null;
}
type User = { id: number; email: string };
async function getUsers(): Promise<ApiResponse<User[]>> {
const res = await fetch("/api/users");
return res.json();
}Type parameters can also have defaults, such as interface ApiResponse<T = unknown>, so callers who don't care about the payload type can write ApiResponse alone.
Generic constraints with extends
An unconstrained T could be anything, so you can't access properties on it. A constraint says what T must at least have:
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest("apple", "kiwi"); // OK, string has length
longest([1, 2], [1, 2, 3]); // OK, arrays have length
// longest(10, 20); // Error: number has no 'length'The most common constraint uses keyof to tie one type parameter to another:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, email: "[email protected]" };
getProperty(user, "email"); // string
// getProperty(user, "name"); // Error: '"name"' is not assignable to '"id" | "email"'The return type T[K] is an indexed access type, so the result is exactly the type of the property you asked for. keyof and indexed access types are covered in more depth in TypeScript advanced types: unions, keyof and mapped types.
Conditional types: T extends U ? X : Y
A conditional type works like a ternary expression over types. If T is assignable to U, the result is X, otherwise Y:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // falseDistribution over unions
When the checked type is a bare type parameter and you pass a union, the condition runs once per member and the results are unioned back together. This is what makes filtering possible:
type NumberOrString = string[] | number[] | number | string | undefined;
type ArrayFilter<T> = T extends unknown[] ? T : never;
type ArrayOnly = ArrayFilter<NumberOrString>;
// string[] | number[] | never | never | never
// => string[] | number[]never disappears from a union, so only the array members survive. The built-in Exclude and Extract utility types work exactly this way.
Sometimes you don't want distribution. Wrap both sides in a tuple to turn it off:
type IsStringWhole<T> = [T] extends [string] ? true : false;
type C = IsString<string | number>; // boolean (true | false)
type D = IsStringWhole<string | number>; // falseExtracting types with infer
Inside the extends clause of a conditional type, infer declares a new type variable and lets TypeScript work out what it is:
type ElementType<T> = T extends readonly (infer U)[] ? U : never;
type E1 = ElementType<string[]>; // string
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type E2 = UnwrapPromise<Promise<number>>; // number
type E3 = UnwrapPromise<boolean>; // boolean
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;
type E4 = FirstArg<(id: number, name: string) => void>; // numberThis is how the built-in ReturnType and Parameters are implemented. Since TypeScript 4.7 you can also constrain an inferred type directly, for example infer U extends string, which saves you a second conditional. infer also works inside template literal types, so S extends `${infer Head}/${string}` can pull the first segment out of a path string.
Recursive conditional types
A conditional type can refer to itself. This handles structures of unknown depth, such as nested arrays:
type Flatten<T> = T extends readonly (infer U)[] ? Flatten<U> : T;
type F1 = Flatten<number[][][]>; // number
type F2 = Flatten<string>; // stringRecursion also works over tuples. This type joins a tuple of strings with a separator:
type Join<T extends string[], Sep extends string> =
T extends [infer Head extends string, ...infer Rest extends string[]]
? Rest extends []
? Head
: `${Head}${Sep}${Join<Rest, Sep>}`
: "";
type Path = Join<["api", "v1", "users"], "/">; // "api/v1/users"The built-in Awaited<T> is itself recursive: it unwraps Promise<Promise<string>> all the way down to string.
The compiler limits recursion depth. When a type recurses too far you'll see "Type instantiation is excessively deep and possibly infinite." Tail-recursive conditional types, where the recursive call is the whole branch result as in Flatten, get a much higher limit than ones that wrap the call, as Join does. If you hit the limit, a runtime check is usually the better tool.
Common mistakes with TypeScript generics
- Type parameters used only once. In
function log<T>(value: T): voidtheTconnects nothing, sovalue: unknownsays the same thing. A type parameter should relate at least two positions, such as an input and an output. - Casting instead of constraining. If you write
(obj as any).length, you need a constraint likeT extends { length: number }. - Deep type-level logic in feature code. Conditional and recursive types suit shared helpers. In business logic, a plain interface is easier to read.
FAQ
What is the difference between generics and any?
any switches off type checking, so the output loses any link to the input. A generic T is filled with a real type at each call site, so you keep full checking and autocomplete.
When should I use a generic constraint?
Add extends whenever the function body needs something from T, such as a property, a method or a key. T extends { id: string } lets you read item.id, and K extends keyof T guarantees a key exists on an object.
What does infer do in TypeScript?
infer declares a type variable inside a conditional type's extends clause and lets TypeScript fill it from the matched type. It is how you extract an array's element type or a function's return type.
Why does my conditional type return a union like boolean?
The condition is distributive: a union passed to a bare type parameter is checked one member at a time. Wrap the type in a tuple, [T] extends [U], to check the union as a whole.
Takeaways
- Use
#fieldswhen privacy must hold at runtime, and avoid parameter properties if you rely on Node's type stripping. - Reach for generics when code does the same thing for many types, and let inference fill in the type arguments.
- Add constraints with
extendsandkeyofas soon as the body needs to know something aboutT. - Use conditional types and
inferto derive types from other types, and remember that they distribute over unions. - Keep recursive types small and well named.
The built-in helpers in the TypeScript utility types guide are all built from these same pieces, so reading their definitions is good practice. If your team is building a typed web application and wants a second pair of eyes on its architecture, Vectorkub works on projects like that.
