Back Fri 30 Oct, 2020

Type | Treat: Challenge 3

Click here to see full details of the challenge.

The Challenge

  1. Reduce duplication using unions and intersections
  2. Refactor type to not be hardcoded

1. Reduce duplication using unions and intersections

Given the following collections, create type definitions for them.

const treats = [
{ location: "House 1", result: "treat", treat: { candy: "Lion Bar" } },
{ location: "House 3", result: "treat", treat: { candy: "Mars Bar" } },
{
location: "House 4",
result: "treat",
treat: { baked: "Cookies", candy: "Reese's" },
},
];

const tricks = [
{ location: "House 2", result: "trick", trick: "Magic" },
{ location: "House 7", result: "trick", trick: "Surprised" },
];

const noShow = [{ location: "House 6", result: "no-show" }];

My Solution

Allowing for duplicate definitions of common properties, we can arrive at

type Treat = {
location: string;
result: "treat";
treat: {
candy: string;
baked?: string;
};
};

type Trick = {
location: string;
result: "trick";
trick: string;
};

type NoShow = {
location: string;
result: "no-show";
};

We can then add these types to the variable declarations

const treats: Treat[] = [...]
const tricks: Trick[] = [...]
const noShow: NoShow[] = [...]

and this makes the compiler happy.

But the challenge specifies reducing duplication of location and result, and it makes mention of union and intersection types. Let's see what we can do.

Let's start by composing location and result from standalone types, and then update the others to use it

// a union
type Result = "treat" | "trick" | "no-show";

type LocationResult = {
location: string;
result: Result;
};

// an intersection type (uses &)
type Treat = LocationResult & {
treat: {
candy: string;
baked?: string;
};
};

// an intersection type (uses &)
type Trick = LocationResult & {
trick: string;
};

type NoShow = LocationResult;

This has reduced duplication, but it's actually made the typing less representitive of what we're trying to model. The definitions above would allow us to define data that looks like

const treats: Treat[] = [
{ location: "House 1", result: "trick", treat: { candy: "Lion Bar" } },
{ location: "House 3", result: "trick", treat: { candy: "Mars Bar" } },
];

Notice that these are treats, but have result: "trick". The compiler doesn't complain about this, as the LocationResult type allows result to be any of the 3 types in the Result union. You can see this play out at this link.

This isn't great. Let's try tighten this up. The first thing is addressing the fact that result can be any of the 3 defined values. What we want is for result to only be the specific value that is allowed for the respective Treat, Trick, or NoShow types.

If we replace the Result union with 3 distinct types, we can arrive at

type HouseLocation = { location: string };
type TreatLocation = HouseLocation & { result: "treat" };
type TrickLocation = HouseLocation & { result: "trick" };
type NoShowLocation = HouseLocation & { result: "no-show" };

type Treat = TreatLocation & {
treat: {
candy: string;
baked?: string;
};
};

type Trick = TrickLocation & {
trick: string;
};

type NoShow = NoShowLocation;

Using these type definitions, the compiler does now warn us of the earlier mistake, as you can see in my final solution.

2. Refactor type to not be hardcoded

Update a hardcoded type so that it is derived from other data, and will therefore not run the danger of going out of sync. Given

// this list of places
const trunkOrTreatSpots = [
"The Park",
"House #1",
"House #2",
"Corner Shop",
"Place of Worship",
] as const;

// update this type so that it always remains in-sync with
// the places above, without ever needing to be modified
type TrunkOrTreatResults = {
"The Park": {
done: boolean;
who: string;
loot: Record<string, any>;
};
"House #1": {
done: boolean;
who: string;
loot: Record<string, any>;
};
"House #2": {
done: boolean;
who: string;
loot: Record<string, any>;
};
};

My Solution

Link to my full solution.

Given that we have a list of places, we will be able to map over them to create the desired type. The first step is to obtain a type from the trunkOrTreatSpots array.

type Spots = typeof trunkOrTreatSpots;

Now that we have the type Spots, we can map over it to create an entry for each of the places. This looks like

type TrunkOrTreatResults = {
[S in Spots[number]]: {
done: boolean;
who: string;
loot: Record<string, any>;
};
};

Two things worth noting here. Firstly, we use the numeric index access on Spots to get each member, which we encountered in Challenge 1.

Second, given we can access each member type this way, we can use the S in Spots[number] syntax to map over them and create a type for each (the right-hand-side of the key-value). One way to think about the behaviour above, which may help to explain it, is as being somewhat analagous to the following JavaScript

const result = {};
for(const item of ["The Park", "House #1", "House #2"]) {
result[item] = /* do something here */
}

Wrapping Up

I'm not entirely sure my solution to the first part of this challenge is what was being asked for. Whilst I did initially solve using a union, I subsequently rolled back on it to arrive at tighter type definitions, albeit with some duplication. A worthwhile trade-off if you ask me.

For the second one, I've used mapped types a fair bit before, so it was nice to know which way to go straight off the bat.

Hopefully this proves helpful for you in some way. As usual, give me a shout on Twitter @jazlalli1 if you have any questions or feedback. Cheers 👋