Back Fri 30 Oct, 2020

Type | Treat: Challenge 2

Click here to see full details of the challenge.

The Challenge

As before, there are 2 parts to the challenge.

  1. Create type definitions using typeof
  2. Narrowing down types

1. Create type definitions using typeof

Given the object and function below

const pumpkin = {
color: "yellowish",
type: "gourd",
size: "Medium",
energy: "109 Kj per 100g",
flowered: false,
sizeCM: "50.8 x 40.6 x 30.2 cm",
};

const createExamplePumpkin = () => {
return pumpkin;
};

use the typeof operator to define the following types

type Pumpkin = ...
type PumpkinFromFunction = ...

My Solution

Link to my full solution

type Pumpkin = typeof pumpkin;
type PumpkinFromFunction = ReturnType<typeof createExamplePumpkin>;

You might be familiar with the typeof from your JavaScript usage. However, rather than returning only one of the predefined values as per the JavaScript spec, TypeScript will give back the type definition that matches the operand. In the case of typeof pumpkin, we get back the type definition that resembles the value of the pumpkin variable

type Pumpkin = {
color: string;
type: string;
size: string;
energy: string;
flowered: boolean;
sizeCM: string;
};

For PumpkinFromFunction, things are a little more involved. Firstly, typeof createExamplePumpkin returns a function type

type PumpkinFunction = typeof createPumpkinFunction;

// gives us
type PumpkinFunction = () => {
color: string;
type: string;
size: string;
energy: string;
flowered: boolean;
sizeCM: string;
};

What we actually want is the type of the return value of that function, not of the function itself. We can't invoke the function in a type declaration. You could try and bypass this by doing the following

const pumpkinFromFunction = createExamplePumpkin();
type PumpkinFromFunction = typeof pumpkinFromFunction;

but invoking a function just to get its return value for a type declaration is a bit heavy-handed and not advisable. If the function has side-effects, then doing this would likely result in some weird behaviour in your application.

Given that we the type of the return value of the function, but don't want to invoke it, we need to look elsewhere. Handily, TypeScript offers a utility type for just that, namely, ReturnType. We can supply the function type to it, in order to unwrap that functions return type

type PumpkinFromFunction = ReturnType<typeof createExamplePumpkin>;

2. Narrowing Down Types

Given ghosts of various types

type Vigo = { name: "Vigo Von Homburg Deutschendorf", born: "Moldova" humanIsh: true }
type Zuul = { name: "Zuul", demon: true sendBackToHell(): void }
type Vinz = { name: "Vinz Clortho", demon: true sendBackToHell(): void }
type Gizer = { name: "Vinz Clortho", god: true hijackStayPuffMan(): void }
type Slimer = { name: "Slimer", color: "Green-y see through", ectoplasmic: true }

type Ghosts = Vigo | Zuul | Vinz | Slimer

make the following function compile, without modifying it, by implementing areGods, areDemons, and areEctoPlasmic.

function investigateReport(ghosts: Ghosts[]) {
if (areGods(ghosts)) {
// Unsure if is the right thing to do
// but it could work
crossTheStreams();
return;
}

// Tricky but something I think we can
// handle on a case-by-case basis
if (areDemons(ghosts)) {
for (const demon of ghosts) {
demon.sendBackToHell();
}
}

// We've done this a lot now,
// shouldn't be too difficult
if (areEctoPlasmic(ghosts)) {
shockAndTrap(ghosts);
}
}

My Solution

This one stumped me initally (hence the absence of a link to the solution, for now).

To start with, I followed the same approach as in the previous challenge, of creating narrower sub-types of ghosts using Extract, as below

type God = Extract<Ghosts, { god: true }>;
type Demon = Extract<Ghosts, { demon: true }>;
type EctoPlasmic = Extract<Ghosts, { ectoplasmic: true }>;

From there, I knew I needed functions to fulfil the calls inside investigateReport, with something like

function areGods(ghosts: Ghosts[]) {
return ghosts.every((ghost) => "god" in ghost);
}

function areDemons(ghosts: Ghosts[]) {
return ghosts.every((ghost) => "demon" in ghost);
}

function areEctoPlasmic(ghosts: Ghosts[]) {
return ghosts.every((ghost) => "ectoplasmic" in ghost);
}

These functions need to return true or false, based on whether the supplied ghosts match the target type (the functions above do this). But that alone is not enough, as there are still compiler errors (as you can see here).

This is where I got stuck. After various amounts of poking around, I caved and took a peek at the solution, and then also the relevant bit of the docs. Turns out I was pretty close to a working solution. Yay for me!

Here is the full solution

function areGods(ghosts: Ghosts[]): ghosts is God[] {
return ghosts.every((ghost) => "god" in ghost);
}

function areDemons(ghosts: Ghosts[]): ghosts is Demon[] {
return ghosts.every((ghost) => "demon" in ghost);
}

function areEctoPlasmic(ghosts: Ghosts[]): ghosts is EctoPlasmic[] {
return ghosts.every((ghost) => "ectoplasmic" in ghost);
}

What I was missing was using the is keyword. This is called a type predicate. The opening paragraph in this very helpful blogpost explains what they are for

Type predicates in TypeScript help you narrow down your types based on conditionals. They’re similar to type guards, but work on functions. They way the work is, if a function returns true, change the type of the paramter to something more useful.

So by specifying ghosts is Demon[], for example, we're telling TypeScript that whenever areDemons(ghosts) evaluates to true, then ghosts can be treated as a Demon[]. This means that the code inside the if (areDemons(ghosts)) block is always operating on a Demon[], which satisfies the compiler, and removes all errors. Neat.

Wrapping Up

TIL about type predicates and the is keyword. Useful for changing type definitions when some condition inside a function is met.

As usual, any questions or pointers, reach me on Twitter @jazlalli1. 👋