The Typescript type system is much more powerful than it may seem on the surface. This article provides an introduction to some of its advanced features along with some basics to showcase how flexible and creative you can be with the type system.
Two examples that use many of the features in the article to increment a number within the type system:
Contents
Introduction
Basic Types
Features
Extends
Inferred Type Variables
Immediately Indexed Object Definitions
Variance
Function Unions and Intersections
Decomposition
Mapped Types
Templated String Literals
Deferred Type Resolution
References
History
I've heard developers refer to Typescript as "Javascript with types" and was guilty of the same at first. While that may be somewhat true broadly speaking, the type system you get is much more feature-rich than it seems on the surface. It offers basic logic constructs, object transforms, and a host of possibly non-intentional tricks that let you be much more expressive than most are probably accustomed to coming from the majority of popular languages.
At a glance, the Typescript type system looks something like the following with respect to basic types:
It may seem complex but if you stare at it for a bit it makes a lot of sense. The relationships are read as "undefined
extends void
" or equivalently "undefined
is assignable to void
." A quick summary:
any
is the universal type. It is a top type, bottom type, and everything in between. unknown
is the top type. Everything is assignable to unknown
. never
is the bottom type. never
is assignable to everything. - literals extend their non-literal counterparts (e.g.
true
extends boolean
, 3
extends number
). - complex basic types extend
object
(e.g. function
, tuple
, enum
)
The different line-only arrows are for the cases where there is some notable exception such as assignability between arrays and tuples. Keep in mind that while these may seem like inheritance relationships, that's not necessarily the case since Typescript uses a structural type system.
class A { A: string; } class B { A: string; }
var a: A extends B ? true : false;
The type of a
is true
despite the fact A
and B
are hierarchically unrelated. This is because they have the same structure. It's much easier in my opinion to think in terms of sets:
class A { A: string; }
class B { A: string; }
class C { A: number; }
class D { A: number; B: string; }
class D extends C { B: string; }
From the set perspective, types can be thought of as named groupings of properties from the universal set U
of all possible properties where a property is a name associated with a type. Therefore A: number
and A: string
are different properties as well as A: string
and B: string
. A type extends another type if it is a superset of the other type. So in the example above, D
extends C
. If the superset relationship isn't a proper superset then both types extend each other - A
extends B
and B
extends A
. This is because they are effectively different names for the same type since they have exactly the same set of properties.
However it ends up making sense, the important part is that it makes sense because the rest of the features require understanding how types relate to one another.
Features
A extends B ? True-branch : False-branch
This is equivalent to a ternary if
with the condition being assignment compatibility from A
to B
. Furthermore in the true-branch, A
's type has the additional type constraint of B
similar to how type guards work. If A
is a naked type parameter (just a lone type variable) and is a union, that union is distributed over the extends
and the results are unioned together. For example:
class A { A: string; }
class B extends A { B: string; }
class C extends B { C: string; }
type example<T> = T extends B ? true : false;
var a: example<A|C>;
The above happens because after distribution we get (A extends B ? true : false)|(C extends B ? true : false)
which simplifies to false | true
. Note that this doesn't happen in the case of:
var b: A|C extends B ? true : false;
Since we no longer have a type parameter, no distribution occurs. Also consider the case of:
type example2<T> = [T] extends [B] ? true : false;
var c: example2<A|C>;
var d: example2<C>;
The type parameter is no longer naked since we are using it in a tuple type ([T]
) so no distribution occurs.
Inferred Type Variables
Inferred type variables are temporary type variables similar to type parameters but bound to the scope of the true-branch and are automatically assigned by context. Their types are as narrow/specific as can be safely inferred (otherwise all inferences would trivially resolve to any
or unknown
).
type example<T> = T extends infer U ? U : never;
var a: example<boolean>;
A more complicated example:
type example<T extends [...any]> = T extends [...infer _, infer A] ? A : T;
var a: example<[boolean, null, undefined, number]>;
var b: example<[void]>;
var c: example<[]>;
This uses inferred type variables to return the last type in a tuple. Notice that what we're essentially saying here is "can we successfully infer types for _
and A
from T
that make sense with the given signature?" The signature of the inference statement can have important repercussions for the resulting variable's type:
type example<T extends [...any]> = T extends (infer A)[] ? A : never;
var a: example<[string, number]>;
type example2<T extends [...any]> = T extends [...infer A] ? A : never;
var b: example2<[string, number]>;
The key difference between these is that (infer A)[]
is a variable-length array signature and [...infer A]
is a fixed-length array signature (i.e. a tuple). This shows how the resulting type information is widened into a union in the first example since it is inferred from a variable-length array signature despite the input being the more narrow tuple type. This loss of positional type information occurs because a variable-length array signature has no concept of position - only of what types are valid for all (not each) of its elements.
Immediately Indexed Object Definitions (i.e. switch statements for types)
One limitation of extends
is it doesn't cleanly allow switch-style branching. We can get around this by using the fact that if you index an object definition, what you get back is the associated property's type which effectively turns the object definition into a switch statement:
type PickOne<Choice extends 0 | 1 | 2 | 3> =
{
0: 'Uhhh...',
1: 'Odd',
2: 'Even',
3: 'Odd'
} [Choice];
Remember, literals are a type! If we were just using extends
this would look like:
type PickOne<Choice extends 0 | 1 | 2 | 3> =
Choice extends 0 ?
'Uhhh...'
: Choice extends 1 ?
'Odd'
: Choice extends 2 ?
'Even'
: Choice extends 3 ?
'Odd'
: never;
Kinda messy, right? The object indexer also supports any type expression that results in a valid index - not just simple type parameters:
type Nullable<T> =
{
0: T | null,
1: T
} [T extends string | object ? 0 : 1];
A bit of a silly example but it showcases the point. Another neat feature is support of recursion:
type digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type DigitsAfter<X extends digit, Current = never> =
{
0: DigitsAfter<1, 1>,
1: DigitsAfter<2, Current | 2>,
2: DigitsAfter<3, Current | 3>,
3: DigitsAfter<4, Current | 4>,
4: DigitsAfter<5, Current | 5>,
5: DigitsAfter<6, Current | 6>,
6: DigitsAfter<7, Current | 7>,
7: DigitsAfter<8, Current | 8>,
8: Current | 9,
9: never
} [X];
Recursion works as long as it can't infinitely recurse. There'll be either an error when using the type or on the recursive property that's causing the issue if infinite recursion is possible. Sometimes this can be a bit finnicky with what the type system accepts but it tends to work best if you have a clear base case and shallow recursion.
Variance
Variance isn't really different in Typescript per se. The standard stuff expected is there:
class A { A: string; }
class B extends A { B: string; }
class C extends B { C: string; }
let f1: ()=>A = () => new B();
let f2: (_: B)=>void = (_: A) => {};
let f3: (_: B)=>B = (_: A) => new C();
let f4: (_:B)=>void = (_:C) => {};
There's one big addition to the standard stuff if the "strictFunctionTypes":false
tsconfig option is set - function parameter types are bivariant. Not particularly useful in my experience outside of explicitly filtering things in a bivariant function context but good to be aware of in case a need arises.
class A { A: string; }
class B extends A { B: string; }
class C extends B { C: string; }
class D { D: string; }
class E extends D { E: string; }
type GetTypesRelatedTo<T, options> =
T extends any ?
options extends any ?
((_:options)=>void) extends ((_:T)=>void) ?
options
: never
: never
: never;
let test: GetTypesRelatedTo<B, A | B | C | D | E>;
Function Unions and Intersections
Unions and intersections behave very similar to base and derivative classes, respectively. Much like inheritance, the properties of assignability and functionality operate opposite of one another. "Up" towards more base types yields greater assignability but lower functionality while moving "down" towards more derivative types yields lower assignability but greater functionality.
From this perspective, a union is a base type relative to its constituent types and an intersection is a derivative type relative to its constituent types. We can verify this analogy holds:
class A { A: string; }
class B { B: string; }
class AB { A: string; B: string; }
let x: A | B;
x = new A();
x = new B();
x = new AB();
x.A;
x.B;
let y: A & B;
y = new A();
y = new B();
y = new AB();
y.A;
y.B;
This analogy is especially convenient for discussing unions and intersections of functions. For example, try to fill in the "???" when coercing the following into T=>U
form:
(a: ???)=>??? = ((a: string)=>string) | ((a: number)=>number)
(a: ???)=>??? = ((a: string)=>string) & ((a: number)=>number)
To solve this, first let's determine what the returns would be since that's a simple starting point:
- A union of functions would naturally have a return type that is the union of the function return types.
- An intersection of functions would naturally have a return type that is the intersection of the function return types.
This leads to a somewhat surprising conclusion when considering assignment; the coercion into a T=>U
form doesn't result in something that is strictly equivalent. Instead, coercion of a function union creates a baser type while coercion of a function intersection creates a more derivative type.
(a: ???)=>(string | number) = ((a: string)=>string) | ((a: number)=>number)
((a: string)=>string) & ((a: number)=>number) = (a: ???)=>(string & number)
Side note: Despite only talking about assignability between types, it's important to remember that a type's purpose is to describe a concrete representation. This fact has to be considered when determining what should be compatible. While the union example is relatively intuitive, it may have been a surprise that we consider individual assignment even in the intersection example. This is because practically speaking a function intersection represents function overloading at the concrete level and therefore the concrete result is only from a single function. This is why string=>string & number=>number
is not assignable to ???=>(string & number)
. Only one of those values would actually be returned and it's return would not satisfy the more derivative (string & number)
. The following is an example of these properties:
class Overload {
static test(a: string): string;
static test(a: number): number;
static test(a: string | number) { return a; }
}
let overloadTest1 = Overload.test("a");
let overloadTest2 = Overload.test(5);
let overloadFunc: ((a:string)=>string) & ((a:number)=>number) = Overload.test;
let overloadTest3 = overloadFunc("a");
let overloadTest4 = overloadFunc(5);
Now we need to figure out the argument types for the original example. For the union, we want something more derivative (contravariance of the arguments) so we'll go with string & number
since that is assignable to either string
or number
. For the intersection, we want something less derivative since we're working on the opposite side of the assignment now so we'll go with string | number
because both string
and number
are assignable to it.
(a: string & number)=>(string | number) = ((a: string)=>string) | ((a: number)=>number)
((a: string)=>string) & ((a: number)=>number) = (a: string | number)=>(string & number)
In summary, coercing an intersection of functions into T=>U
form creates a more derivative type while doing the same for a union of functions creates a baser type. The following demonstrates everything we just figured out:
class W { w: string; }
class U { u: string; }
declare const Inter: ((a: W)=>W) & ((a:U)=>U);
declare const TU_Inter: (a: W | U)=>(W & U);
let ex1: typeof Inter = TU_Inter;
let ex2: typeof TU_Inter = Inter;
declare const Union: ((a: W)=>W) | ((a:U)=>U);
declare const TU_Union: (a: W & U)=>(W | U);
let ex3: typeof Union = TU_Union;
let ex4: typeof TU_Union = Union;
In the following example, we'll step through the code explaining how we use the properties of function unions and intersections to extract the last element (see note at the end of the section) from a union; something that on the surface seems impossible without already knowing the element type in order to discriminate the union.
type UnionToFunctionIntersection<T> =
(T extends any ? () => T : never) extends infer U ?
(U extends any ? (_: U) => void : never) extends (_: infer V) => void ?
V
: never
: never;
type Last<T> = UnionToFunctionIntersection<T> extends () => (infer U) ? U : never;
Let's break it down from the top.
type UnionToFunctionIntersection<T> =
(T extends any ? () => T : never) extends infer U ?
What we're doing here is distributing T
in case it's a union, then returning a union of functions with each element of T
in a return-type position. We're inferring the result of this as U
so we have a simple type variable to refer to later. It also means the next line of code will properly distribute the union we just created.
(U extends any ? (_: U) => void : never) extends (_: infer V) => void ?
Here we're distributing the union of functions U
, then returning another union of functions with each element of U
in an argument-type position. For example, if we started with a type A | D
as T
, U
will be ()=>A | ()=>D
, and now the signature will be (_:()=>A)=>void | (_:()=>D)=>void
. Looks ridiculous but this is all a setup for the next part.
So when we get to extends (_: infer V) => void
, it may seem at first glance we'd just get our original U
back since we're inferring a type variable in the same position we moved the elements of U
into. However, distribution of a type only occurs if the expression is a simple type variable. The complex expression prior to extends
is not a simple type variable. So if distribution doesn't occur, what could V
be inferred as if the complex expression is (_:()=>A)=>void | (_:()=>D)=>void
?
((_:()=>A)=>void | (_:()=>D)=>void) extends (_: infer V) => void
If you remember the union discussion earlier in this section, coercing a union of functions into T=>U
form causes the argument types to be intersected therefore V
is inferred as ()=>A & ()=>D
.
Now let's look at the final line to see why we went through all this trouble just to turn a union into an intersection of functions.
type Last<T> = UnionToFunctionIntersection<T> extends () => (infer U) ? U : never;
Here we're taking the function intersection and inferring a type variable for its return type. What's the return type of ()=>A & ()=>D
? Again, if you recall the earlier discussion, coercing an intersection of function into T=>U
form causes the return types to be intersected therefore U
is inferred as A & D
. Except remember assignability is checked from left to right with extends
. We're going in the opposite direction! We solved for the general case where the T=>U
form was more derived earlier, yet here it needs to be less derived.
()=>U = (()=>A) & (()=>D)
Since there are no arguments we only need to consider the answer from a standard covariant perspective. This runs into a small issue though since an intersection is assignable to its contituent types - both A
and D
work for U
. Here there is no logically best solution. One just has to be picked, so Typescript decided on always returning the last return type in the intersection. U
is therefore inferred as D
. This also has the side-effect that intersections are not strictly commutative in Typescript (though they seem like it in many contexts).
declare const F1: ((a:string)=>string) & ((a:number)=>number);
declare const F2: ((a:number)=>number) & ((a:string)=>string);
type F1Last = typeof F1 extends (_:any)=>(infer L) ? L : never;
type F2Last = typeof F2 extends (_:any)=>(infer L) ? L : never;
F1('a');
F1(1);
That wraps up all the black magic going on. We build up a ridiculous looking function signature in order to turn around and unravel it utilizing the unique properties of how an argument type is inferred for function unions and how a return type is inferred for function intersections in order to grab the last type out of a union.
Important note: Unions in practice are usually well-ordered but the Typescript specification does not require this to be true. Therefore it's only safe to assume Last<T>
will give you some type from the union but not the last type, even if in practice the latter ends up being true most of the time. This is because the intersection used to retrieve the last type is based on a union, therefore a change in the union ordering would change the ordering of the resulting intersection.
Decomposition
Decomposition is the process of breaking down a tuple into its individual parts. The easiest way to achieve this is using inference with a tuple signature that uses a rest parameter for the elements not explicitly handled.
type FirstElementOf<Tuple> = Tuple extends [infer E, ...infer _] ? E : never;
type LastElementOf<Tuple> = Tuple extends [...infer _, infer E] ? E : never;
You could use a function context for this as well:
type FirstElementOf<Tuple> =
(Tuple extends any[] ? (...a:Tuple)=>void : never) extends (a:infer E, ...b:infer _)=>void ?
E
: never;
Functions don't support rest parameters at the start of the argument list so LastElementOf<Tuple>
can't be implemented this way. This method has no advantage over the cleaner, easier first way; it's just an interesting footnote.
Mapped Types
As we've seen in previous sections, the extends
clause can be very useful for creating a new type using an existing type. For example, A extends any ? ()=>A : never
or A extends any ? { a: A } : never
. Typescript also has a mechanism to create a new type using the existing type's properties instead of the type as a whole - mapped types.
{
[ <propertyName> in <properties> as <propertyRemap> ]: <propertyType>
}
propertyName
is a variable that will hold each element returned from the set properties
. It functions the same way the Javascript for...of
loop works. propertyRemap
is an optional expression where you can transform propertyName
into some other valid value for use as the property index. propertyType
is what the new type of the property will be. Let's look at some basic examples to see why all this is useful.
The following example uses a built-in Lowercase
utility type inside a mapped type to create a new type that is the old type with all the property names lowercased.
type LowercaseProperties<T> =
{
[Key in keyof T as Lowercase<Key>]: T[Key]
};
type TestType = { ABC: number; XYZ: string };
type TestTypeLower = LowercaseProperties<TestType>;
That's only a small part of what it's capable of though. You can create new types from raw information:
type Create<PropertyNames extends string> =
{
[Key in PropertyNames as
(Key extends 'id' ? Uppercase<Key> : Capitalize<Lowercase<Key>>)
]: Key extends 'id' ? number : string
};
type IdCardType = Create<'id'|'firstName'|'lastName'>;
You can also use it to change a type's readonly and optional modifiers by using + or - before the modifier. The following creates a readonly type with all properties required.
type MakeReadonly<T> =
{
+readonly [Key in keyof T]-?: T[Key]
};
type ReadonlyID = MakeReadonly<IdCardType>;
This also demonstrates that if the optional as
clause is left out, the iterator variable is used for the property index.
Templated String Literals
Exactly what it says on the tin. Be careful because as convenient as they are when you need them, the interpolated positions are cross-multiplied so can quickly spiral out of control into a compiler-crashing, monstrous union. For example, the following template creates every number from 0-1999. Add a couple more digits and watch Typescript wish it was never created.
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type UhOh = `${0|1}${Digit}${Digit}${Digit}`;
Templates are often useful for creating a new property index from the old one when mapping types.
type MakeJava<T> =
{
-readonly [K in keyof T]-?: T[K]
} &
{
-readonly [K in keyof T as `get${Capitalize<Lowercase<K>>}`]: ()=>T[K]
} &
{
-readonly [K in Exclude<keyof T, 'ID'> as `set${Capitalize<Lowercase<K>>}`]
: (set:T[K])=>void
};
type JavaID = MakeJava<ReadonlyID>;
That's really all there is to them.
Deferred Type Resolution
I won't even pretend to understand exactly how or why some type resolution behavior occurs. However, for whatever reason, there does seem to be a situation where type resolution is always deferred which can be very useful to help deal with the "type instantiation is too deep" error caused by Typescript's recursive depth limit. Whenever a type alias is an object definition's property's type and that object definition is returned from an alias, the type alias doesn't seem to need to be resolved before returning. This makes sense if we consider we don't need to resolve the property types in order to determine the object type to return.
It's probably easiest to showcase this in action:
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type FiftyNumbers = `${0|1|2|3|4}${Digit}`;
type UnionToFunctionIntersection<T> =
(T extends any ? () => T : never) extends infer U ?
(U extends any ? (_: U) => void : never) extends (_: infer V) => void ?
V
: never
: never;
type Last<T> = UnionToFunctionIntersection<T> extends () => (infer U) ? U : never;
type Push<Tuple extends unknown[], Element> = [...Tuple, Element];
type GenerateTuple<
Union,
IsEmpty extends boolean = [Union] extends [never] ? true : false
> =
true extends IsEmpty ?
[]
: Last<Union> extends infer T ?
GenerateTuple<Exclude<Union, T>> extends [...infer U] ?
Push<U, T>
: never
: never;
type tuple = GenerateTuple<FiftyNumbers>;
All the above code is doing is creating a tuple using the elements of a given union. We know for a fact the type instantiation is not infinite because there are only 50 elements, so to avoid the error all we need to do is avoid the recursive depth limit. To do that we're going to re-write GenerateTuple
to use deferred type resolution.
type PushFront<Tuple extends unknown[], Element> = [Element, ...Tuple];
type _GenerateTuple<
Union,
Tuple extends unknown[] = [],
IsEmpty extends boolean = [Union] extends [never] ? true : false
> =
true extends IsEmpty ?
{ _wrap: Tuple }
: Last<Union> extends infer T ?
{ _wrap: _GenerateTuple<Exclude<Union, T>, PushFront<Tuple, T>> }
: never;
The original GenerateTuple
peels off the union elements from the end then creates the tuple from those as the recursion unwinds. Since the new _GenerateTuple
uses the recursion to create a deeply nested object definition, we'll use a slightly different approach to create the tuple - on wind-up instead of wind-down. Due to this, we need to push the elements to the front of the tuple to maintain the ordering and we need a new type parameter to pass the tuple along to the next call since we can no longer rely on stack returns. The resulting object will look something like this:
{ _wrap: { _wrap: { _wrap: { _wrap: { _wrap: ['00', '01', '02', '03', '04'] } } } } }
So we've solved the recursive depth limit issue but it seems like we've traded it for another problem - how do we unwrap this in a depth-agnostic way? Luckily, this isn't as problematic as it seems at first.
type Unwrap<T> = T extends { _wrap: unknown } ? Unwrap<_Unwrap<T>> : T;
type _Unwrap<T> =
T extends { _wrap: { _wrap: infer R } } ?
{ _wrap: _Unwrap<R> }
: T extends { _wrap: infer R } ?
R
: T;
type GenerateTuple<Union> = Unwrap<_GenerateTuple<Union>>;
Let's tackle the core alias first - _Unwrap
. What the first two lines do is collapse two _wrap
objects into a single _wrap
object, calling _Unwrap
recursively on whatever is left. We're using deferred type resolution here again to ensure we don't hit the depth limit while unwrapping. Once there aren't two _wrap
objects to collapse, there must be only one so we simply unwrap it. In the case _Unwrap
gets called on a non-wrapped value, we just return that value (T
).
So a single "outside" call of _Unwrap
when completely resolved, including it's own recursive calls, results in halving the number of _wrap
objects in the overall structure. In order to fully unwrap the value, we therefore need to call _Unwrap
until we've fully unwrapped the inner value. This is what Unwrap
does. It calls _Unwrap
then sets up a recursive call to itself with the result. Only once that result no longer matches a _wrap
object's structure (i.e. the result is fully unwrapped) does it return a result which will be the inner value.
type example = { _wrap: { _wrap: { _wrap: { _wrap: { _wrap: ['00', '01', '02', '03', '04']}}}}};
type unwrappedType = Unwrap<example>;
Viola! Using deferred type resolution we've successfully avoided the depth limit error and performed the operation.
Again, this is an educated guess on what exactly happens under the covers with what I call deferred type resolution, but my guess is what's effectively happening is that since the property type's aren't needed to have a complete object type, the object type is marked complete for return, the alias unwinds, then immediately after the object is returned, the system realizes the property type's aren't fully resolved, which then triggers the alias(es) within the object to get called. This means by using this technique we only ever actually use a single stack depth beyond what we're currently at to process the recursive alias. This also means that unfortunately we can't make the wrapping "cleaner" because the alias used to wrap would trigger the resolution of the inner property types on return, leading us right back into the depth limit error.
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type FiftyNumbers = `${0|1|2|3|4}${Digit}`;
type Wrap<T> = { _wrap: T };
type GenerateTuple2<Union> = Unwrap<_GenerateTuple2<Union>>;
type _GenerateTuple2<
Union,
Tuple extends unknown[] = [],
IsEmpty extends boolean = [Union] extends [never] ? true : false
> =
true extends IsEmpty ?
{ _wrap: Tuple }
: Last<Union> extends infer T ?
Wrap<_GenerateTuple2<Exclude<Union, T>, PushFront<Tuple, T>>>
: never;
type tuple2 = GenerateTuple2<FiftyNumbers>;
Thanks for reading!
Typescript Handbook
GitHub: Typescript Recursive Conditional Types PR#40002
GitHub: Typescript Function Intersection Issue#42204
Susisu: How to Create Deep Recursive Types
9/2/21: Initial release.