Chapter 4. Generics
Until now, our main goal was to take the inherent flexibility of JavaScript and find a way to formalize it through the type system. We added static types for a dynamically typed language, to communicate intent, get tooling, and catch bugs before they happen.
Some parts in JavaScript don’t really care about static types, though. For example, an isKeyAvailableInObject
function should only check if a key is available in an object; it doesn’t need to know about the concrete types. To properly formalize a function like this we can use TypeScript’s structural type system and describe either a very wide type for the price of information or a very strict type for the price of
flexibility.
But we don’t want to pay any price. We want both flexibility and information. Generics in TypeScript are just the silver bullet we need. We can describe complex relationships and formalize structure for data that has not been defined yet.
Generics, along with its gang of mapped types, type maps, type modifiers, and helper types, open the door to metatyping, where we can create new types based on old ones and keep relationships between types intact while the newly generated types challenge our original code for possible bugs.
This is the entrance to advanced TypeScript concepts. But fear not, there shan’t be dragons, unless we define them.
4.1 Generalizing Function Signatures
Discussion
You are writing an application that stores several language files (for example, subtitles) in an object. The keys are the language codes, and the values are URLs. You load language files by selecting them via a language code, which comes from some API or user interface as string
. To make sure the language code is correct and valid, you add an isLanguageAvailable
function that does an in
check and sets the correct type using a type predicate:
type
Languages
=
{
de
:
URL
;
en
:
URL
;
pt
:
URL
;
es
:
URL
;
fr
:
URL
;
ja
:
URL
;
};
function
isLanguageAvailable
(
collection
:
Languages
,
lang
:
string
)
:
lang
is
keyof
Languages
{
return
lang
in
collection
;
}
function
loadLanguage
(
collection
:
Languages
,
lang
:
string
)
{
if
(
isLanguageAvailable
(
collection
,
lang
))
{
// lang is keyof Languages
collection
[
lang
];
// access ok!
}
}
Same application, different scenario, entirely different file. You load media data into an HTML element: either audio, video, or a combination with certain animations in a canvas
element. All elements exist in the application already, but you need to select the right one based on input from an API. Again, the selection comes as string
, and you write an isElementAllowed
function to ensure that the input is actually a valid key of your AllowedElements
collection:
type
AllowedElements
=
{
video
:
HTMLVideoElement
;
audio
:
HTMLAudioElement
;
canvas
:
HTMLCanvasElement
;
};
function
isElementAllowed
(
collection
:
AllowedElements
,
elem
:
string
)
:
elem
is
keyof
AllowedElements
{
return
elem
in
collection
;
}
function
selectElement
(
collection
:
AllowedElements
,
elem
:
string
)
{
if
(
isElementAllowed
(
collection
,
elem
))
{
// elem is keyof AllowedElements
collection
[
elem
];
// access ok
}
}
You don’t need to look too closely to see that both scenarios are very similar. The type guard functions especially catch our eye. If we strip away all the type information and align the names, they are identical:
function
isAvailable
(
obj
,
key
)
{
return
key
in
obj
;
}
The two of them exist because of the type information we get. Not because of the input parameters, but because of the type predicates. In both scenarios we can tell more about the input parameters by asserting a specific keyof
type.
The problem is that both input types for the collection are entirely different and have no overlap. Except for the empty object, for which we don’t get that much valuable information if we create a keyof
type. keyof {}
is actually never
.
But there is some type information here that we can generalize. We know the first input parameter is an object. And the second one is a property key. If this check evaluates to true
, we know that the first parameter is a key of the second parameter.
To generalize this function, we can add a generic type parameter to isAvailable
called Obj
, put in angle brackets. This is a placeholder for an actual type that will be substituted once isAvailable
is used. We can use this generic type parameter like we would use AllowedElements
or Languages
and can add a type predicate. Since Obj
can be substituted for every type, key
needs to include all possible property keys—string
, symbol
, and number
:
function
isAvailable
<
Obj
>
(
obj
:
Obj
,
key
:
string
|
number
|
symbol
)
:
key
is
keyof
Obj
{
return
key
in
obj
;
}
function
loadLanguage
(
collection
:
Languages
,
lang
:
string
)
{
if
(
isAvailable
(
collection
,
lang
))
{
// lang is keyof Languages
collection
[
lang
];
// access ok!
}
}
function
selectElement
(
collection
:
AllowedElements
,
elem
:
string
)
{
if
(
isAvailable
(
collection
,
elem
))
{
// elem is keyof AllowedElements
collection
[
elem
];
// access ok
}
}
And there you have it: one function that works in both scenarios, no matter which types we substitute Obj
for. Just like JavaScript works! We still get the same functionality, and we get the right type information. Index access becomes safe, without sacrificing flexibility.
The best part? We can use isAvailable
just like we would use an untyped JavaScript equivalent. This is because TypeScript infers types for generic type parameters through usage. And this comes with some neat side effects. You can read more about that in Recipe 4.3.
4.3 Getting Rid of any and unknown
Solution
Use generic type parameters when you get to the actual type eventually; refer to Recipe 2.2 on the decision between any
and unknown
.
Discussion
When we are using generics, they might seem like a substitute for any
and unknown
. Take an identity
function—its only job is to return the value passed as input
parameter:
function
identity
(
value
:
any
)
:
any
{
return
value
;
}
let
a
=
identity
(
"Hello!"
);
let
b
=
identity
(
false
);
let
c
=
identity
(
2
);
It takes values of every type, and the return type of it can also be anything. We can write the same function using unknown
if we want to safely access properties:
function
identity
(
value
:
unknown
)
:
unknown
{
return
value
;
}
let
a
=
identity
(
"Hello!"
);
let
b
=
identity
(
false
);
let
c
=
identity
(
2
);
We can even mix and match any
and unknown
, but the result is always the same: Type information is lost. The type of the return value is what we define it to be.
Now let’s write the same function with generics instead of any
or unknown
. Its type annotations say that the generic type is also the return type:
function
identity
<
T
>
(
t
:
T
)
:
T
{
return
t
;
}
We can use this function to pass in any value and see which type TypeScript infers:
let
a
=
identity
(
"Hello!"
);
// a is string
let
b
=
identity
(
2000
);
// b is number
let
c
=
identity
({
a
:
2
});
// c is { a: number }
Assigning to a binding with const
instead of let
gives slightly different results:
const
a
=
identity
(
"Hello!"
);
// a is "Hello!"
const
b
=
identity
(
2000
);
// b is 2000
const
c
=
identity
({
a
:
2
});
// c is { a: number }
For primitive types, TypeScript substitutes the generic type parameter with the actual type. We can make great use of this in more advanced scenarios.
With TypeScript’s generics, it’s also possible to annotate the generic type parameter:
const
a
=
identity
<
string
>
(
"Hello!"
);
// a is string
const
b
=
identity
<
number
>
(
2000
);
// b is number
const
c
=
identity
<
{
a
:
2
}
>
({
a
:
2
});
// c is { a: 2 }
If this behavior reminds you of annotation and inference described in Recipe 3.4, you are absolutely right. It’s very similar but with generic type parameters in functions.
When using generics without constraints, we can write functions that work with values of any type. Inside, they behave like unknown
, which means we can do type guards to narrow the type. The biggest difference is that once we use the function, we substitute our generics with real types, not losing any information on typing at all.
This allows us to be a bit clearer with our types than just allowing everything. This pairs
function takes two arguments and creates a tuple:
function
pairs
(
a
:
unknown
,
b
:
unknown
)
:
[
unknown
,
unknown
]
{
return
[
a
,
b
];
}
const
a
=
pairs
(
1
,
"1"
);
// [unknown, unknown]
With generic type parameters, we get a nice tuple type:
function
pairs
<
T
,
U
>
(
a
:
T
,
b
:
U
)
:
[
T
,
U
]
{
return
[
a
,
b
];
}
const
b
=
pairs
(
1
,
"1"
);
// [number, string]
Using the same generic type parameter, we can make sure we get tuples only where each element is of the same type:
function
pairs
<
T
>
(
a
:
T
,
b
:
T
)
:
[
T
,
T
]
{
return
[
a
,
b
];
}
const
c
=
pairs
(
1
,
"1"
);
// ^
// Argument of type 'string' is not assignable to parameter of type 'number'
So, should you use generics everywhere? Not necessarily. This chapter includes many solutions that rely on getting the right type information at the right time. When you are happy with a wider set of values and can rely on subtypes being compatible, you don’t need to use generics at all. If you have any
and unknown
in your code, think whether you need the actual type at some point. Adding a generic type parameter instead might help.
4.4 Understanding Generic Instantiation
Solution
Remember that values of a generic type can be—explicitly and implicitly—substituted with a variety of subtypes. Write subtype-friendly code.
Discussion
You create a filter logic for your application. You have different filter rules that you can combine using "and" | "or"
combinators. You can also chain regular filter rules with the outcome of combinatorial filters. You create your types based on this
behavior:
type
FilterRule
=
{
field
:
string
;
operator
:
string
;
value
:
any
;
};
type
CombinatorialFilter
=
{
combinator
:
"and"
|
"or"
;
rules
:
FilterRule
[];
};
type
ChainedFilter
=
{
rules
:
(
CombinatorialFilter
|
FilterRule
)[];
};
type
Filter
=
CombinatorialFilter
|
ChainedFilter
;
Now you want to write a reset
function that, based on an already provided filter, resets all rules. You use type guards to distinguish between CombinatorialFilter
and ChainedFilter
:
function
reset
(
filter
:
Filter
)
:
Filter
{
if
(
"combinator"
in
filter
)
{
// filter is CombinatorialFilter
return
{
combinator
:
"and"
,
rules
:
[]
};
}
// filter is ChainedFilter
return
{
rules
:
[]
};
}
const
filter
:
CombinatorialFilter
=
{
rules
:
[],
combinator
:
"or"
};
const
resetFilter
=
reset
(
filter
);
// resetFilter is Filter
The behavior is what you are after, but the return type of reset
is too wide. When we pass a CombinatorialFilter
, we should be sure that the reset filter is also a CombinatorialFilter
. Here it’s the union type, just like our function signature indicates. But you want to make sure that if you pass a filter of a certain type, you also get the same return type. So you replace the broad union type with a generic type parameter that is constrained to Filter
. The return type works as intended, but the implementation of your function throws errors:
function
reset
<
F
extends
Filter
>
(
filter
:
F
)
:
F
{
if
(
"combinator"
in
filter
)
{
return
{
combinator
:
"and"
,
rules
:
[]
};
// ^ '{ combinator: "and"; rules: never[]; }' is assignable to
// the constraint of type 'F', but 'F' could be instantiated
// with a different subtype of constraint 'Filter'.
}
return
{
rules
:
[]
};
//^ '{ rules: never[]; }' is assignable to the constraint of type 'F',
// but 'F' could be instantiated with a different subtype of
// constraint 'Filter'.
}
const
resetFilter
=
reset
(
filter
);
// resetFilter is CombinatorialFilter
While you want to differentiate between two parts of a union, TypeScript thinks more broadly. It knows that you might pass in an object that is structurally compatible with Filter
, but it has more properties and is therefore a subtype.
This means you can call reset
with F
instantiated to a subtype, and your program would happily override all excess properties. This is wrong, and TypeScript tells you that:
const
onDemandFilter
=
reset
({
combinator
:
"and"
,
rules
:
[],
evaluated
:
true
,
result
:
false
,
});
/* filter is {
combinator: "and";
rules: never[];
evaluated: boolean;
result: boolean;
}; */
Overcome this by writing subtype-friendly code. Clone the input object (still type F
), set the properties that need to be changed accordingly, and return something that is still of type F
:
function
reset
<
F
extends
Filter
>
(
filter
:
F
)
:
F
{
const
result
=
{
...
filter
};
// result is F
result
.
rules
=
[];
if
(
"combinator"
in
result
)
{
result
.
combinator
=
"and"
;
}
return
result
;
}
const
resetFilter
=
reset
(
filter
);
// resetFilter is CombinatorialFilter
Generic types can be one of many in a union, but they can be much, much more. TypeScript’s structural type system allows you to work on a variety of subtypes, and your code needs to reflect that.
Here’s a different scenario but with a similar outcome. You want to create a tree data structure and write a recursive type that stores all tree items. This type can be
subtyped, so you write a createRootItem
function with a generic type parameter since you want to instantiate it with the correct subtype:
type
TreeItem
=
{
id
:
string
;
children
:
TreeItem
[];
collapsed
?:
boolean
;
};
function
createRootItem
<
T
extends
TreeItem
>
()
:
T
{
return
{
id
:
"root"
,
children
:
[],
};
// '{ id: string; children: never[]; }' is assignable to the constraint
// of type 'T', but 'T' could be instantiated with a different subtype
// of constraint 'TreeItem'.(2322)
}
const
root
=
createRootItem
();
// root is TreeItem
We get a similar error as before, since we can’t possibly say that the return value will be compatible with all the subtypes. To solve this problem, get rid of the generic! We know how the return type will look—it’s a TreeItem
:
function
createRootItem
()
:
TreeItem
{
return
{
id
:
"root"
,
children
:
[],
};
}
The simplest solutions are often the better ones. But now you want to extend your software by being able to attach children of type or subtype TreeItem
to a newly created root. We don’t add any generics yet and are somewhat dissatisfied:
function
attachToRoot
(
children
:
TreeItem
[])
:
TreeItem
{
return
{
id
:
"root"
,
children
,
};
}
const
root
=
attachToRoot
([]);
// TreeItem
root
is of type TreeItem
, but we lose any information about the subtyped children. Even if we add a generic type parameter just for the children, constrained to TreeItem
, we don’t retain this information on the go:
function
attachToRoot
<
T
extends
TreeItem
>
(
children
:
T
[])
:
TreeItem
{
return
{
id
:
"root"
,
children
,
};
}
const
root
=
attachToRoot
([
{
id
:
"child"
,
children
:
[],
collapsed
:
false
,
marked
:
true
,
},
]);
// root is TreeItem
When we start adding a generic type as a return type, we run into the same problems as before. To solve this issue, we need to split the root item type from the children item type, by opening up TreeItem
to be a generic, where we can set Children
to be a subtype of TreeItem
.
Since we want to avoid any circular references, we need to set Children
to a default BaseTreeItem
, so we can use TreeItem
both as a constraint for Children
and for attachToRoot
:
type
BaseTreeItem
=
{
id
:
string
;
children
:
BaseTreeItem
[];
};
type
TreeItem
<
Children
extends
TreeItem
=
BaseTreeItem
>
=
{
id
:
string
;
children
:
Children
[];
collapsed
?:
boolean
;
};
function
attachToRoot
<
T
extends
TreeItem
>
(
children
:
T
[])
:
TreeItem
<
T
>
{
return
{
id
:
"root"
,
children
,
};
}
const
root
=
attachToRoot
([
{
id
:
"child"
,
children
:
[],
collapsed
:
false
,
marked
:
true
,
},
]);
/*
root is TreeItem<{
id: string;
children: never[];
collapsed: false;
marked: boolean;
}>
*/
Again, we write subtype friendly and treat our input parameters as their own, instead of making assumptions.
4.5 Generating New Object Types
Solution
Use generic mapped types to create new object types based on the original type.
Discussion
Let’s go back to the toy shop from Recipe 3.1. Thanks to union types, intersection types, and discriminated union types, we were able to model our data quite nicely:
type
ToyBase
=
{
name
:
string
;
description
:
string
;
minimumAge
:
number
;
};
type
BoardGame
=
ToyBase
&
{
kind
:
"boardgame"
;
players
:
number
;
};
type
Puzzle
=
ToyBase
&
{
kind
:
"puzzle"
;
pieces
:
number
;
};
type
Doll
=
ToyBase
&
{
kind
:
"doll"
;
material
:
"plush"
|
"plastic"
;
};
type
Toy
=
Doll
|
Puzzle
|
BoardGame
;
Somewhere in our code, we need to group all toys from our model in a data structure that can be described by a type called GroupedToys
. GroupedToys
has a property for each category (or "kind"
) and a Toy
array as value. A groupToys
function takes an unsorted list of toys and groups them by kind:
type
GroupedToys
=
{
boardgame
:
Toy
[];
puzzle
:
Toy
[];
doll
:
Toy
[];
};
function
groupToys
(
toys
:
Toy
[])
:
GroupedToys
{
const
groups
:
GroupedToys
=
{
boardgame
:
[],
puzzle
:
[],
doll
:
[],
};
for
(
let
toy
of
toys
)
{
groups
[
toy
.
kind
].
push
(
toy
);
}
return
groups
;
}
There are already some niceties in this code. First, we use an explicit type annotation when declaring groups
. This ensures we are not forgetting any category. Also, since the keys of GroupedToys
are the same as the union of "kind"
types in Toy
, we can easily index access groups
by toy.kind
.
Months and sprints pass, and we need to touch our model again. The toy shop is now selling original or maybe alternate vendors of interlocking toy bricks. We wire the new type Bricks
up to our Toy
model:
type
Bricks
=
ToyBase
&
{
kind
:
"bricks"
,
pieces
:
number
;
brand
:
string
;
}
type
Toy
=
Doll
|
Puzzle
|
BoardGame
|
Bricks
;
Since groupToys
needs to deal with Bricks
, too, we get a nice error because GroupedToys
has no clue about a "bricks"
kind:
function
groupToys
(
toys
:
Toy
[])
:
GroupedToys
{
const
groups
:
GroupedToys
=
{
boardgame
:
[],
puzzle
:
[],
doll
:
[],
};
for
(
let
toy
of
toys
)
{
groups
[
toy
.
kind
].
push
(
toy
);
// ^- Element implicitly has an 'any' type because expression
// of type '"boardgame" | "puzzle" | "doll" | "bricks"' can't
// be used to index type 'GroupedToys'.
// Property 'bricks' does not exist on type 'GroupedToys'.(7053)
}
return
groups
;
}
This is desired behavior in TypeScript: knowing when types don’t match anymore. This should draw our attention. Let’s give GroupedToys
and groupToys
an update:
type
GroupedToys
=
{
boardgame
:
Toy
[];
puzzle
:
Toy
[];
doll
:
Toy
[];
bricks
:
Toy
[];
};
function
groupToys
(
toys
:
Toy
[])
:
GroupedToys
{
const
groups
:
GroupedToys
=
{
boardgame
:
[],
puzzle
:
[],
doll
:
[],
bricks
:
[],
};
for
(
let
toy
of
toys
)
{
groups
[
toy
.
kind
].
push
(
toy
);
}
return
groups
;
}
There is one bothersome thing: the task of grouping toys is always the same. No matter how much our model changes, we will always select by kind and push into an array. We would need to maintain groups
with every change, but if we change how we think about groups, we can optimize for change. First, we change the type GroupedToys
to feature optional properties. Second, we initialize each group with an empty array if there hasn’t been any initialization yet:
type
GroupedToys
=
{
boardgame
?:
Toy
[];
puzzle
?:
Toy
[];
doll
?:
Toy
[];
bricks
?:
Toy
[];
};
function
groupToys
(
toys
:
Toy
[])
:
GroupedToys
{
const
groups
:
GroupedToys
=
{};
for
(
let
toy
of
toys
)
{
// Initialize when not available
groups
[
toy
.
kind
]
=
groups
[
toy
.
kind
]
??
[];
groups
[
toy
.
kind
]
?
.
push
(
toy
);
}
return
groups
;
}
We don’t need to maintain groupToys
anymore. The only thing that needs maintenance is the type GroupedToys
. If we look closely at GroupedToys
, we see that there is an implicit relation to Toy
. Each property key is part of Toy["kind"]
. Let’s make this relation explicit. With a mapped type, we create a new object type based on each type in Toy["kind"]
.
Toy["kind"]
is a union of string literals: "boardgame" | "puzzle" | "doll" | "bricks"
. Since we have a very reduced set of strings, each element of this union will be used as its own property key. Let that sink in for a moment: we can use a type to be a property key of a newly generated type. Each property has an optional type modifier and points to a Toy[]
:
type
GroupedToys
=
{
[
k
in
Toy
[
"kind"
]]
?:
Toy
[];
};
Fantastic! Every time we change Toy
, we immediately change Toy[]
. Our code needs no change at all; we can still group by kind as we did before.
This is a pattern we have the potential to generalize. Let’s create a Group
type that takes a collection and groups it by a specific selector. We want to create a generic type with two type parameters:
-
The
Collection
can be anything. -
The
Selector
, a key ofCollection
, so it can create the respective properties.
Our first attempt would be to take what we had in GroupedToys
and replace the concrete types with type parameters. This creates what we need but also causes an error:
// How to use it
type
GroupedToys
=
Group
<
Toy
,
"kind"
>
;
type
Group
<
Collection
,
Selector
extends
keyof
Collection
>
=
{
[
x
in
Collection
[
Selector
]]
?:
Collection
[];
// ^ Type 'Collection[Selector]' is not assignable
// to type 'string | number | symbol'.
// Type 'Collection[keyof Collection]' is not
// assignable to type 'string | number | symbol'.
// Type 'Collection[string] | Collection[number]
// | Collection[symbol]' is not assignable to
// type 'string | number | symbol'.
// Type 'Collection[string]' is not assignable to
// type 'string | number | symbol'.(2322)
};
TypeScript warns us that Collection[string] | Collection[number] | Collection[symbol]
could result in anything, not just things that can be used as a key. That’s true, and we need to prepare for that. We have two options.
First, use a type constraint on Collection
that points to Record<string, any>
. Record
is a utility type that generates a new object where the first parameter gives you all keys and the second parameter gives you the types:
// This type is built-in!
type
Record
<
K
extends
string
|
number
|
symbol
,
T
>
=
{
[
P
in
K
]
:
T
;
};
This elevates Collection
to a wildcard object, effectively disabling the type-check from Groups
. This is OK because if something would be an unusable type for a property key, TypeScript will throw it away anyway. So the final Group
has two constrained type parameters:
type
Group
<
Collection
extends
Record
<
string
,
any
>
,
Selector
extends
keyof
Collection
>
=
{
[
x
in
Collection
[
Selector
]]
:
Collection
[];
};
The second option is to do a check for each key to see if it is a valid string key. We can use a conditional type to see if Collection[Selector]
is in fact a valid type for a key. Otherwise, we would remove this type by choosing never
. Conditional types are their own beast, and we tackle this in Recipe 5.4 extensively:
type
Group
<
Collection
,
Selector
extends
keyof
Collection
>
=
{
[
k
in
Collection
[
Selector
]
extends
string
?
Collection
[
Selector
]
:
never
]
?:
Collection
[];
};
Note that we did remove the optional type modifier. We do this because making keys optional is not the task of grouping. We have another type for that: Partial<T>
, another mapped type that makes every property in an object type optional:
// This type is built-in!
type
Partial
<
T
>
=
{
[
P
in
keyof
T
]
?:
T
[
P
]
};
No matter which Group
helper you create, you can now create a GroupedToys
object by telling TypeScript that you want a Partial
(changing everything to optional properties) of a Group
of Toys
by "kind"
:
type
GroupedToys
=
Partial
<
Group
<
Toy
,
"kind"
>>
;
4.6 Modifying Objects with Assertion Signatures
Solution
Use assertion signatures to change types independently of if
and switch
statements.
Discussion
JavaScript is a very flexible language. Its dynamic typing features allow you to change objects at runtime, adding new properties on the fly. And developers use this. There are situations where you, for example, run over a collection of elements and need to assert certain properties. You then store a checked
property and set it to true
, just so you know that you passed a certain mark:
function
check
(
person
:
any
)
{
person
.
checked
=
true
;
}
const
person
=
{
name
:
"Stefan"
,
age
:
27
,
};
check
(
person
);
// person now has the checked property
person
.
checked
;
// this is true!
You want to mirror this behavior in the type system; otherwise, you would need to constantly do extra checks if certain properties are in an object, even though you can be sure that they exist.
One way to assert that certain properties exist are, well, type assertions. We say that at a certain point in time, this property has a different type:
(
person
as
typeof
person
&
{
checked
:
boolean
}).
checked
=
true
;
Good, but you would need to do this type assertion over and over again, as they don’t change the original type of person
. Another way to assert that certain properties are available is to create type predicates, like those shown in Recipe 3.5:
function
check
<
T
>
(
obj
:
T
)
:
obj
is
T
&
{
checked
:
true
}
{
(
obj
as
T
&
{
checked
:
boolean
}).
checked
=
true
;
return
true
;
}
const
person
=
{
name
:
"Stefan"
,
age
:
27
,
};
if
(
check
(
person
))
{
person
.
checked
;
// checked is true!
}
This situation is a bit different, though, which makes the check
function feel clumsy: you need to do an extra condition and return true
in the predicate function. This doesn’t feel right.
Thankfully, TypeScript has another technique we can leverage in situations like this: assertion signatures. Assertion signatures can change the type of a value in control flow, without the need for conditionals. They have been modeled for the Node.js assert
function, which takes a condition, and it throws an error if it isn’t true. This means that, after calling assert
, you might have more information than before. For example, if you call assert
and check if a value has a type of string
, you know that after this assert
function the value should be string
:
function
assert
(
condition
:
any
,
msg
?:
string
)
:
asserts
condition
{
if
(
!
condition
)
{
throw
new
Error
(
msg
);
}
}
function
yell
(
str
:
any
)
{
assert
(
typeof
str
===
"string"
);
// str is string
return
str
.
toUpperCase
();
}
Please note that the function short-circuits if the condition is false. It throws an error, the never
case. If this function passes, you can really assert the condition.
While assertion signatures have been modeled for the Node.js assert function, you can assert any type you like. For example, you can have a function that takes any value for an addition, but you assert that the values need to be number
to continue:
function
assertNumber
(
val
:
any
)
:
asserts
val
is
number
{
if
(
typeof
val
!==
"number"
)
{
throw
Error
(
"value is not a number"
);
}
}
function
add
(
x
:
unknown
,
y
:
unknown
)
:
number
{
assertNumber
(
x
);
// x is number
assertNumber
(
y
);
// y is number
return
x
+
y
;
}
All the examples you find on assertion signatures are based after assertions and short-circuit with errors. But we can take the same technique to tell TypeScript that more properties are available. We write a function that is very similar to check
in the predicate function before, but this time we don’t need to return true
. We set the property, and since objects are passed by value in JavaScript, we can assert that after calling this function whatever we pass has a property checked
, which is true
:
function
check
<
T
>
(
obj
:
T
)
:
asserts
obj
is
T
&
{
checked
:
true
}
{
(
obj
as
T
&
{
checked
:
boolean
}).
checked
=
true
;
}
const
person
=
{
name
:
"Stefan"
,
age
:
27
,
};
check
(
person
);
And with that, we can modify a value’s type on the fly. It’s a little-known technique that can help you a lot.
4.7 Mapping Types with Type Maps
Solution
Store all subtypes in a type map, widen with index access, and use mapped types like Partial<T>
.
Discussion
Factory functions are great if you want to create variants of complex objects based on some basic information. One scenario that you might know from browser JavaScript is the creation of elements. The document.createElement
function accepts an element’s tag name, and you get an object where you can modify all necessary
properties.
You want to spice up this creation with a neat factory function you call createElement
. Not only does it take the element’s tag name, but it also makes a list of properties so you don’t need to set each property individually:
// Using create Element
// a is HTMLAnchorElement
const
a
=
createElement
(
"a"
,
{
href
:
"https://fettblog.eu"
});
// b is HTMLVideoElement
const
b
=
createElement
(
"video"
,
{
src
:
"/movie.mp4"
,
autoplay
:
true
});
// c is HTMLElement
const
c
=
createElement
(
"my-element"
);
You want to create good types for this, so you need to take care of two things:
-
Make sure you create only valid HTML elements.
-
Provide a type that accepts a subset of an HTML element’s properties.
Let’s take care of the valid HTML elements first. There are around 140 possible HTML elements, which is a lot. Each of those elements has a tag name, which can be represented as a string, and a respective prototype object in the DOM. Using the dom lib in your tsconfig.json, TypeScript has information on those prototype objects in the form of types. And you can figure out all 140 element names.
A good way to provide a mapping between element tag names and prototype objects is to use a type map. A type map is a technique where you take a type alias or interface and let keys point to the respective type variants. You can then get the correct type variant using index access of a string literal type:
type
AllElements
=
{
a
:
HTMLAnchorElement
;
div
:
HTMLDivElement
;
video
:
HTMLVideoElement
;
//... and ~140 more!
};
// HTMLAnchorElement
type
A
=
AllElements
[
"a"
];
It looks like accessing a JavaScript object’s properties using index access, but remember that we’re still working on a type level. This means index access can be broad:
type
AllElements
=
{
a
:
HTMLAnchorElement
;
div
:
HTMLDivElement
;
video
:
HTMLVideoElement
;
//... and ~140 more!
};
// HTMLAnchorElement | HTMLDivELement
type
AandDiv
=
AllElements
[
"a"
|
"div"
];
Let’s use this map to type the createElement
function. We use a generic type parameter constrained to all keys of AllElements
, which allows us to pass only valid HTML elements:
function
createElement
<
T
extends
keyof
AllElements
>
(
tag
:
T
)
:
AllElements
[
T
]
{
return
document
.
createElement
(
tag
as
string
)
as
AllElements
[
T
];
}
// a is HTMLAnchorElement
const
a
=
createElement
(
"a"
);
Use generics here to pin a string literal to a literal type, which we can use to index the right HTML element variant from the type map. Also note that using document.createElement
requires two type assertions. One makes the set wider (T
to string
), and one makes the set narrower (HTMLElement
to AllElements[T]
). Both assertions indicate that we have to deal with an API outside our control, as established in Recipe 3.9. We will deal with the assertions later on.
Now we want to provide the option to pass extra properties for said HTML elements, to set an href
to an HTMLAnchorElement
, and so forth. All properties are already in the respective HTMLElement
variants, but they’re required, not optional.
We can make all properties optional with the built-in type Partial<T>
. It’s a mapped type that takes all properties of a certain type and adds a type modifier:
type
Partial
<
T
>
=
{
[
P
in
keyof
T
]
?:
T
[
P
]
};
We extend our function with an optional argument props
that is a Partial
of the indexed element from AllElements
. This way, we know that if we pass an "a"
, we can only set properties that are available in HTMLAnchorElement
:
function
createElement
<
T
extends
keyof
AllElements
>
(
tag
:
T
,
props
?:
Partial
<
AllElements
[
T
]
>
)
:
AllElements
[
T
]
{
const
elem
=
document
.
createElement
(
tag
as
string
)
as
AllElements
[
T
];
return
Object
.
assign
(
elem
,
props
);
}
const
a
=
createElement
(
"a"
,
{
href
:
"https://fettblog.eu"
});
const
x
=
createElement
(
"a"
,
{
src
:
"https://fettblog.eu"
});
// ^--
// Argument of type '{ src: string; }' is not assignable to parameter
// of type 'Partial<HTMLAnchorElement>'.
// Object literal may only specify known properties, and 'src' does not
// exist in type 'Partial<HTMLAnchorElement>'.(2345)
Fantastic! Now it’s up to you to figure out all 140 HTML elements. Or not. Somebody already did the work and put HTMLElementTagNameMap
into lib.dom.ts. So let’s use this instead:
function
createElement
<
T
extends
keyof
HTMLElementTagNameMap
>
(
tag
:
T
,
props
?:
Partial
<
HTMLElementTagNameMap
[
T
]
>
)
:
HTMLElementTagNameMap
[
T
]
{
const
elem
=
document
.
createElement
(
tag
);
return
Object
.
assign
(
elem
,
props
);
}
This is also the interface used by document.createElement
, so there is no friction between your factory function and the built-in one. No extra assertions necessary.
There is only one caveat. You are restricted to the 140 elements provided by HTMLElementTagNameMap
. What if you want to create SVG elements, or web components that can have fully customized element names? Your factory function suddenly is
too constrained.
To allow for more—as document.createElement
does—we would need to add all possible strings to the mix again. HTMLElementTagNameMap
is an interface. So we can use declaration merging to extend the interface with an indexed signature, where we map all remaining strings to HTMLUnknownElement
:
interface
HTMLElementTagNameMap
{
[
x
:
string
]
:
HTMLUnknownElement
;
};
function
createElement
<
T
extends
keyof
HTMLElementTagNameMap
>
(
tag
:
T
,
props
?:
Partial
<
HTMLElementTagNameMap
[
T
]
>
)
:
HTMLElementTagNameMap
[
T
]
{
const
elem
=
document
.
createElement
(
tag
);
return
Object
.
assign
(
elem
,
props
);
}
// a is HTMLAnchorElement
const
a
=
createElement
(
"a"
,
{
href
:
"https://fettblog.eu"
});
// b is HTMLUnknownElement
const
b
=
createElement
(
"my-element"
);
Now we have everything we want:
-
A great factory function to create typed HTML elements
-
The possibility to set element properties with just one configuration object
-
The flexibility to create more elements than defined
The last is great, but what if you only want to allow for web components? Web components have a convention; they need to have a dash in their tag name. We can model this using a mapped type on a string template literal type. You will learn all about string template literal types in Chapter 6.
For now, the only thing you need to know is that we create a set of strings where the pattern is any string followed by a dash followed by any string. This is enough to ensure we only pass correct element names.
Mapped types work only with type aliases, not interface declarations, so we need to define an AllElements
type again:
type
AllElements
=
HTMLElementTagNameMap
&
{
[
x
in
`
${
string
}
-
${
string
}
`
]
:
HTMLElement
;
};
function
createElement
<
T
extends
keyof
AllElements
>
(
tag
:
T
,
props
?:
Partial
<
AllElements
[
T
]
>
)
:
AllElements
[
T
]
{
const
elem
=
document
.
createElement
(
tag
as
string
)
as
AllElements
[
T
];
return
Object
.
assign
(
elem
,
props
);
}
const
a
=
createElement
(
"a"
,
{
href
:
"https://fettblog.eu"
});
// OK
const
b
=
createElement
(
"my-element"
);
// OK
const
c
=
createElement
(
"thisWillError"
);
// ^
// Argument of type '"thisWillError"' is not
// assignable to parameter of type '`${string}-${string}`
// | keyof HTMLElementTagNameMap'.(2345)
Fantastic. With the AllElements
type we also get type assertions back, which we don’t like that much. In that case, instead of asserting, we can also use a function overload, defining two declarations: one for our users, and one for us to implement the function. You can learn more about this function overload technique in Recipes 2.6 and 12.7:
function
createElement
<
T
extends
keyof
AllElements
>
(
tag
:
T
,
props
?:
Partial
<
AllElements
[
T
]
>
)
:
AllElements
[
T
];
function
createElement
(
tag
:
string
,
props
?:
Partial
<
HTMLElement
>
)
:
HTMLElement
{
const
elem
=
document
.
createElement
(
tag
);
return
Object
.
assign
(
elem
,
props
);
}
We are all set. We defined a type map with mapped types and index signatures, using generic type parameters to be very explicit about our intentions. A great combination of multiple tools in our TypeScript tool belt.
4.8 Using ThisType to Define this in Objects
Solution
Use the built-in generic ThisType<T>
to define the correct this
.
Discussion
Frameworks like VueJS rely a lot on factory functions, where you pass a comprehensive configuration object to define initial data, computed properties, and methods for each instance. You want to create a similar behavior for components of your app. The idea is to provide a configuration object with three properties:
- A
data
function -
The return value is the initial data for the instance. You should not have access to any other properties from the configuration object in this function.
- A
computed
property -
This is for computed properties, which are based on the initial data. Computed properties are declared using functions. They can access initial data just like normal properties.
- A
methods
property -
Methods can be called and can access computed properties as well as the initial data. When methods access computed properties, they access it like they would access normal properties: no need to call the function.
Looking at the configuration object in use, there are three different ways to interpret this
. In data
, this
doesn’t have any properties at all. In computed
, each function can access the return value of data
via this
just like it would be part of their object. In methods
, each method can access computed properties and data
via this
in the same way:
const
instance
=
create
({
data
()
{
return
{
firstName
:
"Stefan"
,
lastName
:
"Baumgartner"
,
};
},
computed
:
{
fullName
()
{
// has access to the return object of data
return
this
.
firstName
+
" "
+
this
.
lastName
;
},
},
methods
:
{
hi
()
{
// use computed properties just like normal properties
alert
(
this
.
fullName
.
toLowerCase
());
},
},
});
This behavior is special but not uncommon. And with a behavior like that, we definitely want to rely on good types.
Note
In this lesson we will focus only on the types, not on the actual implementation, as that would exceed this chapter’s scope.
Let’s create types for each property. We define a type Options
, which we are going to refine step by step. First is the data
function. data
can be user defined, so we want to specify data
using a generic type parameter. The data we are looking for is specified by the return type of the data
function:
type
Options
<
Data
>
=
{
data
(
this
:
{})
?:
Data
;
};
So once we specify an actual return value in the data
function, the Data
placeholder gets substituted with the real object’s type. Note that we also define this
to point to the empty object, which means that we don’t get access to any other property from the configuration object.
Next, we define computed
. computed
is an object of functions. We add another generic type parameter called Computed
and let the value of Computed
be typed through usage. Here, this
changes to all the properties of Data
. Since we can’t set this
like we do in the data
function, we can use the built-in helper type ThisType
and set it to the generic type parameter Data
:
type
Options
<
Data
,
Computed
>
=
{
data
(
this
:
{})
?:
Data
;
computed
?:
Computed
&
ThisType
<
Data
>
;
};
This allows us to access, for example, this.firstName
, like in the previous example. Last but not least, we want to specify methods
. methods
is again special, as you are getting access not only to Data
via this
but also to all methods and to all computed properties as properties.
Computed
holds all computed properties as functions. We would need their value, though—more specifically, their return value. If we access fullName
via property access, we expect it to be a string
.
For that, we create a helper type called MapFnToProp
. It takes a type that is an object of functions and maps it to the return values’ types. The built-in ReturnType
helper type is perfect for this scenario:
// An object of functions ...
type
FnObj
=
Record
<
string
,
()
=>
any
>
;
// ... to an object of return types
type
MapFnToProp
<
FunctionObj
extends
FnObj
>
=
{
[
K
in
keyof
FunctionObj
]
:
ReturnType
<
FunctionObj
[
K
]
>
;
};
We can use MapFnToProp
to set ThisType
for a newly added generic type parameter called Methods
. We also add Data
and Methods
to the mix. To pass the Computed
generic type parameter to MapFnToProp
, it needs to be constrained to FnObj
, the same constraint of the first parameter FunctionObj
in MapFnToProp
:
type
Options
<
Data
,
Computed
extends
FnObj
,
Methods
>
=
{
data
(
this
:
{})
?:
Data
;
computed
?:
Computed
&
ThisType
<
Data
>
;
methods
?:
Methods
&
ThisType
<
Data
&
MapFnToProp
<
Computed
>
&
Methods
>
;
};
And that’s the type! We take all generic type properties and add them to the create
factory function:
declare
function
create
<
Data
,
Computed
extends
FnObj
,
Methods
>
(
options
:
Options
<
Data
,
Computed
,
Methods
>
)
:
any
;
Through usage, all generic type parameters will be substituted. And the way Options
is typed, we get all the autocomplete necessary to ensure we don’t run into troubles, as seen in Figure 4-1.
This example shows wonderfully how TypeScript can be used to type elaborate APIs where a lot of object manipulation is happening underneath.1
4.9 Adding Const Context to Generic Type Parameters
Solution
Add a const
modifier in front of your generic type parameter to keep the passed
values in const context.
Discussion
Single-page application (SPA) frameworks tend to reimplement a lot of browser functionality in JavaScript. For example, features like the History API made it possible to override the regular navigation behavior, which SPA frameworks use to switch between pages without a real page reload, by swapping the content of the page and changing the URL in the browser.
Imagine working on a minimalistic SPA framework that uses a so-called router to navigate between pages. Pages are defined as components, and a ComponentConstructor
interface knows how to instantiate and render new elements on your website:
interface
ComponentConstructor
{
new
()
:
Component
;
}
interface
Component
{
render
()
:
HTMLElement
;
}
The router should take a list of components and associated paths, stored as string
. When creating a router through the router
function, it should return an object that lets you navigate
the desired path:
type
Route
=
{
path
:
string
;
component
:
ComponentConstructor
;
};
function
router
(
routes
:
Route
[])
{
return
{
navigate
(
path
:
string
)
{
// ...
},
};
}
How the actual navigation is implemented is of no concern to us right now; instead, we want to focus on the typings of the function interface.
The router works as intended; it takes an array of Route
objects and returns an object with a navigate
function, which allows us to trigger the navigation from one URL to the other and renders the new component:
const
rtr
=
router
([
{
path
:
"/"
,
component
:
Main
,
},
{
path
:
"/about"
,
component
:
About
,
},
])
rtr
.
navigate
(
"/faq"
);
What you immediately see is that the types are way too broad. If we allow navigating to every string
available, nothing keeps us from using bogus routes that lead nowhere. We would need to implement some sort of error handling for information that is already ready and available. So why not use it?
Our first idea would be to replace the concrete type with a generic type parameter. The way TypeScript deals with generic substitution is that if we have a literal type, TypeScript will subtype accordingly. Introducing T
for Route
and using T["path"]
instead of string
comes close to what we want to achieve:
function
router
<
T
extends
Route
>
(
routes
:
T
[])
{
return
{
navigate
(
path
:
T
[
"path"
])
{
// ...
},
};
}
In theory, this should work. If we remind ourselves what TypeScript does with literal, primitives types in that case, we would expect the value to be narrowed to the literal type:
function
getPath
<
T
extends
string
>
(
route
:
T
)
:
T
{
return
route
;
}
const
path
=
getPath
(
"/"
);
// "/"
You can read more on that in Recipe 4.3. One important detail is that path
in the previous example is in a const context, because the returned value is immutable.
The only problem is that we are working with objects and arrays, and TypeScript tends to widen types in objects and arrays to something more general to allow for the mutability of values. If we look at a similar example, but with a nested object, we see that TypeScript takes the broader type instead:
type
Routes
=
{
paths
:
string
[];
};
function
getPaths
<
T
extends
Routes
>
(
routes
:
T
)
:
T
[
"paths"
]
{
return
routes
.
paths
;
}
const
paths
=
getPaths
({
paths
:
[
"/"
,
"/about"
]
});
// string[]
For objects, the const context for paths
is only for the binding of the variable, not for its contents. This eventually leads to losing some of the information we need to correctly type navigate
.
A way to work around this limitation is to manually apply const context, which needs us to redefine the input parameter to be readonly
:
function
router
<
T
extends
Route
>
(
routes
:
readonly
T
[])
{
return
{
navigate
(
path
:
T
[
"path"
])
{
history
.
pushState
({},
""
,
path
);
},
};
}
const
rtr
=
router
([
{
path
:
"/"
,
component
:
Main
,
},
{
path
:
"/about"
,
component
:
About
,
},
]
as
const
);
rtr
.
navigate
(
"/about"
);
This works but also requires that we not forget a very important detail when coding. And actively remembering workarounds is always a recipe for disaster.
Thankfully, TypeScript allows us to request const context from generic type parameters. Instead of applying it to the value, we substitute the generic type parameter for a concrete value but in const context by adding the const
modifier to the generic type parameter:
function
router
<
const
T
extends
Route
>
(
routes
:
T
[])
{
return
{
navigate
(
path
:
T
[
"path"
])
{
// tbd
},
};
}
We can then use our router just as we are accustomed to and even get autocomplete for possible paths:
const
rtr
=
router
([
{
path
:
"/"
,
component
:
Main
,
},
{
path
:
"/about"
,
component
:
About
,
},
])
rtr
.
navigate
(
"/about"
);
Even better, we get proper errors when we pass in something bogus:
const
rtr
=
router
([
{
path
:
"/"
,
component
:
Main
,
},
{
path
:
"/about"
,
component
:
About
,
},
])
rtr
.
navigate
(
"/faq"
);
// ^
// Argument of type '"/faq"' is not assignable to
// parameter of type '"/" | "/about"'.(2345)
The beautiful thing: it’s all hidden in the function’s API. What we expect becomes clearer, the interface tells us the constraints, and we don’t have to do anything extra when using router
to ensure type safety.
1 Special thanks to the creators of Type Challenges for this beautiful example.
Get TypeScript Cookbook now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.