Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript: deep keyof of a nested object, with related type

I'm looking for a way to have all keys / values pair of a nested object.

(For the autocomplete of MongoDB dot notation key / value type)

interface IPerson {
    name: string;
    age: number;
    contact: {
        address: string;
        visitDate: Date;
    }
}

Here is what I want to achieve, to make it becomes:

type TPerson = {
    name: string;
    age: number;
    contact: { address: string; visitDate: Date; }
    "contact.address": string;
    "contact.visitDate": Date;
}

What I have tried:

In this answer, I can get the key with Leaves<IPerson>. So it becomes 'name' | 'age' | 'contact.address' | 'contact.visitDate'.

And in another answer from @jcalz, I can get the deep, related value type, with DeepIndex<IPerson, ...>.

Is it possible to group them together, to become type like TPerson?

Modified 9/14: The use cases, need and no need:

When I start this question, I was thinking it could be as easy as something like [K in keyof T]: T[K];, with some clever transformation. But I was wrong. Here is what I need:

1. Index Signature

So the interface

interface IPerson {
    contact: {
        address: string;
        visitDate: Date;
    }[]
}

becomes

type TPerson = {
    [x: `contact.${number}.address`]: string;
    [x: `contact.${number}.visitDate`]: Date;
    contact: {
        address: string;
        visitDate: Date;
    }[];
}

No need to check for valid number, the nature of Array / Index Signature should allow any number of elements.

2. Tuple

The interface

interface IPerson {
    contact: [string, Date]
}

becomes

type TPerson = {
    [x: `contact.0`]: string;
    [x: `contact.1`]: Date;
    contact: [string, Date];
}

Tuple should be the one which cares about valid index numbers.

3. Readonly

readonly attributes should be removed from the final structure.

interface IPerson {
    readonly _id: string;
    age: number;
    readonly _created_date: Date;
}

becomes

type TPerson = {
    age: number;
}

The use case is for MongoDB, the _id, _created_date cannot be modified after the data has been created. _id: never is not working in this case, since it will block the creation of TPerson.

4. Optional

interface IPerson {
    contact: {
        address: string;
        visitDate?: Date;
    }[];        
}

becomes

type TPerson = {
    [x: `contact.${number}.address`]: string;
    [x: `contact.${number}.visitDate`]?: Date;
    contact: {
        address: string;
        visitDate?: Date;
    }[];
}

It's sufficient just to bring the optional flags to transformed structure.

5. Intersection

interface IPerson {
    contact: { address: string; } & { visitDate: Date; }
}

becomes

type TPerson = {
    [x: `contact.address`]: string;
    [x: `contact.visitDate`]?: Date;
    contact: { address: string; } & { visitDate: Date; }
}

6. Possible to Specify Types as Exception

The interface

interface IPerson {
    birth: Date;
}

becomes

type TPerson = {
    birth: Date;
}

not

type TPerson = {
    age: Date;
    "age.toDateString": () => string;
    "age.toTimeString": () => string;
    "age.toLocaleDateString": {
    ...
}

We can give a list of Types to be the end node.

Here is what I don't need:

  1. Union. It could be too complex with it.
  2. Class related keyword. No need to handle keywords ex: private / abstract .
  3. All the rest I didn't write it here.
like image 337
Val Avatar asked Sep 10 '21 03:09

Val


1 Answers

Below is the full implementation I have of Flatten<T, O> which transforms a type possibly-nested T into a "flattened" version whose keys are the dotted paths through the original T. The O type is an optional type where you can specify a (union of) object type(s) to leave as-is without flattening them. In your example, this is just Date, but you could have other types.

Warning: it's hideously ugly and probably fragile. There are edge cases all over the place. The pieces that make it up involve weird type manipulations that either don't always do what one might expect, or are impenetrable to all but the most seasoned TypeScript veterans, or both.

In light of that, there is no such thing as a "canonical" answer to this question, other than possibly "please don't do this". But I'm happy to present my version.

Here it is:


type Flatten<T, O = never> = Writable<Cleanup<T>, O> extends infer U ?
    U extends O ? U : U extends object ?
    ValueOf<{ [K in keyof U]-?: (x: PrefixKeys<Flatten<U[K], O>, K, O>) => void }>
    | ((x: U) => void) extends (x: infer I) => void ?
    { [K in keyof I]: I[K] } : never : U : never;

The basic approach here is to take your T type, and return it as-is if it's not an object or if it extends O. Otherwise, we remove any readonly properties, and transform any arrays or tuples into a version without all the array methods (like push() and map()) and get U. We then flatten each property in that. We have a key K and a flattened property Flatten<U[K]>; we want to prepend the key K to the dotted paths in Flatten<U[K]>, and when we're done with all that we want to intersect these flattened objects (with the unflattened object too) all together to be one big object.

Note that convincing the compiler to produce an intersection involves conditional type inference in contravariant positions (see Transform union type to intersection type), which is where those (x: XXX) => void) and extends (x: infer I) => void pieces come in. It makes the compiler take all the different XXX values and intersect them to get I.

And while an intersection like {foo: string} & {bar: number} & {baz: boolean} is what we want conceptually, it's uglier than the equivalent {foo: string; bar: number; baz: boolean} so I do some more conditional type mapping with { [K in keyof I]: I[K] } instead of just I (see How can I see the full expanded contract of a Typescript type?).

This code generally distributes over unions, so optional properties may end up spawning unions (like {a?: {b: string}} could produce {"a.b": string; a?: {b: string}} | {"a": undefined, a?: {b: string}}, and while this might not be the representation you were going for, it should work (since, for example, "a.b" might not exist as a key if a is optional).


The Flatten definition depends on helper type functions that I will present here with various levels of description:

type Writable<T, O> = T extends O ? T : {
    [P in keyof T as IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P>]: T[P]
}

type IfEquals<X, Y, A = X, B = never> =
    (<T>() => T extends X ? 1 : 2) extends
    (<T>() => T extends Y ? 1 : 2) ? A : B;

The Writable<T, O> returns a version of T with the readonly properties removed (unless T extends O in which case we leave it alone). It comes from TypeScript conditional types - filter out readonly properties / pick only required properties.

Next:

type Cleanup<T> =
    0 extends (1 & T) ? unknown :
    T extends readonly any[] ?
    (Exclude<keyof T, keyof any[]> extends never ?
        { [k: `${number}`]: T[number] } : Omit<T, keyof any[]>) : T;

The Cleanup<T> type turns the any type into the unknown type (since any really fouls up type manipulation), turns tuples into objects with just individual numericlike keys ("0" and "1", etc), and turns other arrays into just a single index signature.

Next:

type PrefixKeys<V, K extends PropertyKey, O> =
    V extends O ? { [P in K]: V } : V extends object ?
    { [P in keyof V as
        `${Extract<K, string | number>}.${Extract<P, string | number>}`]: V[P] } :
    { [P in K]: V };

PrefixKeys<V, K, O> prepends the key K to the path in V's property keys... unless V extends O or V is not an object. It uses template literal types to do so.

Finally:

type ValueOf<T> = T[keyof T]

turns a type T into a union of its properties. See Is there a `valueof` similar to `keyof` in TypeScript?.

Whew! 😅


So, there you go. You can verify how closely this conforms to your stated use cases. But it's very complicated and fragile and I wouldn't really recommend using it in any production code environment without a lot of testing.

Playground link to code

like image 139
jcalz Avatar answered Sep 17 '22 00:09

jcalz