Check out my books on Amazon at https://www.amazon.com/John-Au-Yeung/e/B08FT5NT62
Subscribe to my email list now at http://jauyeung.net/subscribe/
One way to create reusable code is to create code that lets us use it with different data types as we see fit. TypeScript provides the generics construct to create reusable components where we can work with a variety of types with one piece of code. This allows devs to use these components by putting in their own types. In this article, we’ll look at the many ways to define generic classes and also validate properties by defining interfaces and the extends
keyword and also force the code to only accept property names that are in an object by using the keyof
keyword.
Defining a Generic Class
To define generic classes in TypeScript, we put the generic type marker in between the <>
after the class name. We can have more than one type marker separated by a comma. Also, we can use the same type marker to mark the type of the function to let us change parameter and return type of methods within our class. For example, we can write the following code to define a generic class with a single generic type marker:
class GenericPerson<T> {
name: T;
getName: (name: T) => T;
}let person = new GenericPerson<string>();
person.name = 'Jane';
person.getName = function(x) { return x; };
In the code above, we passed in the string type inside the <>
. We can also change to any other type we want like number
, boolean
, or any interface like in the following code:
class GenericPerson<T> {
name: T;
getName: (name: T) => T;
}let person = new GenericPerson<number>();
person.name = 2;
person.getName = function(x) { return x; };
We can also insert multiple types within the same class as in the following code:
class GenericPerson<T, U> {
name: T;
getName: (name: U) => U;
}let person = new GenericPerson<string, number>();
person.name = 'Jane';
person.getName = function (x) { return x; };
person.getName(2);
In the code above, T
and U
may or may not be different types. As long as we put them both in and that we pass in and assign data with the types we specified in the class definition, then the TypeScript compiler will accept it. The examples above are only useful for a limited amount of cases since we can’t reference any properties in our class definition since the TypeScript compiler doesn’t know what properties T
or U
contains.
To make the TypeScript compiler aware of the properties that may be used in our class definition, we can use an interface to list all the properties that can be used with the class and the use the extends
keyword after the generic type marker to denote the that type may have the list of properties that are listed in the interface. For example, if we want to have different complex types in our class methods, we can write something like the following code:
interface PersonInterface {
name: string;
age: number;
}
interface GreetingInterface {
toString(): string;
}
class GenericPerson<T extends PersonInterface, U extends GreetingInterface> {
person: T;
greet(greeting: U): string {
return `${greeting.toString()} ${this.person.name}. You're ${this.person.age} years old`;
}
}
let jane = new GenericPerson();
jane.person = {
name: 'Jane',
age: 20
};
console.log(jane.greet('Hi'));
In the code above, we defined 2 interfaces, the PersonInterface
and the GreetingInterface
to denote the properties that can be referenced with T
and U
respectively. In the PersonInterface
, we have the name
and age
properties listed so we can reference these properties for data with type T
. For the data with U
type we can call the toString
method on it. Therefore, in our greet
method, we can call toString
on greeting
since it has the U
type and since this.person
has the T
type, we can get the name
and age
properties from it.
Then after the class definition, we can instantiate the class and then set values for the name
and age
properties on the jane
object that we created. Then when we run console.log
on jane.greet(‘Hi’)
then we should see ‘Hi Jane. You’re 20 years old’ since we set the values for jane.person
.
We can also put in the types explicitly when we instantiate the object to make the types more clear. Instead of what we have above, we can change it slightly and write the following code:
interface PersonInterface {
name: string;
age: number;
}
interface GreetingInterface {
toString(): string;
}
class GenericPerson<T extends PersonInterface, U extends GreetingInterface> {
person: T;
greet(greeting: U): string {
return `${greeting.toString()} ${this.person.name}. You're ${this.person.age} years old`;
}
}
let jane = new GenericPerson<PersonInterface, string>();
jane.person = {
name: 'Jane',
age: 20
};
console.log(jane.greet('Hi'));
The only difference is that we add the <PersonInterface, string>
after new GenericPerson
. Note that we can add interfaces or primitive types in between the brackets. TypeScript only cares that the type has the methods listed in the interface we defined. Now that the types are constrained by these generic types, we don’t have to worry about referencing any unexpected properties. For example, if we reference something that doesn’t exist like in the following code:
interface PersonInterface {
name: string;
age: number;
}
interface GreetingInterface {
toString(): string;
}
class GenericPerson<T extends PersonInterface, U extends GreetingInterface> {
person: T;
greet(greeting: U): string {
return `${greeting.foo()} ${this.person.name}. You're ${this.person.age} years old`;
}
}
let jane = new GenericPerson<PersonInterface, string>();
jane.person = {
name: 'Jane',
age: 20
};
console.log(jane.greet('Hi'));
We would get “Property ‘foo’ does not exist on type ‘U’.(2339)” since we didn’t list foo
in our GreetingInterface
.
Constraining Retrieval of Object Properties
TypeScript also provides a way to let us get the properties of an object safely with generics. We can use the keyof
keyword to constrain the values of the than an object can take on to the key names of another object. For example, we can use the keyof
keyword like in the following code:
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
let x = { foo: 1, bar: 2, baz: 3 };console.log(getProperty(x, "foo"));
In the code above, we constrain the generic marker K
to only accept the keys of whatever object is passed in to obj
as the valid values of key
since K
has the extends keyof T
marker after K
. This means that whatever keys the T
type has, then the keys are valid values for K
. With code like the ones above, we don’t have to worry about getting values for properties that don’t exist. So if we pass in a key name that doesn’t exist in obj
, like in the following code:
getProperty(x, "a");
Then the TypeScript compiler will reject the code and give the error message “Argument of type ‘“a”’ is not assignable to parameter of type ‘“foo” | “bar” | “baz”’.(2345)“. This means that only 'foo'
, 'bar'
and 'baz'
are valid values for key
since it has the type K
, which has the extends keyof T
marker after it to constrain the valid uses to be the key names of obj
.
We can define a generic class easily with TypeScript. To define generic classes in TypeScript, we put the generic type marker in between the <>
after the class name. We can have more than one type marker separated by a comma. Also, we can use the same type marker to mark the type of the function to let us change parameter and return type of methods within our class. Also, we can use the extends
keyword to define the properties that can be referenced with our data marked by generic types. We can use the keyof
keyword to constrain the values of the than an object can take on to the key names of another object.