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.
tsTry
constx = 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:
ts
condition ? exprIfTrue : exprIfFalse
Conditional types
Conditional types allow for types to be expressed using a very similar (basically, the same) syntax
tsTry
classGrill {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:
tsTry
typeCookingDevice <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
ts
condition ? 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
tsTry
constone = 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 byX
is 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.
tsTry
typeFavoriteColors =| "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
Extract
ing the subset ofFavoriteColors
that 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
tsTry
typeOneNever = 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 | never
s
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
tsTry
typeanswer_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
↩