Type Safe Object Access

It's like `lodash.get`, but type safe!

The Problem

In our codebase over at KazooHR.com, we generate all of our code with @graphql-codegen. We use those types (mostly) as the canonical records for our business objects in the frontend (and for a lot of backend operations). Generating this code automatically gives us type safety for most of our backend and frontend code.

We also enforce 100% code coverage. At first, it was onerous, but everyone quickly becomes very good at Jest, so that the bus factor is actually rethinking code so that it’s more easily testable. This rethinking almost always results in cleaner, terser, and more functional code and tests. It’s slower at first, but pays off HUGE in the long run.

But in the front-end, we often run into examples where we have deeply-nested partial data that we don’t want to jump through hoops to write accessors and typeguards for to satisfy our code coverage requirements. Take, for example, a sample GraphQL result:

const { data, loading } = useAutomaticallyGeneratedQuery();

type data =
  | undefined
  | {
      dataNode: {
        sessions:
          | undefined
          | {
              pageInfo: {
                totalPages: number;
              };
              edges: {
                cursor: string;
                node: {
                  id: string;
                  status: "started" | "submitted";
                };
              };
            }[];
      };
    };

How in the world do we safely get the node type of data.sessions without a ton of type guards that result in dozens of new lines of test code or extremely awkward manual type declarations like:

type: Required<AutomaticallyGeneratedQuery["data"]>["dataNode"]["sessions"][0]["edges"][0]["node"]

The accessors for this are even more onerous:

function ReactComponent() {
  const { data } = useAutomaticallyGeneratedQuery();

  if(!data) {
    // handle no data case
  }

  if(!data.sessions) {
    // handle no sessions case
  }

  if(!data.sessions.edges.length) {
    // handle yet another no sessions case
  }

  // Etc...
}

Yikes.

We can get around this a little bit by using nullish-coalescors:

function ReactComponent() {
  const { data } = useAutomaticallyGeneratedQuery();

  if(!data?.sessions?.edges.length) {
    // handle no data case
  }

  // 
}

That satisfies a great chunk of our use-cases, but imagine a scenario where we want to render multiple things based on the data of multiple bits of data in the above shape: it gets hairy and hard to reason-about real quick.

The Solution

TypeScript 4.1’s template literal types to the rescue!

Let’s start with the root-object case:

type SimpleModel = {
  foo: string;
  bar: number;
};

type Keys<T extends object> = keyof T;

const keys: Keys<SimpleModel>[] = ["bar", "foo"];

Sweet! Now, we know the basic keys.

But wait… how do we know that string literal type bar refers to number? In a simple case, that’s pretty easy, right?

type SimpleModel = {
  foo: string;
  bar: number;
};

type Keys<T extends object> = keyof T;

const key: Keys<SimpleModel> = "foo";

// @ts-expect-error
const wrongKeyType: SimpleModel[typeof key] = 123; // Yay, TS knows this is wrong

const correctKeyType: SimpleModel[typeof key] = "this works!";

But let’s see what happens when we expand our root type to nested objects… First, let’s get our nested keys.

type Model = {
  foo: string;
  bar: number;
  nested: {
    hello: string;
    world: "string literal";
  };
};

// Appends an object's keys with `Path`, if necessary
type KeysOf<T extends object, Path extends string = ""> = `${Path extends ""
  ? keyof T
  : `${Path}.${string & keyof T}`}`;

type ValuesOf<T extends object> = T[keyof T];

// Our base API: note, it's recursive
type Keys<T extends object, Path extends string = ""> =
  // Get the root keys
  | KeysOf<T, Path>
  | ValuesOf<
      {
        // Construct a new object from `T[K]` if it's an object, and prepend
        // the new object's keys with `Path.K`
        [K in keyof T]: T[K] extends object
          ? Keys<
              T[K],
              `${Path extends "" ? string & K : `${Path}.${string & K}`}`
            >
          : never;
      }
    >;

const key: Keys<Model> = "nested.hello";

// This errors :(
const correctKeyType: Model[typeof key] = "Fails :(";

Why?

Property 'nested.hello' does not exist on type 'SimpleModel'.(2339)

At this point, you might give up. There’s no way beyond this, since nested.hello can’t ever properly index our Model type.

Maps to the rescue

But fear not! We can maintain the relationship between the object keys and their values by creating a temporary type that maps the dot notation keys to the values that we used to create them. For example, the above Model can be be represented properly if we can somehow turn this:

type Model = {
  foo: string;
  bar: number;
  nested: {
    hello: string;
    world: "string literal";
  };
};

…into the following:

type DotNotationModel = {
  foo: string;
  bar: number;
  nested: { hello: string; world: "string literal" };
  "nested.hello": string;
  "nested.world": "string literal";
};

Then, to get the keys, we can do keyof DotNotationModel, and to map those keys to their values, we can index the DotNotationModel! Excellent! Let’s give it a go:

type Model = {
  foo: string;
  bar: number;
  nested: {
    hello: string;
    world: "string literal";
  };
};

type PathForKey<Path extends string, Key extends string> = `${Path extends ""
  ? Key
  : `${Path}.${string & Key}`}`;

type ValuesOf<T extends object> = T[keyof T];

type NonObjectKeysOf<T> = {
  [K in keyof T]: T[K] extends Array<any> ? K : T[K] extends object ? never : K;
}[keyof T];

type DotNotationMap<T extends object, Path extends string = ""> =
  | ValuesOf<
      {
        [K in keyof T]: T[K] extends object
          ? DotNotationMap<T[K], PathForKey<Path, string & K>>
          : never;
      }
    >
  | {
      [K in keyof T as PathForKey<Path, string & K>]: T[K];
    };

// This works! Yay!
const obj: DotNotationMap<Model> = {
  "nested.hello": "any string",
  "nested.world": "string literal",
  bar: 123,
  foo: "any string!",
  nested: {
    hello: "world",
    world: "string literal",
  },
};

Woohoo! That works. Now, let’s see those keys, just to make sure it works:

// @ts-expect-error
const keys: keyof typeof obj = "nested.hello";

Uh oh. What gives? Why is nested.hello not resolving as a key of typeof obj?

It took me quite a while to figure it out, see if you can spot it.

Find it? Don’t worry if you didn’t. This one was ridiculously hard to spot.

Check the generic above. It creates a set of types from ValuesOf<T>. The values created from that look something like:

type Model = {
  a: string;
  b: number;
  c: {
    foo: string;
    bar: number;
  };
};

// string | number | { foo: string, bar: number }
type Values = ValuesOf<Model>;

They’re unions! Values are:

  • string, number, OR { foo: string, bar: number }

What we really want is for Values to be:

  • string, number, AND { foo: string, bar: number }.

There’s some voodoo involved in doing this, but let’s check it out:

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

(Copied from an excellent blog post by Stefan Baumgartner.)

What this is doing is accumulating the types into a function’s arguments, then re-assembling all of those types as a product of the new function’s arguments. For a in-depth explanation for how and why this works, check out the blog post by Stefan Baumgartner on UnionToIntersection.

So where do we put it? Let’s check out the full example, now:

type Model = {
  foo: string;
  bar: number;
  nested: {
    hello: string;
    world: "string literal";
  };
};

type PathForKey<Path extends string, Key extends string> = `${Path extends ""
  ? Key
  : `${Path}.${string & Key}`}`;

type ValuesOf<T extends object> = T[keyof T];

type NonObjectKeysOf<T> = {
  [K in keyof T]: T[K] extends Array<any> ? K : T[K] extends object ? never : K;
}[keyof T];

// Here's our hero to the rescue
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

type DotNotationMap<
  T extends object,
  Path extends string = ""
> = UnionToIntersection< // <---- New generic goes here, at the root of our API
  | ValuesOf<
      {
        [K in keyof T]: T[K] extends object
          ? DotNotationMap<T[K], PathForKey<Path, string & K>>
          : never;
      }
    >
  | {
      [K in keyof T as PathForKey<Path, string & K>]: T[K];
    }
>;

const obj: DotNotationMap<Model> = {
  "nested.hello": "any string",
  "nested.world": "string literal",
  bar: 123,
  foo: "any string!",
  nested: {
    hello: "world",
    world: "string literal",
  },
};

const keys: keyof typeof obj = "nested.hello"; // Yay!

And that’s it! Your object can now be strongly typed via dot notation accessors.

@baublet/ts-object-helpers

I’ve wrapped this functionality up into a small package, that includes a get and a variadicGet function. I also added some helpers for grabbing types from arrays, undefined root object types and children, and some additional enhancements to make accessing arrays as simple as possible.

To install it, and get all of the above functionality in an easy-to-use set of functions or generics:

// yarn
yarn add @baublet/ts-object-helpers

// npm
npm i @baublet/ts-object-helpers

It’s a slim wrapper around lodash.get that adds the helpful type handling for you! For more information and new versions, check it out on NPM: @baublet/ts-object-helpers.