I have objects being returned by a server that might contain container attributes.
Imagine an object like:
{
attr1: string,
attr2: number,
attr3: {
uselessArg1: ...,
uselessArg2: ...,
uselessArg3: ...,
content: [subVal1, subVal2, ...]
}
}
I have a function that would grab that object and return it like this:
{
attr1: string,
attr2: number,
attr3: [subVal1, subVal2, ...]
}
Now, the real problem is that any object might have an attribute that has the object type with the useless keys and the content
sub-attribute, including the objects in the redundant
array
So we could imagine that any given object inherits from this interface:
interface MaybeHasContent {
[key:string]?: {
uselessArg1: ...,
uselessArg2: ...,
uselessArg3: ...,
content:this[]
}
}
My utility function first validates whether any key in the object has a content
subkey, if found, hoists the value of the content
subkey to its parent key, which clears all the useless attributes as well. Then it keeps calling itself on any child object of the current working object and also on any object inside the content
array. It essentially runs through all the branches of an object, including branches that have object arrays, and hoists a very specific key one level up
How do I write an interface for this monster? I would like TS to understand that if I have an object where I can write myObj.val1.redundant[0].name
, then after passing it through my utility function this would be a legal call: myObj.val1[0].name
CodePudding user response:
Here's one possible approach:
declare function clean<T>(t: T): Clean<T>;
type Clean<T> = T extends object ? (
T extends { content: infer U } ?
Clean<U> : { [K in keyof T]: Clean<T[K]> }
) : T;
Your function takes a value t
of generic type T
, and returns a value of type Clean<T>
.
And Clean<T>
is a recursive conditional type representing what clean()
does. If T
is of a non-primitive object
type, then we check to see if has a content
property. If so, we extract that property (using conditional type infer
ence) and pass it as the new type argument to Clean
. If not, then we map the object type T
to a version where each property value has Clean
applied to it. (Note that this also takes care of array types, since mapped types on arrays and tuples become arrays and tuples.) Finally, if T
is a primitive type, we just return T
.
Let's test it out:
const cleaned = clean({
attr1: "abc",
attr2: 123,
attr3: {
uselessArg1: 1,
uselessArg2: 2,
uselessArg3: 3,
content: [
{ a: 1, b: 2, c: { content: "a", x: 1 } },
{ a: 3, b: 4, c: { content: "b", x: 2 } }
]
}
});
/* const cleaned: {
attr1: string;
attr2: number;
attr3: {
a: number;
b: number;
c: string;
}[];
} */
Looks good. The type of cleaned
is the same as the input type with all the content
properties "hoisted up". So attr3
doesn't have the uselessArg
properties. It's just an array. And the elements of that array have a c
property that's just a string
, because the c
property of the input array had a string
-valued content
property itself.
There are always edge cases with such recursive type utilities. For example, it would be a bad idea if you tried to clean a pathologically recursive data structure:
interface ContentOuroboros {
content: ContentOuroboros
}
type OhNoes = Clean<ContentOuroboros> // error!
// Type instantiation is excessively deep and possibly infinite.
I imagine you're not likely to do that, but it's hard for me to think of all possible pitfalls in advance. So be sure to test it against your use cases and tweak if necessary.