Master Generics in TypeScript
Isaiah Clifford Opoku
Posted on July 28, 2023
Introduction
Generics are a way to create reusable components. They allow us to create a component that can work over a variety of types rather than a single one. This allows users to consume these components and use their own types. Generics are a way to create reusable components. They allow us to create a component that can work over a variety of types rather than a single one. This allows users to consume these components and use their own types.
Defining Generic Functions:
To declare a generic function, you need to use angle brackets (<>) to specify one or more type parameters. These type parameters act as placeholders for the actual data types that will be used when calling the function.
- Here's the syntax for defining a generic function:
function identity<T>(arg: T): T {
return arg;
}
In above example, we have a function identity which returns whatever is passed to it. This function is generic because it works over a range of types.
- We can call this function in two ways. First, we can pass all of the arguments, including the type argument, to the function:
identity<string>("myString"); // type of output will be 'string'
- We can als use number type instead of string type:
identity<number>(123); // type of output will be 'number'
- Generic type can be inferred by the compiler which means we can omit the type argument:
identity("myString"); // type of output will be 'string'
So now you can see with the power of generics, we can create a component that can work over a variety of types rather than a single one.
Now we will see how to use generics in functions
, classes
and interfaces
, constraints
.
Let us start with functions
.
Generic Functions
Generic functions are functions that can work with a range of types. This allows us to reuse the components we build and can create a chain of functions that can work with multiple types.
Let us take an example of a function that takes an array of any type and returns the array with the same type.
// Generic function
function genericFunctions<T>(arg: T[]): T[] {
console.log(arg.length); // Array has a .length, so no more error
return arg;
In above example, we have a function genericFunctions which takes an array of any type and returns the array with the same type. We can call the function and pass an array of numbers or strings.
- Passing an array of numbers:
// Passing an array of numbers
let outputFuncNumbers = genericFunctions<number>([1, 2, 3, 4, 5]);
console.log(outputFuncNumbers); // [1, 2, 3, 4, 5]
- Passing an array of strings:
// Passing an array of strings
let outputFuncString = genericFunctions<string>(["Apple", "Orange", "Banana"]);
console.log( outputFuncString); // ["Apple", "Orange", "Banana"]
We can also omit the type argument and the compiler will infer the type for us:
- Inferred type of String:
// Inferred type of String
let outputFuncInfer = genericFunctions(["Apple", "Orange", "Banana"]);
console.log(outputFuncInfer); // ["Apple", "Orange", "Banana"]
I hope now you have a good understanding of generic functions. Now we will see how to use generics in classes
.
Generic Classes
To declare a generic class, you use the same angle brackets (<>) syntax as with generic functions. You define the type parameter inside the class declaration and then use it throughout the class as needed.
Here's the syntax for defining a generic class:
class ClassName<T> {
// Class properties and methods can use the type parameter 'T'
}
Let us take an example of a generic class
that takes a type parameter T and has a property of type T and a method that returns a value of type T.
class GenericClass<T> {
// INSTANCE VARIABLE OF TYPE T
private genericProperty: T;
// a constructor that takes an argument of type T
constructor(value: T) {
this.genericProperty = value;
}
// a method that returns a value of type T
public getGenericProperty(): T {
return this.genericProperty;
}
}
- Creating an instance of GenericClass with number and String type argument:
// Creating an instance of GenericClass
let instance1 = new GenericClass<number>(123);
console.log(instance1.getGenericProperty()); // 123
// create instance of generic class with string type argument
let instance2 = new GenericClass<string>("Hello, world");
console.log(instance2.getGenericProperty());
As you can see in above example, we have created an instance of GenericClass with number and String type argument. This is Mean you can create an instance of GenericClass with any type argument.
If your new to OOP in typescript, you can read my article onObject Oriented Programming with Typescript
So now you have a good understanding of generic classes. Now we will see how to use generics in interfaces
.
Generic Interfaces
Generic interfaces are interfaces that can work with a range of types. This allows us to reuse the components we build and can create a chain of interfaces that can work with multiple types.Similar to generic functions and classes, you can create generic interfaces that work with different types. This allows you to define flexible interfaces that can adapt to various data structures while providing type safety.
Here's the syntax for defining a generic interface
// syntax for defining a generic interface:
interface InterfaceName<T> {
// Interface properties and methods can use the type parameter 'T'
}
Now let us take an example of a generic interface that takes a type parameter T and has a property of type T and a method that returns a value of type T.
interface Box<T> {
value: T;
}
const box1: Box<number> = { value: 42 };
const box2: Box<string> = { value: "Hello" };
console.log(box1, box2); // { value: 42 } { value: 'Hello' }
Now let see another example Generic type with multiple arguments
function genericFunction<T, U>(x: T, y: U): void {
console.log(x, y);
}
genericFunction<number, string>(1, "Hello"); // 1 "Hello"
Generic Functions with Interfaces:
You can also use generic functions with interfaces to define contracts for functions that can work with various data types.
interface MathOperation<T> {
perform: (a: T, b: T) => T;
}
const add: MathOperation<number> = {
perform: (a, b) => a + b,
};
const concatenate: MathOperation<string> = {
perform: (a, b) => a + b,
};
console.log(add.perform(5, 10)); // Output: 15
console.log(concatenate.perform("Hello, ", "World!")); // Output: "Hello, World!"
Now with interface you can see we can create and interface and add generic type to it and we can use it for multiple types.
So now you have a good understanding of generic interfaces. Now we will see how to use generics in constraints
.
Defining Constraints:
To apply constraints, you use the extends
keyword followed by the type or interface you want to use as the constraint. This tells TypeScript that the type parameter used in the generic code must be a subtype of the specified constraint.
Here's the syntax for defining constraints:
function functionName<T extends ConstraintType>(param: T): void {
// Function logic here
}
Example:
interface Lengthy {
length: number;
}
function printLength<T extends Lengthy>(arg: T): void {
console.log("Length:", arg.length);
}
printLength("Hello"); // Output: Length: 5
printLength([1, 2, 3]); // Output: Length: 3
printLength({ length: 10 }); // Output: Length: 10
printLength(42); // Error: Type 'number' does not satisfy the constraint 'Lengthy'.
In this example, we define the Lengthy
interface with a length
property. The printLength
function takes a generic type 'T', but it must satisfy the Lengthy
constraint. Therefore, the function can be called with strings, arrays, and objects that have a length
property, but it will produce a compilation error if you try to pass a type that doesn't meet the constraint (like number
).
Multiple Constraints:
You can also apply multiple constraints to a generic type by using the extends
keyword followed by an intersection (&
) of multiple types or interfaces.
Example:
interface Printable {
print: () => void;
}
interface Serializable {
serialize: () => string;
}
function process<T extends Printable & Serializable>(obj: T): void {
obj.print();
console.log("Serialized:", obj.serialize());
}
const myObject: Printable & Serializable = {
print() {
console.log("Printing...");
},
serialize() {
return "Serialized data";
},
};
process(myObject); // Output: Printing... Serialized: Serialized data
In this example, the process
function takes a generic type 'T', which must satisfy both the Printable
and Serializable
constraints. This ensures that the function can be called only with objects that implement both interfaces. The myObject
object satisfies both constraints, so it can be passed to the process
function.
Using Type Constraints with Classes:
Constraints can also be applied to generic classes, ensuring that the class accepts only certain types that meet the constraint.
Example:
interface Animal {
name: string;
}
class Zoo<T extends Animal> {
constructor(private animals: T[]) {}
listAnimals(): void {
this.animals.forEach(animal => {
console.log("Name:", animal.name);
});
}
}
const zoo = new Zoo([
{ name: "Lion" },
{ name: "Elephant" },
{ name: "Giraffe" },
]);
zoo.listAnimals();
In this example, the Zoo
class is a generic class that takes a type parameter 'T', which must satisfy the Animal
constraint. This ensures that the array of animals passed to the constructor contains objects with the name
property (which is defined in the Animal
interface).
Constraints provide a powerful way to make generic code more robust and safe by specifying the types it can work with. They allow you to define clear contracts and ensure that the generic code operates only on suitable types, leading to more reliable and maintainable programs.
Thank you for getting the point of this article. I hope you enjoyed it. If you have any questions, please feel free to ask in the comments section below. I will try to answer them as soon as possible. You can find all the source code on my Github
Posted on July 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.