1

I want to have a Typescript type that changes (recursively) the type of a property depending on the properties name.

The use case is to have a proper typed function that converts strings ending with '_at', in an object, to Dates.

How would I enable this? Is it even possible? Code snippet in Typescript Playground

const isDate = (obj: unknown) => Object.prototype.toString.call(obj) === '[object Date]';
const isObject = (obj: unknown) => ((typeof obj === 'function' || (typeof obj === 'object' && !!obj)) && Array.isArray(obj) === false && !isDate(obj));
 
// What should be the return type of this function?
const dateStringsToDate = (dbResult: any) => {
    for (const key of Object.keys(dbResult)) {
        // we assume every timestamp ends like this e.g. created_at, begin_at
        if (key.endsWith('_at') && typeof dbResult[key] === 'string') {
            dbResult[key] = new Date(dbResult[key]);
        }
        if (isObject(dbResult[key])) dbResult[key] = dateStringsToDate(dbResult[key]);
        if (Array.isArray(dbResult[key]))
            dbResult[key] = dbResult[key].map((x: any) => dateStringsToDate(x));
    }
    return dbResult;
};

// example usage
const post = { created_at: '2022-01-15T14:31:05.252Z', title: 'foo'}
const convertedPost = dateStringsToDate(post)
console.log(convertedPost) // { created_at: Sat Jan 15 2022 14:31:05 GMT+0100 (Central European Standard Time), title: 'foo' }

// how to get the proper type for convertedPost?
samuba
  • 80
  • 8
  • This looks like it is a run time operation, and TypeScript only works at compile time. – tromgy Jan 15 '22 at 14:46
  • Yes this is a run time operation, but Typescript can do crazy advanced things with it's type system. You could have for example a objectKeysToSnakeCase() function that executes on runtime but leverages this type: https://github.com/sindresorhus/type-fest/blob/main/source/snake-cased-properties-deep.d.ts in the functions definition to make the usage completely typesafe – samuba Jan 15 '22 at 14:51
  • I would advise against doing "crazy advanced things" if you want to be able to read and maintain your code a year from now – tromgy Jan 15 '22 at 14:59
  • Having maintainable code is exactly the reason why I want this use case to be properly typed ;-) – samuba Jan 15 '22 at 15:08
  • This is definitely possible, but your example doesn't work for me out of the box at runtime (e.g., `isObject`) and there are a bunch of TS errors unrelated to your question. Please provide a [mre] that clearly demonstrates the issue you are facing. Ideally someone could drop the code into a standalone IDE like [The TypeScript Playground (link here!)](https://tsplay.dev/WJRLvW) and immediately get to work solving the problem without first needing to re-create it. So there should be no pseudocode, typos, unrelated errors, or undeclared types or values. – jcalz Jan 16 '22 at 00:57
  • Thank you for pointing this out @jcalz. I updated the question accordingly. – samuba Jan 17 '22 at 08:32
  • Does [this approach](https://tsplay.dev/mAvGkW) meet your needs? If so I can write up an answer; if not, please let me know what I'm missing. – jcalz Jan 18 '22 at 00:34
  • @jcalz it fits exactly my needs, thanks! And even looks little bit cleaner then Jimmys answer to me. – samuba Jan 18 '22 at 08:29
  • Since you've already accepted an answer I don't feel particularly inclined to write up another one. – jcalz Jan 18 '22 at 14:56

1 Answers1

1

This does what you want, but it involves a couple of type assertions. Your original function modified the object that was passed in in-place and then returned that object. This version creates a new object and leaves the original alone. I believe the type assertions are necessary to "build up" the returned object as we iterate through the keys. (If there's safer way to do this that doesn't involve type assertions, I'd be interested to know.)

I updated your example usage at the bottom to demonstrate that this construction works with recursive objects, and used a modified version of @jcalz's Expand type that removes all the properties of Date itself in the final object. This is just for demonstration purposes to clearly show the shape of the final object.

const isObject = (obj: unknown) =>
  (typeof obj === "function" || (typeof obj === "object" && !!obj)) &&
  Array.isArray(obj) === false &&
  !(obj instanceof Date);

type VivifyDates<T> = {
  [K in keyof T]: K extends `${string}_at`
    ? T[K] extends string
      ? Date
      : T[K]
    : VivifyDates<T[K]>;
};

const dateStringsToDate = <T extends { [index: string]: any }>(
  o: T
): VivifyDates<T> => {
  const converted = {} as any;

  for (const key of Object.keys(o)) {
    if (key.endsWith("_at") && typeof o[key] === "string") {
      converted[key] = new Date(o[key]);
    } else {
      converted[key] = o[key];
    }

    if (isObject(o[key])) {
      converted[key] = dateStringsToDate(o[key]);
    }

    if (Array.isArray(o[key])) {
      converted[key] = o[key].map((x: any) => dateStringsToDate(x));
    }
  }

  return converted as VivifyDates<T>;
};

// example usage
const post = {
  created_at: "2022-01-15T14:31:05.252Z",
  title: "foo",
  array: [1, 2, { created_at: "2022-01-15T14:31:05.252Z" }],
  nested: {
    created_at: "2022-01-15T14:31:05.252Z",
    title: "foo",
  },
};
const convertedPost = dateStringsToDate(post);

// Shows the "expanded" type including each field with its final type.
type ExpandRecursively<T> = T extends object
  ? T extends infer O
    ? {
        [K in keyof O]: O[K] extends Date ? O[K] : ExpandRecursively<O[K]>;
      }
    : never
  : T;
const expanded: ExpandRecursively<typeof convertedPost> = convertedPost;

TS Playground

Jimmy
  • 35,686
  • 13
  • 80
  • 98
  • This is great. Thank you for also pointing out the Expanded type, very useful. I like @jcalz version a little bit more as it is a little bit more readable to me. – samuba Jan 18 '22 at 08:26