Wrangling Wild CMS Data with TypeScript
Here at RBI, we’ve prioritized building a software platform that enables us to rapidly deliver high value. Sometimes this means literally delivering valuable food to your waiting stomachs, but for software engineers, it also means delivering a highly customizable web platform to our internal marketing teams, which allows us to drive dynamic customer experiences with minimal engineering effort. To that end, one of the products that we incorporate in our tech stack is a CMS.
Classic Three-letter Acronym
A CMS, or content management system, can be used in a variety of ways. At RBI we primarily use it to allow our digital marketing and product teams to control the content we deliver on our websites in real-time. This can be both a boon and a curse. On the one hand, it enables us to curate and create new experiences without needing to wait for deployment, but on the other, it means our code needs to be prepared to deal with some pretty unpredictable data. Oftentimes, this data is simply used to control the display of content to the user, but we also use it to drive some pretty core functionality like our digital menu, and this is where things can become problematic.
CMS data is edited by humans and published in real-time, which means null or missing values in data are an unfortunate reality, and if not dealt with proactively, can lead to production failures. To that end, we rely on TypeScript and GraphQL as two technologies that increase our confidence in the features we ship. These tools can work hand-in-hand to create an excellent developer experience and help ensure you are writing sound and correct code.
They Can, But Do They?
That being said, sometimes the harsh reality of typed code can be more of a thorn in one’s side than a brilliant shining beacon, guiding you to feature completion. Take the following interface, derived automatically from a GraphQL operation, as an example:
If you see this and want to immediately return to writing un-typed JavaScript, fear not! There are some handy utility types and helper functions we can create to not only derive a much more sensible data structure for our interface, but also guarantee that at run-time we do not encounter unexpected type errors.
If You’ve Got It, Give It To Me
The first, and easiest to use, tool in our toolbox is optional chaining. This has been added to TypeScript somewhat recently (in version 3.7
).
Here’s an example of using optional chaining to derive some data from the result of IAllOffersQuery
for display:
Here, we are able to get all of the name.enUS
values from offers that are defined in our array, and otherwise return the default 'A nameless offer!'
. The ??
token is another recently added TypeScript feature — nullish coalescing. This means the default value will only be used if the value on the left-hand side of the operator is null | undefined
— protecting us from overriding an intentionally empty string name.
Optional chaining can make working with these nested data-structures much less of a hassle, but other times we want to be more strict about the data we allow — defaulting to a value may not be a reasonable approach. In this case, it makes sense to turn to another powerful TypeScript feature: refinement functions, or type guards.
Are You on the List?
Sometimes we use CMS data to drive more business-critical functionality, and in these cases, we need to be much more attentive to the structure of the data. For example, if we are using CMS data to compute a cart item for the user to order, we do not want to present the user with options that will fail when we attempt to send them to our ordering API, due to misconfigured or invalid CMS data.
In cases like these, optional chaining may not be sufficient. One way we can deal with unruly data early on in our code and avoid repeating data validation in every file, we can implement some helper functions and filter to only valid results when we retrieve our data:
This function isValidOffer
is known as a refinement. We pass in IOffer | null
, and if the result of the evaluation is true, TypeScript knows our offer
is actually an IValidOffer
. This is pretty powerful:
Refinements can be leveraged on their own, but they can also be combined with Array.prototype.filter
’s little known TypeScript overload:
This overload means when filter
is called with a refinement, TypeScript knows that the returned array is actually all U
, and no members of type T
are present.
When we call our query, we can easily filter our offers list down to only “valid” offers, allowing the rest of our code to safely consume the now null-less interface:
Now that we have a couple of ways to deal with this data, let’s also look at how we can improve the developer experience of defining these derived, “valid” interfaces so that we don’t need to re-create duplicate types for every GraphQL interface our application uses.
Mapping It Out
TypeScript is a typed language with generics. Generics enable us to define Kinds, or types abstract to their underlying data-type. An example of a Kind is the Array type:
const stringArray: Array<string> = [“foo”, “bar”];
The Array
type is itself a type, however it is not a type that exists in data — an Array must always have some underlying, concrete type that represents the data inside of the array (the elements). This does not mean the Array type only supports homogeneous lists — a heterogeneous list can be represented using a union type: Array<string | number>
. Kinds allow us to define types who accept an argument, and return a concrete type. Similar to a function, but for types!
Armed with this knowledge, and the interface that continues to be a thorn in our side (but less so now!), let’s see if we can implement a helper type to automatically derive the “valid” version of a piece of CMS data.
These types have created a nice shortcut for us. Let’s review what they do exactly:
type ExcludeNull<T> = Exclude<T, null>;
Exclude
is a built-in utility type in TypeScript; however, it can also be defined in versions where it is excluded 😀.
type Exclude<T, U> = T extends U ? never : T;
This type allows us to remove members from a union type, such as ExcludeNull
, which turns the union string | null
into string
declare const s: ExcludeNull<string | null>;
// same as
declare const s: string;
The next type is called a mapped type. It allows us to accept another type and map over members, creating a new type from the properties of the old one. In this example, we have created a type that receives another object
type (an interface, or array — in JavaScript arrays are objects too), and returns a type with the same properties, with all null
values removed. The important thing to note in the type below is the K in keyof T
syntax — this is what gives us the “mapping” behavior. keyof T
is equivalent to the union of all property names in T
, so if T
is IOffer
, keyof T
would be '_id' | '_type' | 'name' | 'description' | 'price'
, and K
in this example would be each of those union members.
type ExcludeNullFromProperties<T extends object> = {
[K in keyof T]: T[K] extends object
? ExcludeNullFromProperties<T[K]>
: ExcludeNull<T[K]>;
};
Another thing to call out here is the conditional typing — T[K] extends object ? ExcludeNullFromProperties<T[K]> : ExcludeNull<T[K]>
. Normally, types are static, so dynamic logic like control flow is not possible. However, this is not a type, it is a Kind, and it can only be resolved to a concrete type by being given another (static) type. This means TypeScript will be able to statically analyze the resulting type!
Let’s bring this all together and create the above IValidOffer
using our new mapped type.
type IValidOffer = ExcludeNullFromProperties<IOffer>;
That’s it! This type is equivalent to the IValidOffer
we defined above. The same type can be provided an array:
type IValidOfferList = ExcludeNullFromProperties<Array<IOffer | null>>;
And the same result is achieved — an array with no null
members allowed, and all members of type IValidOffer
.
Where Do We Go From Here?
These are just a couple of ways TypeScript can help deal with CMS data. As your CMS schema evolves, marketing and product teams will surely find new and exciting ways to potentially break your application. Now, next time you are faced with a deep tree-shaped object filled with Maybe
s, you can breathe a sigh of relief and calmly pass it to your mapped type, filter your list using your refinement, and know that your data has some integrity. Howdy partner.