Strictly Typed Object Keys in TypeScript

I feel like my relationship with TypeScript most closely resembles that of a hostage suffering from Stockholm Syndrome. I'm not sure that I can rationally argue in favour of something which I've yet to see catch or mitigate a bug that wouldn't have been caught quickly otherwise, but which does (regularly!) add hours to sprint times. And yet, having used it for several years at this point, I also find myself missing it on projects that are using regular ol' JavaScript. Still, there are a few aspects to it which continue to wind me up. A big one is the lack of coupled types or conditional types. I'm sure that TypeScript purists will argue that even so much as wanting these kinds of features is in some way antithetical to the whole system but, dammit, I often do want them.

As a quick example of what I mean, let's take a front end component that handles data visualisations. For the sake of simplicity, let's presume that I want a single component that can either output a pie chart or a bar graph. You might craft a type schema along these lines:

type DataVis = {
    title: string;
    barGraph?:
        columns: {
            title: string;
            value: number;
        }[];
        axes: {
            x: string;
            y: string;
        };
    };
    pieChart?: {
        segments: {
            percentage: number;
            colour: string;
        };
    };
}

We have a required title string, and then optional data objects for either a pie chart or a bar graph. Except, hold on, this type definition has a couple of problems. What happens if someone sends both pie chart and bar graph data at once? Or what if someone doesn't send either? No, what we actually want is to restrict the type to being either a pie chart or a bar graph, and we want to ensure that one of those is always present. Okay, how about this:

type BarGraph = {
    columns: {
        title: string;
        value: number;
    }[];
    axes: {
        x: string;
        y: string;
    };
}

type PieChart = {
    segments: {
        percentage: number;
        colour: string;
    };
}

type DataVis = {
    title: string;
    visualisation: BarGraph | PieChart;
}

That works well, but now I don't have the same immediate way of knowing what visualisation I'm meant to be showing. Yes, in this simplified example we can use the different naming conventions of PieChart and BarGraph in our component to work this out e.g. if data.segments exists, then it's a pie chart. But let's say we have a dozen graph types, and some lack unique type attributes. Or what if we're building a component that lets the user switch between a few different data visualisations. We could write convoluted if statements, or we could encode the graph type at the point we likely already know it: when we're passing the data into the component.

The simplest solution here is to extend PieChart and BarGraph with a required, consistently named type attribute. Maybe something like graphType, which can have a value of either pie or bar, for instance. However, remembering that this has to be added (and maintained) across every chart type is fiddly; it would be better to be colocated with the DataVis type. And sometimes we can't easily extend those nested types, as we may be dealing with third-party components or APIs.

We could set a graphType attribute at the top level, but then what happens when you pass the graphType as bar, but provide a PieChart data object 🤦‍♀️ Wouldn't it be nice to have some type safety in this type system of ours!

I've smacked my head against this wall multiple times in the past, but recently found a little trick that does exactly that! By (slightly abusing) ternary structures, alongside the key in TypeScript function, we can create typed keys that can be reused and referenced elsewhere, and which will only accept a specific set of values.

type GraphType = "bar" | "line" | "pie"

type DataVis = {
    title: string;
    visualisation: {
        [key in GraphType]: key extends "bar" ? BarGraph : key extends "line" ? LineGraph : key extends "pie" ? PieChart : never;
    };
}

That allows us to use the values within the string literal GraphType and map them to our various visualisation types (e.g. BarGraph or PieChart), using a typical if/else statement structure. Doing so restricts the allowed type of visualisation to the relevant data structure.

The result is a data object that would look like this:

{
    title: "Graph Title",
    visualisation: {
        bar: {...},
        line: {...},
        chart: {...}
    }
}

If you won't always have the same set of subsections within visualisation, you can make the key optional too: [key in GraphType]?: ... – though note that this makes all keys optional! (If anyone knows of a way to make individual ones optional, then I'd love to know more.)

I understand that this is a niche requirement, and I also understand that there are probably other ways to rewrite this type schema so that you don't need the nesting etc., but I've found this useful several times now, so wanted to make a note of it. Hopefully other people find it useful, too.

Explore Other Articles

Conversation

Want to take part?

Comments are powered by Webmentions; if you know what that means, do your thing 👍

  • <p>Want to type a data object so that a given key (e.g. "foo") can only be paired with a specific type (e.g. Bar)? Now you can!</p>
  • Murray Adcock.
Article permalink

Made By Me, But Made Possible By:

CMS:

Build: Gatsby

Deployment: GitHub

Hosting: Netlify

Connect With Me:

Twitter Twitter

Instagram Instragram

500px 500px

GitHub GitHub

Keep Up To Date:

All Posts RSS feed.

Articles RSS feed.

Journal RSS feed.

Notes RSS feed.