Back Sat 31 Oct, 2020

Type | Treat: Challenge 4

Click here to see full details of the challenge.

The Challenge

  1. Stop objects from being mutated
  2. Refactoring type annotations of existing code

1. Stop objects from being mutated

Given the following data

type Rectory = {
rooms: Room[];
noises: any[];
};

type Room = {
name: string;
doors: number;
windows: number;
ghost?: any;
};

const epworthRectory = {
rooms: [
{ name: "Entrance Hall", doors: 5, windows: 2 },
{ name: "The Kitchen", doors: 2, windows: 3 },
...
],
};

Ensure that the function below cannot mutate the argument data (the last line in the function)

function haunt(rectory: Rectory) {
const room = rectory.rooms[Math.floor(Math.random() * rectory.rooms.length)];
room.ghost = { name: "Old Jeffrey" };
}

My Solution

Link to my full solution

I immediately went about adding the readonly modifier to every property in each of the types

type Rectory = {
readonly rooms: Room[];
readonly noises: any[];
};

type Room = {
readonly name: string;
readonly doors: number;
readonly windows: number;
readonly ghost?: any;
};

This works, and correctly results in the compiler complaining about the last line of the haunt function. Adding the same modifier to each property in turn doesn't feel ideal, as we'd have to remember to do the same for every new property that might be added in the future. To find a better approach, a quick search of the docs revealed the Readonly utility type. That means we can express the same solution as

type Rectory = Readonly<{
rooms: Room[];
noises: any[];
}>;

type Room = Readonly<{
name: string;
doors: number;
windows: number;
ghost?: any;
}>;

Much better! 🙂

2. Refactoring type annotations of existing code

Refactor the following code to improve the typing

declare function decideWinner(
breed: string,
costume: string
): { name: string; video: string };

window.decideWinner = someoneElseDecides;

const breeds = ["Hound", "Corgi", "Pomeranian"] as const;
const costumes = ["Pumpkin", "Hot Dog", "Bumble Bee"] as const;

function tallyPopularWinners(
_breeds: typeof breeds,
_costumes: typeof costumes
) {
const winners: Record<string, any> = {};

for (const breed of _breeds) {
for (const costume of _costumes) {
const id = `${breed}-${costume}`.toLowerCase();
winners[id] = decideWinner(breed, costume);
}
}

return winners;
}

const winners = tallyPopularWinners(breeds, costumes);

// note that the properties on winners object are lowercased!!
// should pass
winners["hound-pumpkin"].name;

// should fail
winners["pumpkin-pumpkin"].video;

My Solution

Link to my full solution

I love a bit of refactoring, so this one was fun. I started off by lifting some type annotations out to standalone types, so that we can better see what we're dealing with. Firstly, for the decideWinner function

type Winner = { name: string; video: string };

declare function decideWinner(breed: string, costume: string): Winner;

Then the arguments for the tallyPopularWinners function

type Breeds = typeof breeds;
type Costumes = typeof costumes;

function tallyPopularWinners(_breeds: Breeds, _costumes: Costumes) {
/* ... */
}

Next up is the winners variable inside tallyPopularWinners. It's a Record which accepts any string as a key, and a value of type any. This can be tightened up immediately by noticing that the values will be Winner objects, so the variable declaration becomes

const winners: Record<string, Winner> = {};

Finally, we want to tackle those string keys. We can see from the function that the key is only ever going to be a breed concatenated with a costume, separated by a hyphen. That being the case, we ought to define a type that reflects this. The challenge handily drops a hint to use a new feature from the 4.1 beta, which I have not yet used myself, but which I know to be Template Literal Types. Let's give them a whirl.

We have the types that represent the breeds and costumes arrays. Let's grab the items out of them using indexed access

// we already have
type Breeds = typeof breeds;
type Costumes = typeof costumes;

// using indexed access we can create unions of all the members
type AllBreeds = Breeds[number]; // -> "Hound" | "Corgi" | "Pomeranian"
type AllCostumes = Costumes[number]; // -> "Pumpkin" | "Hot Dog" | "Bumble Bee"

Now we want to combine these 2 unions to create the hyphenated pairs which are used as the keys in the winners object. Enter template literals

type BreedCostumes = `${lowercase AllBreeds}-${lowercase AllCostumes}`;

As I said, I've not used them before, but looking at what you can define with just a single line like this, they're pretty powerful. The definition above spits out every combination of the 2 supplied types. Being able to throw in lowercase was pretty neat too (though not before having tried to call .toLowerCase() in a few places 🙄).

With all possible valid keys now strictly typed, we can update the winners variable to

const winners: Record<BreedCostumes, Winner> = {};

That's all the types defined, but we still have a couple of compiler errors inside the tallyPopularWinners function. The lines being complained about are 2 and 7 below

function tallyPopularWinners(_breeds: Breeds, _costumes: Costumes) {
const winners: Record<BreedCostumes, Winner> = {}; // <- error on winners here

for (const breed of _breeds) {
for (const costume of _costumes) {
const id = `${breed}-${costume}`.toLowerCase();
winners[id] = decideWinner(breed, costume); // <- error here when using id
}
}

return winners;
}

The errors are similar to each other, with both saying that the value being used doesn't match the expected type. For line:2, empty object ({}) does not match the Record<BreedCostumes, Winner> type, and for line:7 the id is a string, whereas it ought to be a BreedCostumes.

Given that I believe the types to be good, we can go about addressing these with some type assertions. To do so, I first lifted the Record into a standalone type, and then added assertions to each of the problem lines, to end up with

type Winners = Record<BreedCostumes, Winner>;

function tallyPopularWinners(_breeds: Breeds, _costumes: Costumes) {
const winners: Winners = {} as Winners;

for (const breed of _breeds) {
for (const costume of _costumes) {
const id = `${breed}-${costume}`.toLowerCase() as BreedCostumes;
winners[id] = decideWinner(breed, costume);
}
}

return winners;
}

Et voilà! That's everything from my solution, go check out the link at the top of this section to see all the code and the compiler output.

Wrapping Up

This was my first time using Template Literal Types! Super powerful, and I can really see how you could add strict checks on all your strings to help you catch typos, and invalid or unexpected values ("[object Object]" anyone?).

As usual, any questions or feedback, find me on Twitter @jazlalli1. Cheers 👋