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.