Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript recursively mapping object property types: object and array element types

UPDATE 1 -> SEE BELOW

given the following interfaces

interface Model {
    bla: Child1;
    bwap: string;
}
interface Child1 {
    blu: Child2[];
}
interface Child2 {
    whi: string;
}

I am trying to write a function that will essentially be used like so:

let mapper = path<Model>("bla")("blu");
let myPropertyPath = mapper.path; //["bla", "blu"]

Function call x only accepts arguments that are keys of the type found at level x in the object hierarchy.

I have a working version of this, inspired by RafaelSalguero's post found here, and it looks like this:

interface IPathResult<TRoot, TProp> {
    <TSubKey extends keyof TProp>(key: TSubKey): IPathResult<TRoot, TProp[TSubKey]>;
    path: string[];
}

function path<TRoot>() {
    return <TKey extends keyof TRoot>(key: TKey) => subpath<TRoot, TRoot, TKey>([], key);
}

function subpath<TRoot, T, TKey extends keyof T>(parent: string[], key: TKey): IPathResult<TRoot, T[TKey]> {
    const newPath = [...parent, key];
    const x = (<TSubKey extends keyof T[TKey]>(subkey: TSubKey) => subpath<TRoot, T[TKey], TSubKey>(newPath, subkey)) as IPathResult<TRoot, T[TKey]>;
    x.path = newPath;
    return x;
}

A little more context is in order here to see where I'm going with this: I'm writing a binding library for use with (p)react. Drawing inspiration from Abraham Serafino's guide, found here: his uses plain dotted strings to describe a path; I want to have type safety when doing it. So an IPathResult<TRoot, TProp> instance could be read like: this gives me a path starting from TRoot that results in any property down the line of type TProp.

An example of its usage would be (without the implementation details)

<input type="text" {...bind.text(s => s("bwap")) } />

where the s parameter is path<TState>() and TState = Model. The bind.text function would return a value property and an onChange handler that would call the component's setState method, setting the value found through the path we mapped to the new value.

This all works wonderfully, until you throw array properties in the mix. Let's say I want to render inputs for all elements found in the blu property on the Child1 interface. What I would like to do is to write something like this:

let mapper = path<Model>("bla"); //IPathResult<Model, Child1>
for(let i = 0; i < 2; i++) { //just assume there are 2 elements in that array
    let myPath = mapper("blu", i)("whi"); //IPathResult<Model, string>, note: we're writing "whi", which is a key of Child2, NOT of Array<Child2>
}

So, the mapper calls would have an overload that takes an extra argument for array TProp, specifying the index of the element in the array to bind to, and the most important part: the next mapper call would require keys of the array element type, NOT the array type itself!

So plugging that into the actual binding example, this could become:

<input type="text" {...bind.text(s => s("bla")("blu", i)("whi")) } />

So this is where I'm stuck: even disregarding actual implementation, how do I define that overload in the IPathResult<TRoot, TProp> interface? The following would be the gist of it, but doesn't work:

interface IPathResult<TRoot, TProp> {
    <TSubKey extends keyof TProp>(key: TSubKey): IPathResult<TRoot, TProp[TSubKey]>;
    <TSubKey extends keyof TProp>(key: TSubKey, index: number): IPathResult<TRoot, TProp[TSubKey][0]>;
    path: string[];
}

Typescript doesn't accept the TProp[TSubKey][0] part, saying that type 0 cannot be used to index type TProp[TSubKey]. I guess that makes sense since there is no way for the typing system to know that TProp[TSubKey] can be indexed that way, it's simply not defined.

If I write an interface like this:

interface IArrayPathResult<TRoot, TProp extends Array<TProp[0]>> {
    <TSubKey extends keyof TProp[0]>(key: TSubKey): IPathResult<TRoot, TProp[0][TSubKey]>;
    path: string[];
}

that allows me to actually type this:

const result: IArrayPathResult<Model, Child2[]> = null;
result("whi"); //woohoow!

That's a pointless example in itself of course, but just goes to show that it IS possible to get that array element type returned. But TProp is now defined as always being an array, which is obviously not going to be the case.

I feel like I'm pretty close to getting this, but bringing it all together is eluding me. Any ideas?

Important note: there are no instance arguments to infer types from, and there aren't supposed to be any!

UPDATE 1:

Using Titian's solution with conditional types, I can use the syntax I wanted. However, some type information seems to have gone missing:

const mapper = path<Model>();
const test: IPathResult<Model, string> = mapper("bla"); //doesn't error!

This should not work, because the mapper("bla") call returns IPathResult<Model, Child1>, which is not assignable to IPathResult<Model, string>! If you remove one of the 2 method overloads in the IPathResult interface, the code above does correctly error, saying that "Type 'Child1' is not assignable to type 'string'."

So close! Any more ideas?

like image 582
Timothy Degryse Avatar asked Feb 02 '26 00:02

Timothy Degryse


1 Answers

You can use conditional types to extract the item element, and ensure that the array version is only called with array keys:

type KeysOfType<T, TProp> = { [P in keyof T]: T[P] extends TProp? P : never}[keyof T];
type ElementTypeIfArray<T> = T extends Array<infer U> ? U : never

interface IPathResult<TRoot, TProp> {
    <TSubKey extends KeysOfType<TProp, Array<any>>>(key: TSubKey, index: number): IPathResult<TRoot, ElementTypeIfArray<TProp[TSubKey]>>;
    <TSubKey extends keyof TProp>(key: TSubKey): IPathResult<TRoot, TProp[TSubKey]>;
    path: string[];
}

let mapper = path<Model>()("bla")("blu", 1)("whi"); // works
let mapper2 = path<Model>()("bla", 1) // error bla not an array key

Update

Not sure why ts thinks IPathResult<Model, string> is compatible with IPathResult<Model, Child1>, if I remove the any of the overloads it indeed does report a compatibility error. Maybe it is a compiler bug, I might investigate it later if I get a chance. The simplest work around is to add a field to IPathResult that uses TProp directly to ensure incompatibility:

interface IPathResult<TRoot, TProp> {
    <TSubKey extends KeysOfType<TProp, Array<any>>>(key: TSubKey, index: number): IPathResult<TRoot, ElementTypeIfArray<TProp[TSubKey]>>;
    <TSubKey extends keyof TProp>(key: TSubKey): IPathResult<TRoot, TProp[TSubKey]>;
    path: string[];
    __: TProp;
}
like image 74
Titian Cernicova-Dragomir Avatar answered Feb 03 '26 12:02

Titian Cernicova-Dragomir