published on March 11th, 2018
TypeScript 2.8 introduces a fantastic new feature called conditional types which is going to make TypeScript's type system even more powerful and enable a huge range of type orperators which were previously not possible. Let's take a look at how this exciting new feature works and what we can do with it.
Conditional types essentially give you an if
statement along with
the ability to ask questions at the type level. This enables some
very powerful constructs which were not possible before. Conditional
types look as follows:
T extends U ? X : Y
In Anders Hejlsberg's words this adds the ability to express non-uniform type mappings. Before conditional types, there was basically no way to make an either-or decision in type operators, now there is.
The question in the conditional type is the extends
check - if T extends U
then the resulting type is X
, otherwise it is Y
. A type extends
another
type if it is assignable to it, or in some sense "at least as big as" the other type. If
T extends U
you can do everything with T
that you can do with U
or
use T
everywhere you can use U
.
Here is a very simple (and slightly silly) example for using conditional types:
type IsNumberType<T> = T extends number ? "yes" : "no";
type Yes = IsNumberType<3>; // type "yes"
type No = IsNumberType<"foo">; // type "no"
A more interesting and more useful example is to implement JavaScript's
typeof
operator at TypeScript's type level:
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
type T0 = TypeName<string>; // "string"
type T1 = TypeName<"a">; // "string"
type T2 = TypeName<true>; // "boolean"
type T3 = TypeName<() => void>; // "function"
type T4 = TypeName<string[]>; // "object"
One interesting rule about conditional types is how they interact with union types. A conditional types distributes over a union type with the following distribution law:
(A | B) extends T ? X : U = (A extends T ? X : U) | (B extends T ? X : U)
Let's see how we can apply this law to the TypeName
operator defined
above:
TypeName<string | (() => void)>
= (string | (() => void)) extends string ? "string" :
(string | (() => void)) extends number ? "number" :
(string | (() => void)) extends boolean ? "boolean" :
(string | (() => void)) extends undefined ? "undefined" :
(string | (() => void)) extends Function ? "function" :
"object";
= (string extends string ? "string" :
string extends number ? "number" :
string extends boolean ? "boolean" :
string extends undefined ? "undefined" :
string extends Function ? "function" :
"object")
|
((() => void) extends string ? "string" :
(() => void) extends number ? "number" :
(() => void) extends boolean ? "boolean" :
(() => void) extends undefined ? "undefined" :
(() => void) extends Function ? "function" :
"object")
= "string" | "function"
So we have a substitution rule by which we can evaluate conditional types where the argument is a union type.
Diff
Type OperatorAn interesting and useful application of this distribution rule is to
remove cases from union types. For example, it is easy to write a
Diff
type operator which relies on this rule:
type Diff<T, U> = T extends U ? never : T;
This might seem confusing at first (it certainly did to me!), but makes sense when looking at an example:
Diff<"a" | "b" | "c", "a" | "b">
= ("a" | "b" | "c") extends ("a" | "b") ? never : ("a" | "b" | "c")
= "a" extends ("a" | "b") ? never : "a"
| "b" extends ("a" | "b") ? never : "b"
| "c" extends ("a" | "b") ? never : "c"
= never
| never
| "c"
= "c"
NonNullable
Type OperatorA useful application to this the NonNullable
type operator which
removes the types null
and undefined
from a union type:
type NonNullable<T> = Diff<T, null | undefined>;
type Foo = NonNullable<string | null | undefined>; // Foo = string;
It would seem like we could the inverse of the Partial
type operator
with this technique - namely making all parameters on an object required.
However, this is not the case:
type AlmostNonPartial<T extends object> =
{ [KEY in keyof T]: NonNullable<T[KEY]>; };
type Bar = AlmostNonPartial<{ a?: number; b?: string; }>;
// o_O - a and b are still optional on Bar:
// Bar = { a?: number | undefined; b?: string | undefined; }
Fear not - rescue is in sight, TypeScript 2.8 also provides [better control over mapped type modifiers], with which we can write the NonPartial operator:
// notice the -? which removes the ? modifier
type NonPartial<T extends object> =
{ [KEY in keyof T]-?: T[KEY]; };
type Bar = NonPartial<{ a?: number; b?: string; }>;
// Bar now has the right type:
// Bar = { a: number; b: string; }
DeepReadonly
In TypeScript type mappings can be recursive under certain
conditions. Using recursive type definitions and conditional
types, we can implement the coveted DeepReadonly
type operator with
conditional and recursive types:
type DeepReadonly<T> =
T extends any[] ? DeepReadonlyArray<T[number]> :
T extends object ? DeepReadonlyObject<T> :
T;
interface DeepReadonlyArray<T> extends ReadonlyArray<DeepReadonly<T>> {}
type DeepReadonlyObject<T> = {
readonly [KEY in keyof T>]: DeepReadonly<T[KEY]>;
};
So with conditional types, we can now write type operators we could not previously write and probably simplify a few other ones which were possible but difficult to write. What's more, in another PR, the ability to infer type parameters within conditional types was added to the upcomming 2.8 release. With this ability we can basically pattern match on types and extract for example the return type of a function or that of a function parameter. More on this next time.