Create wonders with advanced TypeScript types

Aishwaryalakshmi Panneerselvam
9 min readSep 28, 2021

--

Image is taken from https://www.wallpapermaiden.com/

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 readonlykeyword, 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

--

--

Aishwaryalakshmi Panneerselvam
Aishwaryalakshmi Panneerselvam

Written by Aishwaryalakshmi Panneerselvam

Software Engineer at Mercedes Benz Tech Innovation

Responses (3)