Generics
# Generics
In software engineering, we not only want to create well-defined and consistent APIs but also consider reusability. Components that can work with not just current data types but also future ones provide great flexibility when building large systems.
In languages like C# and Java, generics can be used to create reusable components that can work with multiple data types. This allows users to use components with their own data types.
# Basic Example
Let's create our first example using generics: the identity function. This function returns whatever is passed into it. You can think of it as the echo command.
Without generics, the function might look like this:
function identity(arg: number): number {
return arg
}
2
3
Or, we could define the function using the any type:
function identity(arg: any): any {
return arg
}
2
3
Using any causes the function to accept any type for arg, but this loses some information: the input type and return type should be the same. If we pass in a number, we only know that any type could be returned.
Therefore, we need a way to ensure the return type is the same as the input parameter type. Here, we use a type variable, which is a special kind of variable that works with types rather than values.
function identity<T>(arg: T): T {
return arg
}
2
3
We've added the type variable T to identity. T captures the type the user provides (e.g., number), and we can use that type afterwards. Here we use T again as the return type. Now we know that the parameter type and return type are the same. This allows us to track type information throughout the function.
We call this version of the identity function generic, because it works with multiple types. Unlike using any, it doesn't lose information -- like the first example, it maintains accuracy by accepting a number type and returning a number type.
After defining the generic function, we can use it in two ways. The first is to pass all arguments, including the type argument:
let output = identity<string>('myString')
Here we explicitly specified that T is string and passed it as a type argument using <> rather than ().
The second method is more common. It uses type inference -- the compiler automatically determines the type of T based on the argument passed:
let output = identity('myString')
Notice we don't need to explicitly pass the type in angle brackets (<>); the compiler can look at the value myString and set T to its type. Type inference helps keep code concise and readable. If the compiler can't automatically infer the type, you may need to explicitly pass T as in the first approach, which can happen in more complex cases.
# Using Generic Type Variables
When creating generic functions like identity, the compiler requires that you use the generic type correctly within the function body. In other words, you must treat these parameters as if they could be any type.
Look at the identity example again:
function identity<T>(arg: T): T {
return arg
}
2
3
What if we want to log the length of arg? We might be tempted to do this:
function loggingIdentity<T>(arg: T): T {
console.log(arg.length)
return arg
}
2
3
4
If we do this, the compiler will report an error saying we're using .length on arg, but nothing indicates that arg has this property. Remember, these type variables represent any type, so someone using this function might pass in a number, which doesn't have a .length property.
Now suppose we want to work with arrays of T rather than T directly. Since we're working with arrays, .length should be available. We can create this array just like any other:
function loggingIdentity<T>(arg: T[]): T[] {
console.log(arg.length)
return arg
}
2
3
4
You can read the type of loggingIdentity as: the generic function loggingIdentity takes a type parameter T and an argument arg which is an array of Ts, and returns an array of Ts. If we pass in an array of numbers, we get a number array back since T would be number. This lets us use the generic type variable T as part of the type rather than the whole type, giving us greater flexibility.
# Generic Types
In the previous section, we created the identity generic function that works with various types. In this section, we'll explore the type of the function itself and how to create generic interfaces.
The type of a generic function is just like a non-generic function, with a type parameter listed first, similar to a function declaration:
function identity<T>(arg: T): T {
return arg
}
let myIdentity: <T>(arg: T) => T = identity
2
3
4
5
We can also use a different name for the generic type parameter, as long as the number and usage correspond:
function identity<T>(arg: T): T {
return arg
}
let myIdentity: <U>(arg: U) => U = identity
2
3
4
5
We can also write the generic type as a call signature of an object literal:
function identity<T>(arg: T): T {
return arg
}
let myIdentity: {<T>(arg: T): T} = identity
2
3
4
5
This leads us to writing our first generic interface. Let's extract the object literal from the example above into an interface:
interface GenericIdentityFn {
<T>(arg: T): T
}
function identity<T>(arg: T): T {
return arg
}
let myIdentity: GenericIdentityFn = identity
2
3
4
5
6
7
8
9
We can even make the generic parameter a parameter of the entire interface. This way we can clearly see which specific generic type we're using (e.g., Dictionary<string> rather than just Dictionary). This also makes the type parameter visible to all other members of the interface.
interface GenericIdentityFn<T> {
(arg: T): T
}
function identity<T>(arg: T): T {
return arg
}
let myIdentity: GenericIdentityFn<number> = identity
2
3
4
5
6
7
8
9
Notice the subtle change. Instead of describing a generic function, we now have a non-generic function signature as part of a generic type. When we use GenericIdentityFn, we pass a type argument to specify the generic type (here: number), locking in the type used by the code that follows. Understanding when to put the parameter on the call signature vs. on the interface itself is helpful for describing which parts of a type are generic.
In addition to generic interfaces, we can create generic classes. Note that it's not possible to create generic enums and generic namespaces.
# Generic Classes
Generic classes look similar to generic interfaces. Generic classes use angle brackets (<>) enclosing the generic type, following the class name.
class GenericNumber<T> {
zeroValue: T
add: (x: T, y: T) => T
}
let myGenericNumber = new GenericNumber<number>()
myGenericNumber.zeroValue = 0
myGenericNumber.add = function(x, y) {
return x + y
}
2
3
4
5
6
7
8
9
10
The usage of GenericNumber is pretty straightforward, and you may have noticed that there's nothing restricting it to number. You could use strings or other more complex types.
let stringNumeric = new GenericNumber<string>()
stringNumeric.zeroValue = ''
stringNumeric.add = function(x, y) {
return x + y
}
console.log(stringNumeric.add(stringNumeric.zeroValue, 'test'))
2
3
4
5
6
7
Just like with interfaces, putting the generic type directly after the class helps us confirm that all members of the class are using the same type.
As we noted in the Classes section, a class has two sides: the static side and the instance side. Generic classes are generic over their instance side, so static members of the class cannot use the generic type.
# Generic Constraints
Sometimes we want to operate on a set of values of a certain type, and we know what properties that set of values has. In the loggingIdentity example, we wanted to access arg's length property, but the compiler couldn't prove every type has a length property, so it raised an error.
function loggingIdentity<T>(arg: T): T {
console.log(arg.length)
return arg
}
2
3
4
Instead of working with all types, we'd like to constrain the function to work with any type that has the .length property. As long as the type has this property, we'll allow it -- at minimum, it must have this property. To do so, we define a constraint on T.
We define an interface to describe the constraint, create an interface with a .length property, and use this interface with the extends keyword to implement the constraint:
interface Lengthwise {
length: number
}
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length) // OK
return arg
}
2
3
4
5
6
7
8
Now this generic function is constrained, so it no longer works with any and all types:
loggingIdentity(3); // Error
We need to pass in values whose type satisfies the constraint -- they must have the required property:
loggingIdentity({length: 10, value: 3}) // OK
# Using Type Parameters in Generic Constraints
You can declare a type parameter that is constrained by another type parameter. For example, we want to get a property from an object using its name. We want to ensure that the property exists on the object obj, so we need a constraint between the two types.
function getProperty<T, K extends keyof T> (obj: T, key: K ) {
return obj[key]
}
let x = {a: 1, b: 2, c: 3, d: 4}
getProperty(x, 'a') // okay
getProperty(x, 'm') // error
2
3
4
5
6
7
8