Typescript – recursive object keys as typed string


Some background before the code, I was on a team that cached data from multiple internal APIs. The whole process is an API would event to one of our SQS queues and we would then grab the latest document and update our cache for that document. Then we had an indexer that would listen to Dynamo events and dispatch to multiple queues if relevant data for that index was updated. When dynamo was updated we would know the DocumentType as well as the typescript interfaces that the DocumentType would be along with a copy of the old and current data to compare if something changed. In order to reduce the number of times we called our main service we wanted to compare values from the old to current values to see if we needed to update. In the end it reduced about 30% of calls we made which was a nice bonus. But when defining the list, we could (and did) make simple spelling mistakes that wouldn’t produce an error but we would not be checking what we wanted. We wanted a way for TypeScript to expose the keys for a given object with intellisense. That way we type the object once and can be sure we have the correct path (or can fix it in one place that will display an error elsewhere for us to fix).

// An example 
interface SomeObject {
  checkInDate: string;
  operatingLocation: {
    location: {
      href: string;
    };
  };
}

// We wanted intellisense to allow us to define any of
// the keys of the object something like
const keys: RecursiveKeysOf<SomeObject>[] = [
  // Simple string value
  'checkInDate',
  // Object if we wanted to compare the whole object
  'operatingLocation',
  // only a child string value if the object had other keys
  'operatingLocation.location.href'
];

//stackoverflow.com/questions/65332597#answer-65333050

We came across the above very helpful and super insightful post on StackOverflow that was mostly there. Not sure the exact problem we were having but we eventually got it working and with some lodash imports we had an easy way of typing out the path of objects to check if any of the target fields were not equal!

type RecursiveKeyOfHandleValue<TValue, Text extends string> = TValue extends any[]
  ? Text
  : TValue extends object
  ? Text | `${Text}${RecursiveKeyOfInner<TValue>}`
  : Text;

type RecursiveKeyOfInner<TObj extends object> = {
  [TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue<TObj[TKey], `.${TKey}`>;
}[keyof TObj & (string | number)];

export type RecursiveKeyOf<TObj extends object> = {
  [TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue<TObj[TKey], `${TKey}`>;
}[keyof TObj & (string | number)];

export const targetFieldsHaveChanged =
  <T extends Record<string, any>>(...targetFields: RecursiveKeyOf<T>[]): FieldCheck<T> =>
  (oldDocument: T, newDocument: T): boolean => {
    return !isEqual(pick(oldDocument, targetFields), pick(newDocument, targetFields));
  };

And then we could easily check later with all of typescripts helpful intellisense, it felt like magic! But also the fact that we were constructing the path we could use backslashes instead which can be useful for OpenAPI paths, or we could remove the objects if needed and only allow checking of the child primitives if we wanted. It was really cool to work on that part of the code base.

const didChange = targetFieldsHaveChanged<SomeObject>(
  'checkInDate',
  'operatingLocation.location.href'
);
,