Ternary operator with values
In a wide range of programming languages, we can find if/then/else logic. JavaScript provides a ternary1 operator that allows us to express this kind of logic concisely. For example.
tsTryconstx = 16constisXNegative =x >= 0 ? "no" : "yes"
The general format of this expression in the regular JS/TS world, when used with values (as shown in the snippet above) is:
tscondition ? exprIfTrue : exprIfFalse
Conditional types
Conditional types allow for types to be expressed using a very similar (basically, the same) syntax
tsTryclassGrill {startGas () {}stopGas () {}}classOven {setTemperature (degrees : number) {}}typeCookingDevice <T > =T extends "grill" ?Grill :Oven letdevice1 :CookingDevice <"grill">letdevice2 :CookingDevice <"oven">
Let’s remove everything except for the conditional type:
tsTrytypeCookingDevice <T > =T extends "grill" ?Grill :Oven
Expressing conditions
On the right side of the = operator, you can see the same three parts from our definition of a traditional value-based ternary operator
tscondition ? exprIfTrue : exprIfFalse
| part | expression |
|---|---|
| condition | T extends "grill" |
| exprIfTrue | Grill |
| exprIfFalse | Oven |
You probably notice the extends keyword in the condition, which as of TypeScript v5.3 is the only mechanism of expressing any kind of condition.
If we think back to the mental model of types a sets of allowed values, extends is a check of a subset relationship. Let’s look at a smaller example to convince ourselves of this
tsTryconstone = 1;consttwo = 2;constten = 10;typeIsLowNumber <T > =T extends 1 | 2 ? true : falsetypeTestOne =IsLowNumber <1>typeTestTwo =IsLowNumber <2>typeTestTen =IsLowNumber <10>typeTestTenWithTwo =IsLowNumber <10 | 2>
Let’s look specifically at the conditions, when T is each of our three types
T = 1—>{ 1 } extends { 1, 2 }—> trueT = 2—>{ 2 } extends { 1, 2 }—> trueT = 10—>{ 10 } extends { 1, 2 }—> falseT = 10 | 2—>{ 10, 2 } extends { 1, 2 }—> boolean
Looking at the first three test cases, we can see that
for
X extends Y, we’re really testing whether the set represented byXis a subset of the set represented byY
Of course the last test case is also quite interesting. How are we getting boolean out of this?
When a union type is “projected” through a generic, you can think of it kind of like each element of the union type is independently evaluated, and then all of the results are union’d together.
In this case
T = 2—>{ 2 } extends { 1, 2 }—> trueT = 10—>{ 10 } extends { 1, 2 }—> falsetrue | false—>boolean
Utility types that use conditional types
There are several types that are broadly useful enough that TypeScript includes them as part of the “core types” for the JS language.
Now that we’ve learned about conditional types, let’s study
the built-in utility types Extract and Exclude, which are
implemented with conditional types
Extract
Extract is useful for obtaining some sub-part of a type that is assignable to some other type.
tsTrytypeFavoriteColors =| "dark sienna"| "van dyke brown"| "yellow ochre"| "sap green"| "titanium white"| "phthalo green"| "prussian blue"| "cadium yellow"| [number, number, number]| {red : number;green : number;blue : number }typeStringColors =Extract <FavoriteColors , string>typeObjectColors =Extract <FavoriteColors , {red : number }>typeTupleColors =Extract <FavoriteColors , [number, number, number]>
In plain language…
We’re
Extracting the subset ofFavoriteColorsthat is assignable tostring
Exclude
Exclude is the opposite of Extract, in that it’s useful for obtaining
the part of a type that’s not assignable to some other type
tsTry// a set of four specific thingstypeFavoriteColors =| "dark sienna"| "van dyke brown"| "yellow ochre"| "sap green"| "titanium white"| "phthalo green"| "prussian blue"| "cadium yellow"| [number, number, number]| {red : number;green : number;blue : number }typeNonStringColors =Exclude <FavoriteColors , string>
How do these work?
Here’s the complete source code for these types
ts/*** Exclude from T those types that are assignable to U*/type Exclude<T, U> = T extends U ? never : T/*** Extract from T those types that are assignable to U*/type Extract<T, U> = T extends U ? T : never
They’re just conditional types, and the only difference
between them is the reversal of the “if true” and “if false” expressions (never : T vs T : never).
You may be wondering how the T that’s returned by this expression isn’t the same T that we passed in. Remember that each element of the union type is evaluated independently, and then all of the resultant types are union-ed back together again.
What these utility types take advantage of, is that union-ing a type with never is essentially a no-op
tsTrytypeOneNever = 1 | never
As a consequence, all the union members that are subtypes of U and all of the union members that aren’t are effectively separated into groups. All that’s different between Extract and Exclude is which group is returned to us, and which effectively disappears into | nevers
Quiz: Expressing conditions
Let’s study a few examples of extends scenarios and see if we can figure out
whether it will evaluate to true or false
| condition | |
|---|---|
| 1 | 64 extends number |
| 2 | number extends 64 |
| 3 | string[] extends any |
| 4 | string[] extends any[] |
| 5 | never extends any |
| 6 | any extends any |
| 7 | Date extends {new (...args: any[]): any } |
| 8 | (typeof Date) extends {new (...args: any[]): any } |
Click to reveal answers // SPOILER WARNING
tsTrytypeanswer_1 = 64 extends number ? true : falsetypeanswer_2 = number extends 64 ? true : falsetypeanswer_3 = string[] extends any ? true : falsetypeanswer_4 = string[] extends any[] ? true : falsetypeanswer_5 = never extends any ? true : falsetypeanswer_6 = any extends any ? true : falsetypeanswer_7 =Date extends { new (...args : any[]): any }? true: falsetypeanswer_8 = typeofDate extends { new (...args : any[]): any }? true: false
-
Definition of ternary: three-part
↩