DEV Community

Cover image for An Introduction to Conditional Types in TypeScript
Johnny Simpson
Johnny Simpson

Posted on • Originally published at fjolt.com

An Introduction to Conditional Types in TypeScript

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)[]
Enter fullscreen mode Exit fullscreen mode

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`
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)