Conditional data types with TypeScript
When dealing with data that’s returned from an API you don’t always have control over the exact structure which can make it a bit of a challenge to add types to that data with TypeScript to improve the developer experience. I have recently run into this issue when hooking up the Juju Dashboard to a new API endpoint that sends deltas every time there is a change in the users environment.
The deltas are sent in the format [DeltaEntity, DeltaType, Delta]
, or more specifically [["application", "change", {...}], ["machine", "remove", {...}], ...]
. The structure of the Delta
portion of this tuple is determined by the combination of the DeltaEntity
and DeltaType
so when dealing with this data you first need to run a conditional over those type values to determine what to do with the Delta
. In order to type these we can use a Type Predicate or a Discriminated Union. Ultimately I landed on using a discriminated union but I’ll run through both approaches here.
Using a Type Predicate
Define the type for the whole delta tuple.
type AllWatcherDelta = [DeltaEntity, DeltaType, Delta];
Define the possible values for the entity and type using a union. Yes, even the type predicate approach uses unions as a component of the typing approach. You also want to define the type for each delta type.
type DeltaEntity = "application" | "charm" | "unit";
type DeltaType = "change" | "remove";
interface UnitChangeDelta {
name: string;
ports: string[];
}
Now we need to create a function that will be used as the predicate in a conditional to determine what the data type is while working on the delta values. These functions will take, at a minimum, the data that you want to define the type for and any additional data you need to determine that type. It must then return a boolean value, if true
then change
in this function will be of type UnitChangeDelta
, otherwise not.
function isUnitChangeDelta(
delta: AllWatcherDelta,
change: Delta
): change is UnitChangeDelta {
return delta[0] === "unit" && delta[1] === "change";
}
Using this approach you would then have a string of conditionals checking for the appropriate type and within, would be of the expected type. In this example delta[2]
, the actual delta in our tuple will now be properly typed within the conditional as a UnitChangeDelta
.
if (isUnitChangeDelta(delta, delta[2])) {
delta[2].ports.forEach(console.log);
}
Using Discriminated Unions
Define all possible tuples of DeltaEntity
, DeltaType
, and Delta
as a discriminated union.
type AllWatcherDelta =
| ["unit", "change", UnitChangeDelta]
| ["machine", "change", MachineChangeDelta]
| ["application", "remove", ApplicationRemoveDelta];
Now when using the delta you need to check the values of each predicate manually and TypeScript will correctly type the data. Here I’ve used a switch
statement but you could use any type of conditional.
switch (delta[0]) {
case "unit":
switch (delta[1]) {
case "change":
delta[2].ports.forEach(console.log);
break;
}
break;
}
Summary
Using only a Discriminated Union is great when you have a known set of data that you need to type. As the size or complexity of that data set increases you might want to evaluate using a Type Predicate as you can execute whatever code you need to determine the type including introspepction into the data itself if you needed to use a canary in the object as the flag.
If either of these approaches don’t quite get you there you can also look into adding Generics into the mix but that’ll have to wait for another time.
No matter the approach you take, having properly typed dynamic data dramatically reduces a certain set of errors and increases the productivity of the developer consuming that data so I feel it’s worth the investment.
Thanks for reading!