WEIRD TS TYPES ๐ธ: Using Contextual Typing and Deferred Inference to Plan an Alien Conquest
David
Posted on December 1, 2021
This post is accompanied by a Typescript playground found here
Typescript has a powerful inference system which automatically assigns types to variables and function parameters when they are not explicitly provided. The behavior of inference is thoroughly documented in the Typescript Handbook.
Inference is helpful, but there are some cases (like writing generic functions) where the order of inference needs to be controlled.
This was recently exemplified by an RFC in the state library NGXS. In short, one of the functions published by the library was improperly typed because the type of its first argument was intended to be inferred from its contextually expected return type.
Instead of explaining the RFC directly, however, I wanted to have some fun and explain it through ALIENS instead ๐ฝ.
Let's look at how contextually inferred, deferred types can help a state management alien invasion library...
Invasion Overview
Let's say our alien overlords want us to plan an invasion of Smallville, a rural town in the US.
Smallville is like many small towns, so we'll create types that describe it in general terms.
type Person = '๐ง';
type Cow = '๐ฎ';
// regular field or field with a crop circle ๐
type CornField = '๐ฝ๐ฝ๐ฝ' | '๐ฝโ๐ฝ';
type RuralTown = {
people: Array<Person>;
cows: Array<Cow>;
cornFields: Array<CornField>;
};
const smallville: RuralTown = {
people: ["๐ง", "๐ง"],
cows: ["๐ฎ", "๐ฎ", "๐ฎ"],
cornFields: ["๐ฝ๐ฝ๐ฝ", "๐ฝ๐ฝ๐ฝ"],
};
To conduct research, our alien scientists have created many research vessels equipped to serve specific missions. These spacecraft all study different ResearchTarget
s.
type ResearchTarget = {
[researchSubject: string]: Array<string>;
}
smallville
, being a RuralTown
, is a ResearchTarget
since it has the same shape.
Our spaceship, a ruralTownResearchShip
, can research and interfere with any town that is like a RuralTown
.
const smallTownResearchShip = new ResearchShip<RuralTown>();
It interacts with the town through expeditions. Each time an expedition is conducted, the town may be changed in some way. The changes to the town are evaluated using an ExpeditionChangeFn
.
type ExpeditionChangeFn<T extends ResearchTarget> = (town: T) => T;
class ResearchShip<T extends ResearchTarget> {
// Expeditions are executed and the resulting state of the town is returned
public conductExpeditions(...expeditions: Array<ExpeditionChangeFn<T>>): : { on: (town: T) => T } { /*...*/ }
}
Our alien leaders have created a standard pattern for interacting with research targets by creating functions that produce ExpeditionChangeFn
s. These generalized methods allow alien developers to apply the same expedition methodologies across their various spacecraft, drones, and VR technologies.
Our invasion plan will employ two of these methods using our spacecraft- interfere
and eradicate
.
interfere
-ing causes us to produce a new state of how the town looks after we interact. We can provide a partial update of which features of the town changed.
function interfere<T extends ResearchTarget>(afterInterference: Partial<T>): ExpeditionChangeFn<T> { /*...*/ }
For instance, if there were two corn fields previously in the town (['๐ฝ๐ฝ๐ฝ', '๐ฝ๐ฝ๐ฝ']
), and we plan to draw a crop circle in one of them, we would provide that change as interfere({ cornFields: ['๐ฝ๐ฝ๐ฝ', '๐ฝโ๐ฝ'] })
.
eradicate
-ion, meanwhile, removes all elements across all features of the town.
function eradicate <T>(): ExpeditionChangeFn<T> { /*...*/ }
Eradication requires no configuration, so we can create an eradication plan with no arguments using eradicate()
.
Invasion Blueprint
Our invasion will be conducted in four stages. First, we will do three rounds of expeditions, and then conclude by preparing the town for our settlement.
smallTownResearchShip
.conductExpeditions(
// 1๏ธโฃ, we'll make a crop circle in a corn field
// to see how the populace reacts ๐
interfere({ cornFields: ["๐ฝ๐ฝ๐ฝ", "๐ฝโ๐ฝ"] }),
// 2๏ธโฃ, we'll take all the cows for wildlife research ๐
interfere({ cows: [] }),
// 3๏ธโฃ, we'll abduct a human to analyze their weaknesses ๐
interfere({ people: ["๐ง"] }),
// Finally, we'll eradicate everything for our colonization! ๐
eradicate()
)
Just as we've finished writing our invasion plan, we notice a type error. It looks like interfere
is suffering from the same problem the NGXS team had with their state operators!
Fixing Our Invasion Plan's Inferred Types
Typescript outputs the following error:
smallTownResearchShip
.conductExpeditions(
//Argument of type 'ExpeditionChangeFn<{ cornFields: ("๐ฝ๐ฝ๐ฝ" | "๐ฝโ๐ฝ")[]; }>' is not assignable to parameter of type 'ExpeditionChangeFn<RuralTown>'.
// Type '{ cornFields: ("๐ฝ๐ฝ๐ฝ" | "๐ฝโ๐ฝ")[]; }' is not assignable to type 'RuralTown'. (2345)
interfere({ cornFields: ["๐ฝ๐ฝ๐ฝ", "๐ฝโ๐ฝ"] }),
interfere({ cows: [] }),
interfere({ people: ["๐ง"] }),
eradicate()
)
This error is telling us that the inferred shape of the research target does not match the expected shape of a RuralTown
.
The interfere
function is inferring T
as the shape of its first argument, { cornFields: ("๐ฝ๐ฝ๐ฝ" | "๐ฝโ๐ฝ")[] }
. This object does not match a RuralTown
because it is missing cows
and people
.
Let's look at the signature for interfere
again.
function interfere<T extends ResearchTarget>(afterInterference: Partial<T>): ExpeditionChangeFn<T> {
The research target T
will be inferred from the parameter afterInterference
. But that's not helpful for us, because we expect that interfere
will return a ExpeditionChangeFn
for the same research target shape as our spacecraft. And RuralTown
has more properties than just cornFields
.
So we have a strange requirement. We want to infer a generic type from context, and we want to infer based on the return type, not the first parameter.
This may sound like an impossible task, but Typescript has the ability to infer types based on context, known as Contextual Typing.
In our case, conductExpeditions
is typed to say that its expeditions
argument is an array of ExpeditionChangeFn
s which return T
.
conductExpeditions(...expeditions: Array<ExpeditionChangeFn<T>>)
Typescript knows that a function passed as an expedition
should return a function that returns T
. This is how Typescript correctly infers T
for the return type of eradicate
, actually! eradicate
's generic is inferable from the return type, which is contextually known to be T
from the conductExpeditions
signature.
In the case of the interfere
function, Typescript is preferring to infer from the value passed into its first argument instead of contextually for the return type.
There isn't a built-in way to tell Typescript to not infer a generic type from a parameter. But, we can create a utility type for this named NoInfer
.
type NoInfer<T> = T extends infer S ? S : never;
Conditional types allow us to infer
the types from generics. This is helpful to infer the type of the elements of an array, for example, like:
type ElementsOfArray<T> = T extends Array<infer E> ? E : never;
// becomes `1 | 2 | 3`
type OneTwoOrThree = ElementsOfArray<[1, 2, 3]>;
NoInfer
doesn't appear to do anything at first. Since it infers S
directly from T
, it looks like it just repeats whatever type is passed into it:
// becomes `string`
type StringAgain = NoInfer<string>;
But the inference of conditional types is deferred in certain scenarios. In this case, the condition depends on a type variable T
to determine S
, and the conditional type is deferred.
So for interfere
, that type variable is inferred from the expected return type of an element of expeditions
!
Excited by this new knowledge, we change the definition of interfere
to use NoInfer
.
// Wrap T with NoInfer ๐
function interfere<T extends ResearchTarget>(afterInterference: Partial<NoInfer<T>>): ExpeditionChangeFn<T> {
All our plans should now be successful, and strongly typed to boot ๐พ.
Let's make sure our invasion runs as expected:
console.log('Before expeditions:', smallville);
const smallvilleAfterExpeditions = smallTownResearchShip
.conductExpeditions(
interfere({ cornFields: ["๐ฝ๐ฝ๐ฝ", "๐ฝโ๐ฝ"] }),
interfere({ cows: [] }),
interfere({ people: ["๐ง"] }),
eradicate()
)
.on(smallville);
Looking at our console output, it seems our invasion went flawlessly!
[LOG]: "Before expeditions:", {
"people": [
"๐ง",
"๐ง"
],
"cows": [
"๐ฎ",
"๐ฎ",
"๐ฎ"
],
"cornFields": [
"๐ฝ๐ฝ๐ฝ",
"๐ฝ๐ฝ๐ฝ"
]
}
[LOG]: "After expedition 1:", {
"people": [
"๐ง",
"๐ง"
],
"cows": [
"๐ฎ",
"๐ฎ",
"๐ฎ"
],
"cornFields": [
"๐ฝ๐ฝ๐ฝ",
"๐ฝโ๐ฝ"
]
}
[LOG]: "After expedition 2:", {
"people": [
"๐ง",
"๐ง"
],
"cows": [],
"cornFields": [
"๐ฝ๐ฝ๐ฝ",
"๐ฝโ๐ฝ"
]
}
[LOG]: "After expedition 3:", {
"people": [
"๐ง"
],
"cows": [],
"cornFields": [
"๐ฝ๐ฝ๐ฝ",
"๐ฝโ๐ฝ"
]
}
[LOG]: "After expedition 4:", {
"people": [],
"cows": [],
"cornFields": []
}
I hope the lens of extraterrestrial conquest has been helpful for thinking about this weird edge of the Typescript inference system. Please examine the full example in the Typescript playground here.
Posted on December 1, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 2, 2024