1

Here is the demo

I am trying to type this function

const reduceTransformNode = (cacheNode, [transformKey, transformValue]) => {
  const { [transformKey]: node } = cacheNode;
  const newCacheValue =
    typeof transformValue === "function"
      ? transformValue(node)
      : traverse(transformValue, node);

  return {
    ...cacheNode,
    [transformKey]: newCacheValue
  };
};

I cannot seem to solve it because there seems to be a circular dependency between traverse and reduceTransformNode

This is one solution I found that would work but not ideal.

function reduceTransformNode<T extends { [key: string]: any }>(cacheNode: T, [transformKey, transformValue]: [string, any]): T {
  const { [transformKey]: node } = cacheNode;
  const newCacheValue =
    typeof transformValue === "function"
      ? transformValue(node)
      : traverse(transformValue, node);

  return {
    ...cacheNode,
    [transformKey]: newCacheValue
  };
};

I don't want to have any in the code. Can anyone give this trick TypeScript problem a try

Joji
  • 4,703
  • 7
  • 41
  • 86
  • 1
    "I don't want to have `any`", but when I look at the demo I see `((params: T[K]) => any)`. I doubt you'd be happy if someone called `traverse(cache, { a: () => 1 })`. Could you clean that up so that I understand what the typings are really supposed to be? Also, all the code in a [mcve] should be in the text of the question and not just in an external link, as mentioned in [ask]. The playground links are great but do not suffice here (I spent a while asking "what `Object.entries`?" before I realized you left your code out of the question). – jcalz Nov 06 '20 at 14:52
  • Is [this](https://tsplay.dev/lm0dqm) what you're looking for? If so, I'll write it up. If not, please elaborate. – jcalz Nov 06 '20 at 15:31
  • @jcalz hi thanks for the reply. Yes I probably shouldn't use `any` as in `((params: T[K]) => any`, the idea is that it is a function that filters the field and returns the filtered items. I just don't know how to type it so I leave it as `any`. And yes `traverse(cache, { a: () => 1 })` it not ok. Also I think you are on the right track. Please finish it. Really appreciate your help. – Joji Nov 06 '20 at 17:39

1 Answers1

3

I'm not 100% sure I understand the use case, but my inclination would be to give things the following types. First of all, we should decide what the return type of function-like transformed methods should be; any is not restrictive enough. It looks like you want to use a reduce-like function, so perhaps the transformations should preserve the type of the input:

type MappedTransform<T> = {
  [K in keyof T]?: MappedTransform<T[K]> | ((params: T[K]) => T[K]); // <-- not any, right?
};

Since you're using Object.entries() it might be helpful to give define an Entries<T> type that evaluates to a union of the key-value tuples for an object type T.

type Entries<T> = { [K in keyof T]-?: [K, T[K]] }[keyof T];

The Object.entries() typings in TypeScript only returns something like [string, any] because object types in TypeScript are not "exact" (see microsoft/TypeScript#12946) and can always have more properties than the compiler knows about; (see this answer for more info). So if you want to tell the compiler that Object.entries(transformObject) only has the entries known about in the type MappedTransform<R>, you need to use a type assertion:

function traverse<R>(cache: R, transformObject: MappedTransform<R>): R {
  return (
    Object.entries(transformObject) as Array<Entries<MappedTransform<R>>>
  ).reduce(reduceTransformNode, cache);
}

We are claiming above that traverse() takes a cache of type R and returns a value of the same type R. (This is only technically true for the outermost call to traverse() and not the inner recursive calls, so we'll have to use another type assertion inside reduceTransformNode() below).

In order for reduceTransformNode() to be usable as a callback to reduce() above, its typing needs to be be a function from an accumulator of type R, a value of type Entries<MappedTransform<R>>, and return a new accumulator of type R:

const reduceTransformNode = <R, K extends keyof R>(
  cacheNode: R,
  [transformKey, transformValue]: [K, MappedTransform<R[K]> | ((params: R[K]) => R[K]) | undefined]
): R => {
  const { [transformKey]: node } = cacheNode;

  // optional properties can technically be present but undefined, so let's deal with that
  if (typeof transformValue === 'undefined') return cacheNode;

  const newCacheValue =
    typeof transformValue === "function"
      ? (transformValue as (params: R[K]) => R[K])(node)
      : traverse(transformValue as R[K], node); // NOTE!
  // ----------> ~~~~~~~~~~~~~~~~~~~~~~
  // transformValue is a MappedTransform<R[K]> and not an R[K].  But
  // traverse expects R[K] as its first parameter, so we need an assertion
  // this is probably because in this recursive call to traverse, you are not
  // just converting an initial R[K] to a final R[K].  It's possible that
  // this type assertion is indicative of some problem, but it's hard for me
  // to pinpoint. It's safer than any, at least

  return {
    ...cacheNode,
    [transformKey]: newCacheValue
  };
};

We can get better type safety guarantees from the compiler if the value is of type [K, MappedTransform<R[K]> | ((params: R[K]) => R[K]) | undefined] for a generic K extends keyof R instead of the essentially equivalent Entries<MappedTransform<R>>. That's so the implementation of reduceTransformNode() can be seen as acting upon a single property of R and not on a union of all properties of R.

As you can see, we have a type assertion in the inner recursive call to traverse(). It's possible one can type traverse() to work for both the outermost call where the output type matches the input type and inner calls where the output type is a transformed version of the input type... but I couldn't find one in the time I spent on it. Instead I would use a type assertion with an explanatory comment (like above) and move on.


Let's test it:

const x = traverse(cache, {
  a: {
    b: {
      c: (node) => node.filter((s) => !thingsToRemove.includes(s))
    }
  }
});
console.log(x.a.b.c.map(x => x.toLowerCase()).join(", ")); // topreserve1, topreserver2

Looks good. x has the type MyCache and the compiler infers that the transformObject parameter's a.b.c callback is of type string[] (which is good) to string[].


Again, it's possible that I missed the use case and there are things you need that are incompatible with the typings above. But hopefully it gives you some direction.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thanks for the thorough explanation! Really appreciate it! There is just one thing I forgot to mention in the description, which is that the transform function we have here, which in your solution has this type `((params: T[K]) => T[K])`, the `params` here can only be an array, i.e. `(node) => node.filter((s) => !thingsToRemove.includes(s))` here the `node` can only be an array. Also the result of this function is also going to remain as an array, i.e. `newCacheValue ` is an array. It is possible to add this type assertion for this function? – Joji Nov 14 '20 at 22:06
  • Ideally we can provide a second generic type in addition to `T` in `type MappedTransform`, for example, `MappedTransform`, here U is for the type of the array which is exactly `node`'s type. I am not sure if it is possible to do that. But really thank you for pointing me to the right direction! – Joji Nov 14 '20 at 22:09
  • Possibly, yes! But I don’t know if I can commit to putting more effort into this answer for a while. My hope in posting the solution code as a comment was that any missing use case would be exposed then, and the question scope would stabilize before I wrote up an answer. Now that I have written one up, I’m less inclined to dedicate more resources to scope creep. – jcalz Nov 14 '20 at 22:12
  • That's totally fine. Thanks for the effort. I really learned a lot from it. Just wondering if there is any quick fix we can do to assert that `node` is an array of any kind? Thanks for the answer really appreciate it. – Joji Nov 14 '20 at 22:15