Create wonders with advanced TypeScript types
No one wants to break their head on a Friday evening with a production bug that says cannot read property of undefined
. TypeScript is like the buddy next door that saves us from all these troubles. Like any other tool, one has to use it effectively to reap the real benefits. In this blog, I will explain some mind-blowing TypeScript features that help us to create effective reusable types. They are
- Mapped types
- Conditional types
- Combining mapped and Conditional types
- Recursion in Typescript
- Infer Type
Mapped Types
The mapped type takes a type as input and generates a new type from it. One can create new types with an interface, a type or even arrays.
Let’s start with the basic example first.
I have an Object type (Person) containing some properties and, I want to create another type with the keys of Person
, but their property type should be a boolean instead.
So, here is thePerson
type
type Person = {firstName: string;lastName: string;country: string;occupation: string;};
And the corresponding conversion code
Let’s dissect the parts to understand them better. Now, for exampletype PersonKeys = keyof Person
produces
type PersonKeys = 'firstName' | 'lastName' | 'country' | 'occupation'
Next, the full part
[Property in keyof Person]: boolean
It says to iterate over every Property
in PersonKeys
(aka keyof Person) and set the type as boolean.
One can even create a more reusable type with generics as follows. This can be applied to any object and not just Person
type ConvertToBoolean<Obj> = {[Property in keyof Obj]: boolean;};
That’s it. We have created a new type from the existing type without duplicating the original.
Let’s see another example with arrays containing const assertions. If you are not familiar with const assertions, you can learn more about them here.
I have an array containing the keys of an object, and now I want to iterate over the keys and assign string
as their type.
As you may have noticed we have something called T[number]
in the code. Before explaining what it is, let me throw some insights into how index signatures work in TypeScript.
Below we have the code for a Dictionary type. Now, if you do Dictionary[string]
, where string
is the type of the index, we get number[] (the corresponding type value for the index)
type Dictionary = { [index:string]: number[]}type P = Dictionary[string] // returns number[]
And as well all know, everything in JavaScript is an object. So, any array is also an object but with the index number
We can represent this as follows
type MyArray<T> = { [index:number]: T; length: number;}
This MyArray<T>
is the internal representation of array and is the same as string[], or Person[] or any other array.
In our example, we already said T
is an array (T extends readonly string[]
). Next, we get the individual element’s type with T[number]
like Dictionary[string]
, with the only difference in index type (for arrays, the index is a number)
The rest of the logic is the same as the first example of mapped types.
Mapped types get even more powerful with “+” and “-” operators.
The Person
type we saw above needs every property by default. Now, if I want to make every key optional in that type, I can make use of the mapped type and + operator like below.
The code says for every property in the ObjectType
, add?
at the end.
The other way round is possible too. You can make all the keys required with -
operator. Typescript has a built-in utility type Required
that does the same.
type Required<T> = { [P in keyof T]-?: T[P];};
One can also use “+” and “-” operators with the readonly
keyword, as in the example below. This example converts a non-mutable Person
type to a mutable type.
Conditional types
Conditional types are the ternary operators for TypeScript. The general format is
type DerivedType = CurrentType extends TypeA ? TrueType : FalseType;
As always, we will start with a basic example.
type isString<T> = T extends string ? true : false;type IamString = isString<'aish'>; // type returns truetype IamNotString = isString<2>; // type returns false
T extends string
is like T=string.
The code checks if the type of T = string, if yes, then the type will return true
otherwisefalse.
Here’s an example with arrays.
type ExtractTypeFromArray<T> = T extends any[] ? T[number] : never;type StringType = ExtractTypeFromArray<string[]>; // Type returns string
Utility functions like Extract
and Exclude
are also built with conditional types
type Exclude<T, U> = T extends U ? never : T;type ShirtSize = 'S' | 'XS' | 'M' | 'L' | 'XL';//Usagetype RequiredShirtSize = Exclude<ShirtSize, 'M' | 'L'>;//Outputtype RequiredShirtSize = 'S' | 'XS' | 'XL';
The Exclude
type says, if T = U
then never
(remove it from the union), otherwise, return the type T
Extract work the similar way too
type Extract<T, U> = T extends U ? T : never;
There is a limitation when narrowing types based on if condition in the JavaScript code with the conditional types
In the above example, if we remove as any
in our first if
condition, Typescript complains Type 'string' is not assignable to type 'T extends string ? T : boolean'
and the value of theinput
is inferred as T & string
.
There is also a detailed discussion on the limitation
Combining conditional with mapped types
We can create even more powerful types when combining conditional and mapped types. Let’s see a few examples.
The first example here, I have an Object type with certain properties, and I am interested to extract only the properties with type string
type Person = {name: string;country: string;age: number;isMarried: boolean;};
This code extracts the values from the original type
It returnstype PersonWithStringKeys = "name" | "country"
Let’s split the type into parts to understand better.
So, the first part:
{
[Property in keyof Obj]: Obj[Property] extends string ? Property : never
}
The code checks, for every Property
in Obj
, if onlyObj[Property]
, the value of the property (in our case, it could be one ofstring
, string
, number
and boolean
for Person
) is equal to string
, take the property (So for our case, it is name
and country
respectively). Otherwise, return never
This code produces
type Person = {name: string;country: string;age: never;isMarried: never;};
Now, I want to prune these keys with never
and is interested in only string types.
For this, I add [keyof Obj]
at the end. It takes only the keys and prunes the ones with never
. Finally, we have only name
and country
as output of PersonWithStringKeys
Now, let’s consider a more complex example.
Assume, we are interested only in the required properties in an object, this can be retrieved through
If this looks complicated for you, don’t worry. I will split the code into parts in the section below.
So, the first part:
[K in keyof T]-?
This tells typescript to delete ?
from every key of the input object type while producing the output object type. So, age?:number
and isMarried?:boolean
becomes age:number
and isMarried:boolean
respectively in the resultant output.
A short intro about Pick
before moving further. Pick
is a utility type from TypeScript, that says for a given type T
, pick only the property K
type NameProp = Pick<Person, 'name'>;// Outputtype NameProp = { name: string };
With this short intro, let’s move to the right side of the type.
{} extends Pick<T, K> ? never : K
The left side iterates over every property and asks if {} = Pick<T, K>
. Let’s take age
and name
as example properties and dissect the output
{} extends Pick<Person, "age">
produces the mini type {} extends {age?: number}
. The age is optional. So, the mini type can have either {}
or {age: <someNumber>}
as valid values.
Since the possible values contain {}
, the condition {} extends Pick<T, K>
returns true and the code returns never
.
Now, for name
, we have {} extends Pick<Person, "name">
which produces the mini type {} extends {name: string}
. Now, the mini type can have only one possible value {name: <someString>}
as the valid value. So, {} extends Pick<T, K>
fails and it take the value in the else condition which is K
Now, without the [keyof T]
at the end, we have
type RequiredPersonKeys = { name: "name"; age: never; country: "country"; isMarried: never;}[keyof T]
Now, with keyof T
, this becomes type RequiredPersonKeys = ‘name’ | ‘country’
;
Recursion with Typescript
You can create recursive types in Typescript. But, be cautious when using them in object types with larger depth. You will end up getting Type instantiation is excessively deep and possibly infinite.
when the object depth increases. Recursive types gives too much work to the compiler. So, use it sparingly and think if it is really necessary. With enough warnings forehand, let's get into action.
Let’s say we have an object type NestedObjectType
with properties containing string type at the leaf level and we want to convert every property from type string to type boolean.
This type recursively iterates over every property in NestedObjectType
until the leaf node and change its type from string
to boolean
. That is, for every Property
in T
, call the type recursively with its value T[Property]
.
For example, for T
= {name: string}
, then for Property name
, T[Property] is string
For a nested property
{ name: { firstName: string; lastName: string; }}
then T[Property]
is {firstName: string; lastName: string}
and this will be called with BooleanNestedObject<{firstName: string; lastName: string}>
. Now, it takes firstName
, lastName
and calls BooleanNestedObject<string>
for each of them and converts each sub-property type to a boolean.
The code finally produces
Another use case for recursion is when you want to access the property path.
Here is an object type
Now, if you want to access address.street.name
from an object of Person
type and perform some operation based on the path you provide on the object, then recursion comes as a handy solution.
Here is the solution
Here is the function test
that takes an object and a path as its arguments
You get both auto-completion as well as type-safety when accessing the variables as seen in the screenshot below.
Infer types
Last type for this blog, theinfer
keyword in TypeScript.
Infer
keyword is used with conditional types to dynamically identify types based on values passed and make a decision based on the dynamically inferred type.
So, a basic example first
type FlattenArrayType<ArrayType> =
ArrayType extends (infer Member)[] ? Member : ArrayType;type MyType = FlattenArrayType<number[]>; // the return value is number
Typescript checks if ArrayType
extends some array, it stores the member's type in the variable Member
and returns Member
if the extends
condition is true, else it returns ArrayType
Like other types we discussed above, Some of the TypeScript’s utility types already uses infer
keyword. Let's see some of them.
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
The type Parameters
expects a function as the generic argument and it says if T
is a function, then store the types of all args
as an array in the variable P
and return P
, otherwise, return never
.
In the below example, we get the arguments of getEmployeeData
as an array using Parameters
utility type.
ReturnType
is similar to Parameters
with the only difference, ReturnType
returns the return type of the function.
Parameters
and ReturnType
are often useful when using third party libraries. Often times we create wrappers around third-party APIs to use them in our application for better maintenance and portability. In such situations, if the library does not expose the types of parameters or return types, then Parameters
and ReturnType
becomes handy.
Another point to be noted is infer
keyword must be used with conditional types
. Otherwise, typescript would throw an error as
'infer' declarations are only permitted in the 'extends' clause of a conditional type.
You can also combine mapped type, conditional type and infer
keyword together
Let’s consider a type like this
type SupportedValues = { countries: Country[]; currencies: Currency[]; deliveryTypes: DeliveryType[];}
I want to derive a type from the above type whose keys are same as SupportedValues
and the types should be singular like Country
, Currency
and DeliveryType
. Let's see how could we achieve this without duplicating the original type.
type SupportedValue = {[Property in keyof SupportedValues]: SupportedValues[Property] extends (infer MemberType)[] ? MemberType : never}
Now, the magic
type SupportedValue = { countries: Country; currencies: Currency; deliveryTypes: DeliveryType;}
We can do tons of magical stuff with conditional, mapped and infer types in TypeScript. What I explained here is just a drop in the ocean. Writing efficient types is an art. If we master this, we can produce bug-free code, get auto-completion from the IDE. It ultimately leads to a good developer experience as well.
If you like my content, follow me at https://twitter.com/aishwarya2593