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