Last updated: Feb 29, 2024
Reading time·3 min

Let's say we have a class named Attributes that manages attributes of
different types - i.e. EmployeeAttributes, ClientAttributes, etc.
type EmployeeProps = { name: string; salary: number; }; export class Attributes<T> { constructor(private data: T) {} get(key: string): string | number { return this.data[key]; } } const attrs = new Attributes<EmployeeProps>({ name: 'Tom', salary: 5000, }); // name is of type string or number because that's // what we have hardcoded as a return of the get method const name = attrs.get('name');
We created an instance of the Attributes class passing it a generic of type
EmployeeProps.
The type has keys of name and salary of type string and number.
We used the get method on the instance to get the name attribute, however,
the type we get on the return value is either string or number.
This means that we can only use operations on it common to strings and numbers before using a type guard to narrow down the type of the value.
We should be able to tell TypeScript that the name variable should be of
type string, so we don't have to use type guards in this situation.
What we are trying to do is constrain the argument that the get method
takes to be either name or salary.
The name and salary properties are the only valid keys in the type T.
We can only get values from the data object based on those keys.
Once we have constrained the arguments of the get method to be one of the
keys of the type T, we should specify the return value of the get method to
be the type corresponding to the passed-in key.
So if we for example call the get() method as follows.
get('name');
The type of the name key in the T type is a string and that's the type the
get() method should return.
We can use a generic on the get method where we specify that generic type K
can only be one of the keys of the generic type T which is passed to the class
upon initialization.
If the argument of the get method is only constrained to be one of the keys of
the T interface that is the
signature of the data class property, then the get method should return
T[K] - the type of the key K from the type T.
type EmployeeProps = { name: string; salary: number; }; export class Attributes<T> { constructor(private data: T) {} get<K extends keyof T>(key: K): T[K] { return this.data[key]; } } const attrs = new Attributes<EmployeeProps>({ name: 'Tom', salary: 5000, }); // is now of type string const name = attrs.get('name'); // ERROR: Argument of type '"test"' is not assignable to parameter of type '"name" | "salary"'. const test = attrs.get('test');
If you now hover over the name variable you should see that it is of type
string because we've specified that the get method returns T[K], which is
an object lookup just like in JavaScript.
It looks up the value of the key K in the object T. In our case, this is the
value of the name key from the type EmployeeProps which is string.
Important points in the code sample:
The type of K can only ever be one of the keys of T - in our case, name
or salary.
In other words, we can only call get with one of the keys of T - name or
salary.
For the return value of get, we say "Look at the type T at the key K and
return the corresponding type". In this case, look at the type EmployeeProps
at the key name and return string.
We are only able to do this because in TYpeScript strings can be types. For
example, the string "world" can be of type "world".
type World = 'world'; function logHello(message: World) { console.log(`Hello ${message}`); } // ERROR: Argument of type '"all"' is not assignable to parameter of type '"world"'. logHello('all'); logHello('world');
The keys of the EmployeeProps interface are strings of type name and
salary.
This allows us to constrain the arguments the get method receives to the
specific keys. Once we have done that, all we have to do is to annotate the
return value of the get method as the lookup of the specific key K in the
type EmployeeProps.
I've also written an article on how to use generics in arrow functions.