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: Horse
s, and User
s. 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.