Consider this object:
export const cypher = {
Foo: {
get Bar() {
return 'Foo_Bar';
},
get Baz() {
return 'Foo_Baz';
},
},
get Qux() {
return 'Qux';
},
Lan: {
Rok: {
get Saz() {
return 'Lan_Rok_Saz';
},
},
},
};
You can see the pattern: this is a "tree" where each leaf would return a string of the concatanated names of its entire branch, i.e.
console.log(cypher.Lan.Rok.Saz) // Lan_Rok_Saz
This manual object is strictly typed, so I get intellisense nicely.
I would like to now create some constructor, that accepts an object of a type such as:
interface Entries {
[key: string]: (string | Entries)[];
}
And returns an object with the structure as the above cypher
, such that TS would be able to intellisense.
The internal implementation isn’t necesarrily important, but the usage of X.Y.Z
is definitely a priority.
So far I’ve tried a recursive function that defines properties based on values:
interface Entries {
[key: string]: (string | Entries)[];
}
const mockEntries: (string | Entries)[] = ['Gat', 'Bay', {
Foo: ['Bar', 'Baz', { Cal: ['Car'] }],
}];
function buildCypherFromEntries(entries: (string | Entries)[], parentName: string, cypher: Record<string, any> = {}) {
entries.forEach(entry => {
if (typeof entry === 'string') {
Object.defineProperty(cypher, entry, {
get() { return (parentName ? `${parentName}_${entry}` : entry); },
});
} else {
Object.entries(entry).forEach(([key, furtherEntries]) => {
cypher[key] = {};
const furtherParentName = parentName ? `${parentName}_${key}` : key;
buildCypherFromEntries(furtherEntries, furtherParentName, cypher[key]);
})
}
})
return cypher;
}
const c = buildCypherFromEntries(mockEntries, '');
console.log(c.Gat) // Gat
console.log(c.Bay) // Bay
console.log(c.Foo.Bar); // Foo_Bar
console.log(c.Foo.Cal.Car) // Foo_Cal_Car
This works, but does not give any intellisense.
It’s also not perfect as it does not support top-level leaves that turn into getters.
I also tried doing this in class form but again the typing confounds me.
2
Answers
In order for this to possibly work, you can’t annotate
mockEntries
as having type(string | Entries)[]
, since that completely throws away any more specific information the compiler might infer from the initializer. Instead you should use aconst
assertion to get the most specific information about the value as possible, especially thestring
literal types:Now we can proceed. Note that the arrays are
readonly
tuple types; we don’t care much about the ordering or thereadonly
-ness (although it’s probably for the best that you don’t alter that information), but that does mean things will be easiest if we allowreadonly
arrays in our types. To that end, let’s redefineEntries
as:One approach looks like this:
We need
buildCypherFromEntries()
to be generic in the typeT
of theentries
argument, plus I guess the typeU
of thecypher
argument which we’ll default to the empty object type{}
. The return type of the function is the intersection of thecypher
typeU
with the typeBuildCypherFromEntryElements<T>
. TheU
part just keeps any other properties that might be inU
, while the main work happens insideBuildCypherFromEntryElements
.So
BuildCypherFromEntryElements<T>
takes aT
which is expected to be an array but might also be "empty" if we’ve recursed down into a single string instead of an object. If it’s not an array we just outputstring
as the value type. If it is an array, then we first applyStrToObj<>
to the union of its elements, so that strings are turned into something with the right key and a value we don’t care about (e.g., we turn'Bar' | 'Baz' | { Cal: ['Car'] }
into{Bar: undefined} | {Baz: undefined} | {Cal: ['Car']}
). This works via distributive conditional type. Then we applyUnionToIntersection<>
to that (see Transform union type to intersection type for implementation information), to turn the union into an intersection (e.g.,{Bar: undefined} & {Baz: undefined} & {Cal: ['Car']}
. And finally we applyBuildCypherFromEntries<T>
to that.And
BuildCypherFromEntries<T>
is a mapped type over its input that just appliesBuildCypherFromEntryElements<>
to each property. Well I also use a trick to turn the resulting nested intersection type into something pretty (see How can I see the full expanded contract of a Typescript type? for implementation info).Let’s test it out:
That looks like what you wanted!
Playground link to code
jcalz already explained all the why’s way better than I can.
I just want to add a slightly different implementation:
Example:
TS Playground link
One important difference to your code: I use a
prefix
where you use theparentName
. Basically