Typescript has sever features. In this blog, I will explain about some mind blowing Typescript features. They include
- Mapped types
- Conditional types
- Combining mapped and Conditional types
- Recursion in Typescript
- Infer Type
It is much like map
array function in Javascript but for types. It takes a type as input and generates a new type based on it. Mapped types are used to reduce redundant types. We can create new types from interface
, type
and even arrays
.
Let's start with the basic example first
This type has four properties, all expecting a string as their value.
type Person = {
firstName: string;
lastName: string;
country: string;
occupation: string;
};
Now, let's assume I want another object with the same keys of Person
but we want the types changed to boolean
for example. Instead of duplicating the type, we could do the following
type PersonWithBooleanKeys = {
[Property in keyof Person]: boolean;
};
// Now, the derived type will be
type Person = {
firstName: boolean;
lastName: boolean;
country: boolean;
occupation: boolean;
};
This produce a new type from an existing type without duplicating the types. Let's dissect the parts to understand it better.
The type type PersonKeys = keyof Person
produces
type PersonKeys = 'firstName' | 'lastName' | 'country' | 'occupation';
The code says, take every Property
in PersonKeys
and set the type as boolean
[Property in keyof Person]: boolean
We can even create a more reusable type with generics as follows
type ConvertToBoolean<Obj> = {
[Property in keyof Obj]: boolean;
};
Now, this could be applied to any object and not just Person
type BooleanPersonObj = ConvertToBoolean<Person>;
Here's an example with arrays containing const assertions. If you are not familiar with const assertions, you can learn more here
const requiredKeys = ['firstName', 'lastName', 'country', 'occupation'] as const;
type GenerateStringObjectFromArray<T extends readonly string[]> = {
[Property in typeof T[number]]: string;
};
type Person = GenerateStringObjectFromArray<typeof requiredKeys>;
//Now, if you check the generated type of person
type Person = {
firstName: string;
lastName: string;
country: string;
occupation: string;
};
Mapped types get more powerful with "+" and "-" operators.
In our Person
type, every key is mandatory. Now, if we want to make them optional
type AnyObject = Record<string, unknown>;
type Optional<ObjectType extends AnyObject> = {
[Property in keyof ObjectType]+?: ObjectType[Property];
};
type OptionalPerson = Optional<Person>;
//Now, OptionalPerson becomes
type OptionalPerson = {
firstName?: string | undefined;
lastName?: string | undefined;
country?: string | undefined;
occupation?: string | undefined;
};
Even, the other way round is possible, you can make all keys required with -
operator. Typescript has Required
type, if you inspect the code
// This removes `?` from the object type
type Required<T> = {
[P in keyof T]-?: T[P];
};
// An object containing optional keys
type OptionalPerson = {
firstName?: string;
lastName?: string;
country?: string;
occupation?: string;
};
type RequiredPerson = Required<OptionalPerson>;
// becomes an object with required keys
type RequiredPerson = {
firstName: string;
lastName: string;
country: string;
occupation: string;
};
This could even be applied to keys like readonly
. Let's assume you have a readonly object
type Person = {
readonly name: string;
readonly occupation: string;
};
const person: Person = {
name: 'Aishwarya',
occupation: 'Engineer',
};
person.name = 'Typescript'; // 💥 Error: Cannot assign to 'name' because it is a read-only property.
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
type MutablePerson = Mutable<Person>;
const anotherPerson: MutablePerson = {
name: 'Typescript',
occupation: 'Engineer',
};
anotherPerson.name = 'Aishwarya'; // ✅ No error
Mapped types are a nice value addition to Typescript.
Conditional types are like ternary operators in JavaScript. It will be in the format
type DerivedType = CurrentType extends TypeA ? TrueType : FalseType;
Let's start with a basic example. In the below example, it checks if the type of T = string, if yes, then the type will be inferred as true
else false
T extends string means T=string
type isString<T> = T extends string ? true : false;
type IamString = isString<'aish'>; // type inferred as true
type IamNotString = isString<2>; // type inferred as false
Now, let's consider an example with arrays
type ExtractTypeFromArray<T> = T extends any[] ? T[number] : never;
type StringType = ExtractTypeFromArray<string[]>; // Type is inferred as 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';
//Usage
type RequiredShirtSize = Exclude<ShirtSize, 'M' | 'L'>;
//Output
type RequiredShirtSize = 'S' | 'XS' | 'XL';
The Exclude
type says, if T = U
then never
(remove it from the union), otherwise return the type
Extract work the similar way too
type Extract<T, U> = T extends U ? T : never;
However, there is a limitation when narrowing types based on if condition with conditional types
In this 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'
.
The value of input
is inferred as T & string
.
function normalizeValue<T extends string | boolean>(input: T): T extends string ? T : boolean {
if (typeof input === 'string') {
return ('-' + input) as any;
}
return !input as any;
}
normalizeValue('Aishwarya'); // return type inferred as string
normalizeValue(false); // return type inferred as boolean
There is also a detailed discussion on the limitation
We can create even more powerful types when conditional and mapped types are combined. Let's see few examples.
type Person = {
name: string;
country: string;
age: number;
isMarried: boolean;
};
In the above example, if we wish to extract only the properties whose type is string
, then we can write as follows
type StringTypeKeys<Obj> = {
[Property in keyof Obj]: Obj[Property] extends string ? Property : never;
}[keyof Obj];
type PersonWithStringKeys = StringTypeKeys<Person>;
This type is inferred as type MyObjectWithStringKeys = "name" | "country"
Let's split the type into parts to understand better
{ [Property in keyof Obj]: Obj[Property] extends string ? Property : never }
We check, for every Property
in Obj
, if Obj[Property]
, the value of the property (for the example, it is string
, string
, number
and boolean
), is equal to string
, take the property (In our example, it is name
and country
). Otherwise return never
This code produces
type Person = {
name: string;
country: string;
age: never;
isMarried: never;
};
Now, we want to prune these keys with never
and is interested in only string types.
For this, we add [keyof Obj]
at the end. It takes only the keys and prune the ones with never
. So, 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
type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];
type Person = {
name: string;
country: string;
age?: number;
isMarried?: number;
};
type RequiredPersonKeys = RequiredKeys<Person>;
// the type is inferred as
type RequiredPersonKeys = 'name' | 'country';
This might look complicated for you. Let's dissect the type into parts
This tells typescript to delete ?
from every key of input type to produce output type. So, age?:number
and isMarried?:boolean
becomes age:number
and isMarried:boolean
respectively in the resultant output
[K in keyof T]-?
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'>;
//is inferred aas
type NameProp = { name: string };
Now, let's move to the right sight of the type,
{} extends Pick<T, K>
this iterates over every property and asks if {} = Pick<T, K>
. That is,
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 {}
, then we say take 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 a valid value. So, {} extends Pick<T, K>
fails and it take the value in the else condition K
Now, without the [keyof T]
at the end, we have
{
name: "name";
age: never;
country: "country";
isMarried: never;
}[keyof T]
Now, with keyof T
, this becomes name
and country
. See, that's the power of Typescript
You can even create recursive types in Typescript. But, be cautious when using them as 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. Enough of the warnings now. Let's get into action
type BooleanNestedObject<T> = T extends string
? boolean
: {
[Property in keyof T]: BooleanNestedObject<T[K]>;
};
This type recursively iterates to the object's leaf level property and changes it's type from string
to boolean
.
{
[Property in keyof T]: BooleanNestedObject<T[K]>;
};
This says for every Property
in T
, call the type recursively with its value T[K]
. That is for a T
=> {name: string}
and property name
, T[K] is string
.
For a nested property
{
name: {
firstName: string;
lastName: string;
}
}
then T[K]
is {firstName: string; lastName: string}
and this will be called with BooleanNestedObject<{firstName: string; lastName: string}>
. Now, it takes firstName
, lastName
calls BooleanNestedObject<string>
for each of it and converts it to a boolean.
Let's consider an example
type NestedObjectType = {
property1: {
property2: string;
property3: string;
};
property4: {
property5: {
property6: string;
};
};
};
type ConvertedBooleanNestedObjectType = BooleanNestedObject<NestedObjectType>;
// this produces
type ConvertedBooleanNestedObjectType = {
property1: {
property2: boolean;
property3: boolean;
};
property4: {
property5: {
property6: boolean;
};
};
};
Another use case for recursion is when you want to access the a property path. For example, if you want to access address.street.name
from a object of Person
type and perform some operation based on the path you provide on the object, then recursion come as a handy solution. You get both auto completion as well as typesafety when accessing the variables.
type Person = {
name: {
first: string;
last: string;
};
address: {
street: {
streetNumber: number;
name: string;
};
zipCode: number;
city: string;
country: string;
};
};
type Recursion<T> = keyof {
[Property in keyof T as T[Property] extends string | number | boolean | null
? Property
: `${string & Property}.${string & Recursion<T[Property]>}`]: true;
};
function test<T>(a: T, fields: Recursion<T>[]) {}
test(
{
name: {
first: 'Aishwarya',
last: 'Lakshmi',
},
address: {
street: {
streetNumber: 12,
name: 'XYZ',
},
zipCode: 123,
city: 'ASD',
country: 'ASD',
},
},
['address.street.name']
);
// Image placeholder
Infer
keyword is used with conditional types to dynamically identify types based on values passed and make a decision based on the outcome.
Let's start with a basic example
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 we say if T
is a function, then store the types of all args
as array in P
and return P
, otherwise return never
.
Let's consider an example
function getEmployeeData(name: string, email: string) {
return {
name,
email,
};
}
type GetEmployeeDataParameters = Parameters<typeof getEmployeeData>; // => returns [name: string, email: string]
ReturnType
is similar to Parameters
with the only difference, ReturnType
returns the return type of the function
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
Parameters
and ReturnType
are often useful when using third party libraries. If the library does not expose types of the functions it expose. You can access them with these utility types.
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[];
}
Assume, we 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. If we learn to master writing efficient types, we can produce bug free code, get good auto-completion from the IDE and also good developer experience with good type safe code.