Typescript

How TypeScript Conditional Types Work

Sponsor

Conditional types in TypeScript give us the ability to define certain types based on logic, just like we do in other aspects of our code. They are a useful tool in defining types in TypeScript.

They take a familiar format, in that we write them like condition ? ifConditionTrue : ifConditionFalse - which is a format already used everywhere in TypeScript and Javascript. Let’s look at how they work.

How Conditional Types work in TypeScript

Let’s look at a simplistic example to understand how this works. Here, a value could be the user’s date of birth (DOB) or age. If it’s a date of birth, then the type should be string - but if it’s an age, it should be a number. We’ll define three types: Dob, Age, and UserAgeInformation.

type Dob = string; type Age = number; type UserAgeInformation<T> = T extends number ? number : string;

So as mentioned, Dob will be a string, like 12/12/1942, and Age, should be a number, like 96.

When we defined UserAgeInformation, we wrote it like this:

type UserAgeInformation<T> = T extends number ? number : string;

Where T is an argument for UserAgeInformation. We can pass any type in here. Then we say, if T extends number, then the type is number. Otherwise, it’s string. What we’re essentially saying here, is if T is of type number, then UserAgeInformation should be a number.

We can then pass Age into userAgeInformation if we want it to be a number, and Dob in, if we want it to be a string:

type Dob = string; type Age = number; type UserAgeInformation<T> = T extends number ? number : string; let userAge:UserAgeInformation<Age> = 100; let userDob:UserAgeInformation<Dob> = '12/12/1945';

Combining Conditional Types with keyof

We can take this a step further by checking if T extends an object. For example, let’s say we run a business which has two types of customers: Horses, and Users. Although a User has an address, a Horse typically only has a location. For each, we have different address formats, as shown below:

type User = { age: number, name: string, address: string } type Horse = { age: number, name: string } type UserAddress = { addressLine1: string, city: string, country: string, } type HorseAddress = { location: 'farm' | 'savanna' | 'field' | 'other' }

In the future, we may also have other types of customers, so we can check generically if T has the property address. If it does, use the UserAddress. Otherwise, use the HorseAddress as the final type:

type AddressComponents<T> = T extends { address: string } ? UserAddress : HorseAddress let userAddress:AddressComponents<User> = { addressLine1: "123 Fake Street", city: "Boston", country: "USA" } let horseAddress:AddressComponents<Horse> = { location: 'farm' }

When we say T extends { address: string }, we check if T has the property address on it. If it does, we’ll use UserAddress. Otherwise, we can default to HorseAddress.

Using T in conditional returns

We can even use T itself in the conditional returns. In this example, since T is defined as User when we call it (UserType<User>), myUser is of type User, and requires the fields defined in that type (age, name, address):

type User = { age: number, name: string, address: string } type Horse = { age: number, name: string } type UserType<T> = T extends { address: string } ? T : Horse let myUser:UserType<User> = { age: 104, name: "John Doe", address: "123 Fake Street" }

Union Types when using T in type outputs

If we were to pass a union type in here, each will be tested separately. For example, let’s say we did the following:

type UserType<T> = T extends { address: string } ? T : string let myUser:UserType<User | Horse> = { age: 104, name: "John Doe", address: "123 Fake Street" }

myUser, above, actually becomes of type User | string. That’s because although User passes the conditional check, Horse does not - so it returns string.

If we modify T in some way (like make it an array). All T values will be modified individually. For example, take the following example:

type User = { age?: number, name: string, address?: string } type Horse = { age?: number, name: string } type UserType<T> = T extends { name: string } ? T[] : never; // ^ -- will return the type arguement T as T[], if T contains the property `name` of type `string` let myUser:UserType<User | Horse> = [{ name: "John" }, { name: "Horse" }] // ^ -- becomes User[] | Horse[], since both User and Horse have the property name

Here, we’ve simplified User and Horse to only have the required property name. In our conditional type, both types contain the property name. As such, both return true, and the type returned is T[]. Since both return true, myUser has a type of User[] | Horse[], so we can simply provide an array of objects containing the name property.

This behaviour is usually fine, but you might want to instead return an array of either User or Horse in some circumstances. In that case, where we want to avoid the distributing of types like this, we can add brackets around T and { name: string }:

type User = { age?: number, name: string, address?: string } type Horse = { age?: number, name: string } type UserType<T> = [T] extends [{ name: string }] ? T[] : never; // ^ -- here, we avoid distributing the types, since T and { name: string } are in brackets let myUser:UserType<User | Horse> = [{ name: "John" }, { name: "Horse" }] // ^ -- that means the type is slightly different now - it is (User | Horse)[]

By using the square brackets, our type has now been converted to (User | Horse)[], rather than User[] | Horse[]. This can be useful in some specific circumstances, and is a complexity about conditional types which is good to remember.

Inferring types with conditional types

We can also use the infer keyword when using conditional types. Suppose we have two types, one for an array of numbers, and another for an array of strings. In this simple case, infer will infer what the types of each item in the array is, and return the correct type:

type StringArray = string[]; type NumberArray = number[]; type MixedArray = number[] | string[]; type ArrayType<T> = T extends Array<infer Item> ? Item : never; let myItem1:ArrayType<NumberArray> = 45 // ^ -- since the items in `NumberArray` are of type `number`, the type of `myItem` is `number`. let myItem2:ArrayType<StringArray> = 'string' // ^ -- since the items in `StringArray` are of type `string`, the type of `myItem` is `string`. let myItem3:ArrayType<MixedArray> = 'string' // ^ -- since the items in `MixedArray` can be `string` or `number, the type of `myItem is `string | number`

Here, we define a new argument in our conditional type called Item, which is the items within the Array which T extends. Notably, this only works if the type we pass in is an array, since we are using Array<infer Item>.

In cases where T is an array, then ArrayType returns the type of its items. If T is not an array, then ArrayType will be of type never.

Conclusion

Conditional types in TypeScript can seem confusing at first, but it’s basically just another way to simplify how we write types in some specific circumstances. It’s useful to know how it works, should you ever see it in a repository or project somewhere, or for simplifying your own codebase.

I hope you’ve enjoyed this guide. If you did, you might also enjoy the article I wrote on the Record utility type.

Last Updated Sunday, 7 August 2022
Johnny Simpson
Johnny Simpson

More Tips and Tricks Typescript

Subscribe for Weekly Dev Tips

Subscribe to our weekly newsletter, to stay up to date with our latest web development and software engineering posts via email. You can opt out at any time.

Not a valid email