Generating Union Types from a Bijective Map Using Typescript's  Const Assertion

Generating Union Types from a Bijective Map Using Typescript's Const Assertion

In this post, I explore a handful of TypeScript features to create Union types from a Bijective Map and use them to restrict what value (not just type) the keys can take when sending data object to Google Analytics.

As a recap, two sets are described as bijective if every value of one set maps to a value of the other set and vice versa. Typically, the set of keys of a plain object and the set of values of an object are not a bijection because there is no way to enforce that two keys don't have the same string value. (eg: {key1: 612, key2: 612}). However, we are using TypeScript and can hack our way to a bijective map.

Next, we'll use the keys and values of the bijection to create union types we can use to do some neat things.

Background

For this post, I'll be converting some of my Google Analytics helper code to TypeScript.

I am using ReactGA to make working with Google Analytics easier in React apps. The interface I use looks like:

ReactGA.ga('send', 'event', data); // Record Event 
ReactGA.ga('send', 'pageView', data); // Record Page View

GA supports the following base data payload for event dimensions:

type GAEventFields = {
  eventCategory?: string;
  eventAction?: string;
  eventLabel?: string;
  eventValue?: string | number;
};

Custom Dimensions are a nice feature of Google Analytics that allow you to pass arbitrary string values that get recorded along with the other event data. This allows you to slice your events in Google Data Studio in really powerful ways. Via the GA admin tools, each custom dimension is given a permenant number (1 index) and a human readable name. For me, dimension1 is Ad Client. To record a value for your custom dimension, you add the dimension property (eg. dimension1) to your data payload and assign it the value you want recorded. (eg. WetPaint).

For example, a payload might look like:

const eventPayload = {
    eventCategory: 'advert',
    eventAction: 'click',
    eventLabel: 'Blue Ad',
    dimension1: "WetPaint" // Ad Client custom dimension
};

As you add more dimensions, it gets hard to keep track of what dimensions have what meaning when you are simply working with strings like dimension1, dimension2, etc.

To make this easier, I made a mapping of human readable slug labels and their associated dimension id value. In Javascript I defined:

const dimensionMap = {
  adClient: 'dimension1', // eg. Wetpaint
  adCampaign: 'dimension2', // eg. 6 month
  adInstance: 'dimension3', // eg. January
  adSpot: 'dimension4', // eg. Homepage Marquee
};

Then in high level code, I send payloads like:

const niceEventPayload = {
    eventCategory: 'advert',
    eventAction: 'click',
    eventLabel: 'Blue Ad',
    adClient: "WetPaint" // Note: Not "dimension1"
};

Finally some lower level code uses the above dimensionMap to shape niceEventPayload into eventPayload that is suitable to pass directly to ReactGA.

This works really well and I'm less likely to pass the wrong value to a dimension due to confusion over the dimension meaning.

However, it could be better. Since it is not strongly typed, you can technically pass any values around by mistake. For example, I'd love for the compiler to catch typos (eg. 'addClient') at compile time. It's also a good opportunity to learn some new features of TypeScript.

Goals

  • Define a single strongly typed mapping that can be used for dictionary style lookups.
  • Generate union types for the keys and values of the mapping to prevent typos, etc - ideally without having to break DRY principals.
  • Create a payload object type that ensures that keys of the object are limited to values in our map

Solution

After a lot of trial and error using TypeScript features, I came up with the below type definitions.

// TypeScript Helpers
type ValueOf<T> = T[keyof T];

export const DimensionMap = {
  adClient: 'dimension1', // eg. Wetpaint
  adCampaign: 'dimension2', // eg. 6 month
  adInstance: 'dimension3', // Book Ad
  adSpot: 'dimension4', // id of adspot - page, column, etc
} as const;

// Union Types
export type DimensionId = ValueOf<typeof DimensionMap>; // eg. "adClient" | "adCampaign"
export type DimensionLabel = keyof typeof DimensionMap; // eg. "dimension1" | "dimension2"

// Value Payload Types
export type DimensionLabelVals = Partial<Record<DimensionLabel, string>>;
export type DimensionIdVals = Partial<Record<DimensionId, string>>;

// Standard Google Analytics Event Fields
export type GAEventFields = {
  eventCategory?: string;
  eventAction?: string;
  eventLabel?: string;
  eventValue?: string;
};

// Combined Value Payload suitable to send to ReactGA
export type GAEventPayload = GAEventFields & DimensionIdVals;

// Combine Value Payload that contains human readable labels
export type GoogleAnalyticsValsPayload = GAEventFields & DimensionLabelVals;

The Map

DimensionMap is a plain object, but with the as const at the end, TypeScript uses a feature called const assertions to prevent widening. This means that the keys and values are also assumed to be constant and thus the type of a property is the literal value (eg. adClient) rather than "the widened" type of string. Without the const literal, the type could be any string. However, we've limited the object's keys and values to those defined thus giving us a very explicit set of strings values can be.

Additionally, since the object was a bijection to begin with and cannot be modified, it continues to be one. Const Assertions are great!

DimensionId and DimensionLabel Union Types.

These two types are union types meaning that they allow a value to be of any type explicitly defined. For example, union types could be used to allow a value to be either a string or number but not a boolean. This would be the union type string | number.

A union type can be literals as well. I could have for example, defined:

type DimensionLabel = 'adClient' | 'adCampaign' | `adInstance` | `adSpot`;
type DimensionId = 'dimension1' | 'dimension2' |'dimension3' |'dimension4'

// Testing
let idGood: DimensionId = 'dimension1'
let idBad: DimensionId = 'dimensionZ'; // Fails since not dimension1 nor dimension2 etc
let labelGood: DimensionLabel = 'adClient'; 
let labelBad: DimensionLabel = 'addClient'; // Fails since not adClient nor adCampaign etc

Note that we can't assign the vars above to any strings other than those explicitly defined in the union type. This is a super powerful TypeScript feature I want to leverage. However, explicitly repeating the types feels very error prone and not very DRY. As such, TypeScript has a couple neat features I first used while figuring this out.

The first is keysof typeof which gives you a list of the keys for a Type. This directly gives us a union type of the keys from DimensionMap.

Using a similar approach, we can make a custom utility type ValueOf to functionally do the same thing, but instead of returning the key, it returns the corresponding value.

Using these two approaches, we have built separate types that limit the values a type can take to the keys and values of our map respectively.

The Payload Types

Earlier in the post, we defined eventPayload and niceEventPayload to objects with values being the dimension data we want to send to GA and keys of dimension id string and the human readable dimension labels respectively. However, the keys of these objects could be any string and typos could easily happen. Leveraging the DimensionId and DimensionLabel types, we can now strongly typed these data payloads.

Surprisingly, this is easily achieved using two builtin Utility Types Partial and Record. You can read more about them in the official documentation. However, high level Partial allows properties on a type to be optional. Record constructs a set of properties mapping properties of one type to another.

For my purposes, I wanted to build a payload type that would only allow keys to one of our union types thus restricting what the names of the keys could be. The right hand side could take any string value. Since I will have more dimensions than apply to any given event type, I wanted to make everything optional, hence the Partial.

The end result:

export type DimensionLabelVals = Partial<Record<DimensionLabel, string>>;
export type DimensionIdVals = Partial<Record<DimensionId, string>>;

// Testing it out
const labelPayload: DimensionLabelVals = {
    adClient: "WetPaint",
    adCampaign: "Spring 2020",
    //addClient: "WetPaint" // Fails since key not in DimensionLabel union type
};

const idPayload: DimensionIdVals = {
    dimension1: "WetPaint",
    dimension2: "Spring 2020",
    //dimension99: "WetPaint" // Fails since key not in DimensionId union type
};

Now we have to payload types that won't allow types AND we didn't have to repeat ourselves defining union types explicitly. We're almost done!

A Note on Const Assertions

When defining DimensionMap we used a "const assertion" (as const) to tell TypeScript that the map can't have any new properties and that existing ones can't change. TypeScript uses this knowledge to prevent "type widening" discussed earlier.

This was critical in the previous steps when defining the DimensionId union type. Without the const assertion, the DimensionId type would "widen" to support any future string values and be equivalent to:

type DimensionId = string; // Basically an type alias

This would then make our DimensionIdVals type not overly useful by allowing any string value as a key rather than only values those used within our map. Using the const assertion, we prevent the widening and restrict the keys to very explicit values.

Mapping Between Dimension ID and Label Payloads

Thus far, we've only really established out types. Now let's use them to write a function to map the human readable payload to the one that Google Analytics expects.

function mapToCustomDimensions(data: DimensionLabelVals): DimensionIdVals { 
    const mapped: DimensionIdVals = {};

    let label: DimensionLabel;
    let id: DimensionId;

    for (label in data) { 
        id = DimensionMap[label]
        mapped[id] = data[label];
    } 

    return mapped;
}

High level, this takes in a data payload of type DimensionLabelVals (the one containing the human readable label keys), iterates over each key, grabs the corresponding dimension id from the DimensionMap and then sets a property on our return map DimensionIdVals with the dimension id as a key and reassigns the value. Since we put all the work in upfront for our types, the resulting payload is also strongly typed.

// Good: Keys are the readable labels
let goodMapped = mapToCustomDimensions({ adClient: 'Wetpaint', adCampaign: '12month' });

// Bad: addClient is a typo
let badMapped = mapToCustomDimensions({ addClient: 'Wetpaint', adCampaign: '12month' });

Note: Since the map is bijective, we could easily write a mapper to go the other direction as well. However, I thought it was redundant for this already lengthy post.

Combine Dimension and Base Google Analytic types

Finally, we want to build a set of payload types to represent the shape of the data sent to Google Analytics. Until now, we've only dealt with dimension data, but we also need the common GA data.

First we define a type to represent the common GA payload data:

export type GAEventFields = {
  eventCategory?: string;
  eventAction?: string;
  eventLabel?: string;
  eventValue?: string;
};

Then we can simply use the intersection operator to create a new type that blends the interfaces of GAEventFields and our dimension payload objects.

export type GAEventPayload = GAEventFields & DimensionIdVals;
export type GoogleAnalyticsValsPayload = GAEventFields & DimensionLabelVals;

// Testing
const labelPayload: GAEventValsPayload = {
    eventCategory: 'advert',
    eventAction: 'click',
    adClient: "WetPaint",
    adCampaign: "Spring 2020",
    //addClient: "WetPaint" // Still fails since not in DimensionLabel nor GAEventFields
};

const idPayload: GAEventPayload = {
    eventCategory: 'advert',
    eventAction: 'click',
    dimension1: "WetPaint",
    dimension2: "Spring 2020",
    //dimension6: "WetPaint" // Still fails since not in DimensionLabel nor GAEventFields
};

Writing our Event Logger

Our final event logger looks like this:

function logEvent(data: GAEventFields & DimensionLabelVals): boolean { 
    const { eventCategory, eventAction, eventLabel, eventValue, ...rest } = data;

    // ReactGA blows up if we don't have at least a category and action
    if (!(eventCategory && eventAction)) { 
        return false;
    }

    // Stub out a plain object to send to ReactGA
    const gaPayload = { eventCategory, eventAction, eventLabel, eventValue };

    // Safely assume the rest of the data is human readable dimension values
    const filteredDimensions = mapToCustomDimensions(rest);

    // Merge the final data objects
    const gaData = { ...gaPayload, ...filteredDimensions };

    // Call ReactGA
    ReactGA.ga('send', 'event', gaData);
    return true;
}

And there we go. We can use human readable dimension labels in our payload objects and still send ReactGA a payload shape it expects. Also, we address typos and get all the other benefits of typescript I've come to know and love ... like autocomplete in VSCode:

Screen Shot 2020-01-31 at 3.42.33 PM.png

Conclusion

This experiment took me into a deep dive of several TypeScript concepts and I'm really impressed with the language features so far. As with a lot of languages and codebases, new things are added every release and it can be hard to sift through the latest best way to do things. If you have a better solution or find a typo, please point it out in the comments. Additionally, check out other programming articles at blainegarrett.com or follow me on twitter @blainegarrett.

Image Credit: "Argonne Scientists Probe the Cosmic Structure of the Dark Universe" by Argonne National Laboratory is licensed under CC BY-NC-SA 2.0