Learn the Key Concepts of TypeScript’s Powerful Generic and Mapped Types
As you write more complex types, you'll notice your code growing.
You might also start seeing code duplication…
When you need to reuse TypeScript code, what do you do? How can you keep your code DRY? How do you reduce the boilerplate code?
There are a few main concepts in Typescript that help you accomplish that goal of reducing boilerplate and reusing more code.
We’ll be specifically be covering Generics and Mapped Types
Both concepts can be a bit scary at first, mostly because of a knowledge gap that creates a wall and makes the code un-readable at first.
Generics are a way to create, in the type world, a similar functionality that functions offer.
Mapped Types are a way to derive and reshape types into new ones.
But, before diving into these new concepts, you need to build up your knowledge with the key features that enable the power of Generics and Mapped Types.
The keyof operator
keyof
is Typescript's answer to JavaScript’s Object.keys
operator.
Object.keys
returns a list of an object’s keys.
keyof
does something similar but in the typed world only. It will return a literal union type listing the "properties" of an object-like type.
This operator is the base building block for advanced typing like mapped and conditional types.
The keyof operator takes an object type and produces a string or numeric literal union of its keys. - Typescript Handbook
The code sample above is from a real webapp. The Colors
type describes a set of colors that can be used across the application.
The keyof
operator retrieves a literal union of all the possible colors from the Colors
type.
Literal union means a Union type made up by literal values like "primary" | "primaryBorder"
The union is then used to type the props of SomeComponent
, allowing the color
argument to be one of the colors defined in the type.
keyof
as constraint
The keyof
operator can also be used to apply constraints to a generic.
For this example, it is enough to know that a Generic behaves similarly to a function argument and that the Generic type can have a type or constraint.
The “Generic” code is the odd angle brackets section <Obj, Key extends keyof Obj>
. That section defines that this function will receive two "type parameters" that obey certain rules.
Then, the function declares that the arguments are of the type of that "type parameters" and it will return a type derived from the generic values as Obj[Key]
Let's dissect the generic portion of the above function definition:
Obj
is the name used to identify this Generic parameter. Usually people use single letters to identify the generic, but IMHO it is more clear to use a better name like you would with a variable. The intention here is to accept any object.- The second generic parameter is
Key extends keyof Obj
. Here theextends
keyword is used as a constraint and can be read as "Key is of ...." meaning that theKey
generic can only be a value found inkeyof Obj
. keyof Obj
is, as mentioned before, a union of string literals from the properties ofObj
andObj
is the first generic parameter. So in Typescript you can reference the previous generic directly.
So, does all of that mean?
It means that the function arguments of getObjectProps
will accept any object inside the obj
argument, but they key
argument can only be a string literal that exists as a property of obj
Other type of constraint that you can write using the keyof
operator is to restrict the return type of a function.
The above example can be read as objectKeys
accepts a obj
arguments that has to be of type Record<string, unknown>
and will return an array of all of the properties of that obj.
keyof
and template literals.
You can also use keyof
to construct complex template literals like the following example.
This will generate a union of literal strings based on the State
properties concatenating the property name with the set
word.
As you can see, the keyof
operator can be small but it is an important piece that unlocks powerful operations when used in the right places.
The extends
keyword
This keyword is very confusing at first glance. It's used in a few different places with different meanings.
The first usage of extends
is for interface inheritance. This lets you create new interfaces that inherit the behavior of a previous one. In other words, it extends the base interface/class.
Interfaces have two main purposes:
- Create the contract that a class must implement
- Perform type declaration.
The first interface, User
, describes a type with a few properties representing a general user of a certain application.
The second interface, named StaffUser
, represents a user who is part of the organization and therefore has a set of roles
. But this user also has firstName
, lastName
, and email
.
You don't want to write that over and over again, right?
But more important, what if the general User
entity changes? How can you be sure that the change is represented everywhere else?
That's why the StaffUser
is written with the extends
keyword, saying that this interface has all of the properties of the User
interface, plus the ones defined by itself.
This usage of extends
can be used to inherit from multiple interfaces at the same time by using a comma-separated list of the base interfaces.
This behavior can also be used to extends a Class
Another usage of the extends
keyword is to narrow the scope of a generic to make it more useful.
This usage of extends
narrowing down or constraining the type of a generic is the corner stone to be able to implement conditional types since the extends
will be used as condition to check if a generic is or not of a certain type.
In the example above, from a real world implementation of tanstack-query, a new type is defined: QueryFunction
. This type accepts two generic values, T
and TQueryKey
.
The extends
keyword here constrains the possible values of the TQueryKey
generic to be of the type QueryKey
, defined elsewhere in the source code. In other words, TQueryKey
has to be of type QueryKey
.
If you're not yet comfortable with the use of generics, think of them as function arguments in the type world. The
QueryFunction
type can be thought of as a function type that accepts two arguments (generics) namedT
, with a default value ofunknown
, andTQueryKey
, with a default value ofQueryKey
.
This usage of extend
, narrowing down or constraining the type of a generic, is the cornerstone of being able to implement conditional types, since extends
is used as a condition to check whether a generic is or is not of a certain type.
The never
keyword.
never
is a type “value” that represents something that will never occur. This is very handy for implementing different types as conditionals and discriminated unions.
A simple example can be defining the type of a set of function arguments, where some of those arguments are dependent:
A React component that can accept some props is a good example. In the above code, you can see that there are three types defined:
CardWithDescription
defines a type that can have adescription
property asstring
, but withtitle
andfooter
asnever
, meaning that they cannot be defined or used.CardWithoutDescription
is the opposite type, wheredescription
cannot be used, buttitle
andfooter
are mandatory.CardProps
defines a union of the previous two which is used to type the props of theCard
component.
With this setup, the Card
component can only be used with description
and no footer
and no title
, or vice versa. If for some reason you try to use the three props together, you'll get the following error:
Generics
If you're serious about becoming a true expert and taking your career to the next level, check out Matt Pocock's Total TypeScript and learn the underlying principles and patterns of being an effective TypeScript engineer.
In any programming language you have ways to implement the DRY principle, TypeScript is no different.
Generics help you build well-defined and consistent APIs that are also reusable. You can use Generics to build dynamic and reusable pieces of code that resemble JavaScript functions.
Let's see an example of a generic in the wild.
The example shows a type called isArray
. It receives a Generic "parameter" called T
and uses that to do some conditional "calculation" to return true
or false
.
This example can give you a hint: Generics are kind of like the function parameters of the typed world.
For convention, the name of the Generics is a single capital letter, but that is not required. You can pass any name to it.
To use generics, you need to follow the angle bracket syntax.
When Typescript sees angle brackets, it understands that there are type variables defined inside of them. You can pass as many generics as you need.
Then, you can use these "type parameters" inside the type definition to the right side of it.
Generics can be used all over the place in Typescript. For example, you can use them to create an interface that changes based on the generics used:
The interface UserInfo
has two properties that depend on the generics used:
· name
with a type of X
· rol
with a type of Y
So when you use UserInfo<string, string>
, both properties will be strings.
You'll find generics in almost every TypeScript code-base. They're the basic way to create reusable chunks of code and also the basic tool that library authors use to create a flexible API.
Conclusion
Mapped types and generics are powerful tools for any TypeScript developer.
With a good understanding of the key features of typescript, such as the keyof
operator, the extends
keyword, the never
keyword, and generics, you can unlock the full potential of TypeScript and write complex solutions for complex data shapes.
With these features, you can create and reuse code, narrow down types, and create custom types, all while ensuring that your code remains DRY.
For more curated TypeScript content, check out our TypeScript landing page!