Skip to content

Instantly share code, notes, and snippets.

@aishwarya257
Created September 27, 2021 21:32
Show Gist options
  • Save aishwarya257/4a5ccf27bd5a05d75ae27e16e1e6755b to your computer and use it in GitHub Desktop.
Save aishwarya257/4a5ccf27bd5a05d75ae27e16e1e6755b to your computer and use it in GitHub Desktop.

Create wonders with Advanced Typescript types

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

Mapped Types

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

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

Combining conditional with mapped types

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

Recursion with 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 types

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment