Click here to see full details of the challenge.
The Challenge
- Reduce duplication using unions and intersections
- 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
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 👋