DEV Community

Cover image for WEIRD TS TYPES ๐Ÿ›ธ: Using Contextual Typing and Deferred Inference to Plan an Alien Conquest
David
David

Posted on

WEIRD TS TYPES ๐Ÿ›ธ: Using Contextual Typing and Deferred Inference to Plan an Alien Conquest

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: ["๐ŸŒฝ๐ŸŒฝ๐ŸŒฝ", "๐ŸŒฝ๐ŸŒฝ๐ŸŒฝ"],
};
Enter fullscreen mode Exit fullscreen mode

To conduct research, our alien scientists have created many research vessels equipped to serve specific missions. These spacecraft all study different ResearchTargets.

type ResearchTarget = {
    [researchSubject: string]: Array<string>;
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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 } { /*...*/ }
}
Enter fullscreen mode Exit fullscreen mode

Our alien leaders have created a standard pattern for interacting with research targets by creating functions that produce ExpeditionChangeFns. 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> { /*...*/ }
Enter fullscreen mode Exit fullscreen mode

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> { /*...*/ }
Enter fullscreen mode Exit fullscreen mode

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()
  )
Enter fullscreen mode Exit fullscreen mode

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()
  )
Enter fullscreen mode Exit fullscreen mode

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> {
Enter fullscreen mode Exit fullscreen mode

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 ExpeditionChangeFns which return T.

conductExpeditions(...expeditions: Array<ExpeditionChangeFn<T>>)
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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]>;
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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> {
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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": []
} 
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (3)

Collapse
 
davidshortman profile image
David

NoInfer is coming natively to Typescript! devblogs.microsoft.com/typescript/...

Collapse
 
djgrant profile image
Daniel Grant

This is such a creative example of a very obscure problem! Thanks!

Collapse
 
jmpsequeira profile image
Joรฃo Sequeira

Thank you so much for this article.