TypeScript Generics: Function, Class and Interface Examples

TypeScript generics let one function, class or interface work with many types while the compiler checks every value. Examples cover constraints, keyof, defaults and const type parameters.

TypeScript generics let us write one function, class or interface that works with many types while the compiler still checks every value. A generic declares a type parameter in angle brackets, named T by convention. The caller (or the compiler, through inference) fills in the real type, for example string or number, and every use of T inside the declaration becomes that type.

We use generics when the same logic applies to different data types and we do not want to give up type checking by falling back to any. For example, an API client can have one getJson<T>() function that returns a typed User from one endpoint and a typed Order from another.

The following example shows each generic feature in one snippet, compiled with the TypeScript 7.0 compiler in strict mode and run on Node.js 22. The comments show the value each line produces, and the type where the type is the point.

// 1. Generic function; T is inferred from the argument
function first<T>(items: T[]): T | undefined {
  return items[0];
}
const name = first(["Lokesh", "Raj"]);        // name = "Lokesh"
const age = first([37, 35]);                  // age = 37

// 2. Constraint with extends
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}
const word = longest("apple", "kiwi");        // word = "apple"

// 3. Constraint with keyof
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const person = { name: "Lokesh", age: 37 };
const personAge = getProperty(person, "age"); // personAge = 37 (number)

// 4. const type parameter keeps literal types
function options<const T extends readonly string[]>(items: T): T {
  return items;
}
const fruits = options(["apple", "banana"]);  // type readonly ["apple", "banana"]

// 5. Generic interface with a default type
interface Box<T = string> {
  value: T;
}
const label: Box = { value: "apple" };        // T = string
const count: Box<number> = { value: 5 };      // T = number

// 6. Generic class
class Stack<T> {
  private items: T[] = [];
  push(item: T): void {
    this.items.push(item);
  }
  pop(): T | undefined {
    return this.items.pop();
  }
}
const stack = new Stack<string>();
stack.push("apple");
const top = stack.pop();                      // top = "apple"

Notice that none of the function calls writes a type argument, because TypeScript infers T from the arguments.

Next, we look at why generics beat any and how constraints with extends and keyof work. After that, we cover default and const type parameters (added in TypeScript 5.0), generic interfaces and classes, and what happens to generic types at runtime.

1. Why Use TypeScript Generics Instead of any

Suppose we need a function that returns the first element of an array. Without generics, we have two options.

  • One function per element type (firstString(), firstNumber()), which repeats the same code.
  • A parameter typed as any[], which accepts every array but also turns off type checking for the result.

The second option compiles, but the compiler no longer knows what the function returns. A typo or a wrong method call reaches production and fails there.

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

const value = firstAny(["Lokesh"]);           // type any
const fixed = value.toFixed(2);               // compiles, TypeError at runtime

The method toFixed() is a number method, here called on a string, so the program throws TypeError: value.toFixed is not a function. A generic version keeps the link between the input type and the output type. When an array of strings goes in, a string comes out, and the compiler rejects toFixed().

Generics cost nothing at runtime, because the compiler removes all type information from the JavaScript output. We look at what that means for our code in section 8.

2. Generic Functions

A function becomes generic when we put a type parameter list in angle brackets before its parameter list. The type parameter can be used anywhere in the signature and inside the function body. The name T is a generics convention, and any valid name works.

// 1. Declare a type parameter T
function first<T>(items: T[]): T | undefined {
  return items[0];
}

// 2. Let TypeScript infer T
const name = first(["Lokesh", "Raj"]);        // name = "Lokesh" (string | undefined)
const age = first([37, 35, 40]);              // age = 37 (number | undefined)

// 3. Pass the type argument explicitly
const nobody = first<string>([]);             // nobody = undefined

// 4. Generic arrow function
const last = <T>(items: T[]): T | undefined => items.at(-1);
const fruit = last(["apple", "banana"]);      // fruit = "banana"

In most calls, we do not write the type argument. TypeScript looks at the argument, sees an array of strings, and sets T to string. That step is called type argument inference. We write the type argument ourselves only when there is nothing to infer from, as with the empty array in step 3, or when we want a wider type than the one TypeScript would pick.

The return type is T | undefined and not T, because an empty array has no first element. In a .tsx file (React), the arrow function form <T>(…) can be read as a JSX tag. Writing <T,>(items: T[]) => … with a trailing comma avoids the problem.

2.1. More Than One Type Parameter

A function can declare several type parameters, separated by commas. Each one is inferred from its own argument. The names K and V are common when the two types are a key and a value.

function pair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}

const entry = pair("Lokesh", 37);             // entry = ["Lokesh", 37] ([string, number])

A type parameter should appear at least twice, for example once in a parameter and once in the return type. If T appears only once, it connects nothing, and a plain unknown parameter does the same job with less noise. Two or three type parameters are fine, but more than that is a sign that the function does too many things.

3. Generic Constraints With extends and keyof

Inside first<T>(), TypeScript knows nothing about T, so we cannot read items[0].length or any other property. A constraint solves the problem. Writing T extends SomeType means “T can be any type, as long as it is assignable to SomeType“. Inside the function, we can use every member of SomeType.

// 1. T must have a length property
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}
const word = longest("apple", "kiwi");        // word = "apple"
const list = longest([1, 2], [1, 2, 3]);      // list = [1, 2, 3]

// 2. T must have an age; the result keeps every other field
interface HasAge {
  age: number;
}
function oldest<T extends HasAge>(people: T[]): T {
  return people.reduce((a, b) => (b.age > a.age ? b : a));
}
const people = [{ name: "Lokesh", age: 37 }, { name: "John", age: 40 }];
const topName = oldest(people).name;          // topName = "John"

Strings and arrays both have a length property, so longest() accepts either. A number has no length, so longest(10, 20) fails to compile with error TS2345.

error TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.

The oldest() function shows why a constraint is better than a plain interface parameter. For example, a leaderboard function takes any player type with a score field and must return the whole player, not only the score. If we wrote oldest(people: HasAge[]): HasAge, the result would only have an age field, and .name would be a compile error. With T extends HasAge, T is the full type of the array elements, so the result keeps name as well.

3.1. Restricting Keys With keyof

The keyof operator produces a union of an object type’s property names. For { name: string; age: number }, keyof gives “name” | “age”. Combined with a constraint, it lets a function accept only property names that exist on the object. For example, a table component sorts rows by a column name, and the compiler rejects a column that the row type does not have. The return type T[K] is an indexed access type, which is the type of property K on T.

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = { name: "Lokesh", age: 37 };
const personName = getProperty(person, "name"); // personName = "Lokesh" (string)
const personAge = getProperty(person, "age");   // personAge = 37 (number)

Each call returns the exact type of the property it reads, not a union of all property types. A wrong key is caught at compile time, and the error message lists the valid keys.

error TS2345: Argument of type '"email"' is not assignable to parameter of type '"age" | "name"'.

4. Default Type Parameters

A type parameter can have a default type, written as T = SomeType. When the caller does not pass a type argument and TypeScript cannot infer one, the default is used. Defaults are most useful on interfaces and type aliases, where there is no argument to infer from.

// 1. Default type on an interface
interface Result<T = string> {
  ok: boolean;
  data: T;
}
const text: Result = { ok: true, data: "done" };   // T = string
const total: Result<number> = { ok: true, data: 5 }; // T = number

// 2. Default type on a function
function emptyList<T = string>(): T[] {
  return [];
}
const names = emptyList();                    // type string[]
const ages = emptyList<number>();             // type number[]

Without the default, emptyList() with no type argument would return unknown[], and every value read from it would need a check. Type parameters with defaults must come after the ones without defaults, the same rule as for optional function parameters. A default can also be combined with a constraint, as in T extends object = {}.

5. const Type Parameters

When TypeScript infers T from an array or object literal, it widens the values to their general types, so [“apple”, “banana”] becomes string[]. Widening is what we want in most code, but sometimes the exact values matter, for example when the function returns one of the given options. Before TypeScript 5.0, the caller had to write as const after the argument. Since TypeScript 5.0, the function can ask for literal inference itself with the const modifier on the type parameter.

// 1. Without const: literals are widened
function plain<T extends readonly string[]>(items: T): T {
  return items;
}
const a = plain(["apple", "banana"]);         // type string[]

// 2. With const: literals are kept
function exact<const T extends readonly string[]>(items: T): T {
  return items;
}
const b = exact(["apple", "banana"]);         // type readonly ["apple", "banana"]

// 3. Use the literal union in the return type
function pickFirst<const T extends readonly string[]>(items: T): T[number] {
  return items[0];
}
const fruit = pickFirst(["apple", "banana"]); // fruit = "apple" ("apple" | "banana")

In step 3, T[number] means “the type of any element of T“. Because T is the tuple readonly [“apple”, “banana”], the return type is the union “apple” | “banana”, not string. Code that later compares fruit with “cherry” gets a compile error.

Two details of const type parameters change what the caller gets.

  • The constraint uses readonly string[] so that callers can also pass arrays declared with as const. With TypeScript 7.0, a mutable constraint such as string[] also keeps the literals, but gives a mutable tuple.
  • The const modifier only affects literals written in the call itself. Passing a variable declared earlier as const arr = [“apple”] still gives string[], because the variable’s type was widened when it was declared.

6. Generic Interfaces and Type Aliases

An interface becomes generic in the same way as a function. The type parameters go in angle brackets after its name, and the members can use them. For example, an API layer declares one ApiResponse<T> interface and uses ApiResponse<User> and ApiResponse<Order> for different endpoints. The built-in types we use every day are generic interfaces. Array<T>, Map<K, V> and Promise<T> are declared in TypeScript’s standard library, and the Array declaration contains members such as push(…items: T[]): number and pop(): T | undefined.

// 1. One type parameter
interface Box<T> {
  value: T;
}
const ageBox: Box<number> = { value: 37 };

// 2. Two type parameters
interface Entry<K, V> {
  key: K;
  value: V;
}
const entry: Entry<string, number> = { key: "Lokesh", value: 37 };

// 3. Generic type alias for a function
type Formatter<T> = (value: T) => string;
const formatAge: Formatter<number> = (age) => age + " years";
const text = formatAge(37);                   // text = "37 years"

Unlike function calls, a variable typed with a generic interface needs the type argument written out (Box<number>), unless the interface has a default. Inside formatAge, the parameter age has the type number without an annotation, because the type alias already describes the function.

The choice between interface and type is the same as for non-generic types. An interface can be extended and implemented by a class, whereas a type alias can also describe unions and function types.

7. Generic Classes

A generic class declares its type parameters after the class name. Every instance chooses its own type argument, and all fields, constructor parameters and methods of that instance use it. A stack, a last-in-first-out collection, is a typical example, because the logic is the same for every element type. For example, the undo feature of a text editor keeps the previous versions of the text in a Stack<string>.

class Stack<T> {
  private 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 fruits = new Stack<string>();
fruits.push("apple");
fruits.push("banana");
const top = fruits.pop();                     // top = "banana"
const size = fruits.size;                     // size = 1

const numbers = new Stack<number>();
numbers.push(5);

The fruits stack accepts only strings, so fruits.push(5) is a compile error (Argument of type ‘number’ is not assignable to parameter of type ‘string’). When the constructor receives a value of type T, TypeScript infers the type argument from it, as it does for functions. A constructor without parameters, like the one in Stack, needs new Stack<string>(), otherwise T becomes unknown.

One restriction confuses many developers. Static members belong to the class itself, not to an instance, so they cannot use the class’s type parameter. Writing static empty: T[] = [] inside Stack<T> gives error TS2302: Static members cannot reference class type parameters.

7.1. Implementing a Generic Interface

A generic class can implement a generic interface and pass its own type parameter on. The interface describes the contract, and the class provides the code. Any code that depends only on Store<T> can later work with a different implementation, such as one that saves to a database.

interface Store<T> {
  add(item: T): void;
  getAll(): T[];
}

class MemoryStore<T> implements Store<T> {
  private items: T[] = [];

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

  getAll(): T[] {
    return [...this.items];
  }
}

const ages = new MemoryStore<number>();
ages.add(37);
ages.add(35);
const all = ages.getAll();                    // all = [37, 35]

The getAll() method returns a copy made with the spread operator, so callers cannot change the internal array. Other class features, such as access modifiers and abstract classes, combine with generics in the same way.

8. Generic Types Do Not Exist at Runtime

TypeScript removes all types when it compiles to JavaScript, and type parameters are no exception (this is called type erasure). At runtime, first<string>() and first<number>() are the same JavaScript function. So we cannot use T as a value. Both value instanceof T and new T() fail with error TS2693: ‘T’ only refers to a type, but is being used as a value here.

When a generic function needs to create or check a T at runtime, the caller passes something that exists at runtime, such as a constructor or a type guard function.

// 1. Pass a constructor when the function must create a T
function create<T>(ctor: new () => T): T {
  return new ctor();
}
const date = create(Date);                    // date is a Date

// 2. Pass a type guard when the function must check a T
function filterBy<T>(items: unknown[], isT: (x: unknown) => x is T): T[] {
  return items.filter(isT);
}
const isString = (x: unknown): x is string => typeof x === "string";
const words = filterBy(["apple", 5, "banana"], isString); // words = ["apple", "banana"]

The type new () => T describes any class that can be called with new and no arguments. The return type x is T marks isString as a type guard. When it returns true, TypeScript treats x as a string. The instanceof operator works with real classes such as Date, never with a type parameter.

A related mistake is to use generics for a function that must behave differently per type, such as one add() that joins two strings or sums two numbers. A single generic signature cannot describe “string in, string out; number in, number out”, so the code ends up full of typeof checks and a return type like string | number. Function overloading is the right tool for per-type behavior.

9. Building the Example Project

The example project on GitHub keeps each section in its own source file. Its package.json pins TypeScript 7.0.2, and Node.js 22 or a later version runs it.

npm install
npm start

The start script calls tsc first and then node, and the console shows one block per section with the same values as the snippet comments. The code that fails to compile is not in the project, because one compile error would stop the whole build.

10. Conclusion

Generics connect the types of inputs and outputs. A generic declaration has a type parameter, and the compiler fills in the real type at each use. Constraints with extends and keyof tell the compiler what a type parameter can do. Defaults remove the need to write common type arguments, whereas const type parameters keep literal values when they matter.

Because types are erased at runtime, a generic function that needs to create or check values receives a constructor or a type guard from the caller. When a function needs different behavior for each type, overloads fit better than generics.

11. References

The TypeScript Handbook and release notes are the sources for the generic features shown here.

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.