2

I want to convert a structure type into another. The source structure is an object which possibly contains properties keys splitted by a dot. I want to expand those "logic groups" into sub-objects.

Hence from this:

interface MyInterface {
    'logicGroup.timeout'?: number;
    'logicGroup.serverstring'?: string;
    'logicGroup.timeout2'?: number;
    'logicGroup.networkIdentifier'?: number;
    'logicGroup.clientProfile'?: string;
    'logicGroup.testMode'?: boolean;
    station?: string;
    other?: {
        "otherLG.a1": string;
        "otherLG.a2": number;
        "otherLG.a3": boolean;
        isAvailable: boolean;
    };
}

to this:

interface ExpandedInterface {
    logicGroup: {
        timeout?: number;
        serverstring?: string;
        timeout2?: number;
        networkIdentifier?: number;
        clientProfile?: string;
        testMode?: boolean;
    }
    station?: string;
    other?: {
        otherLG: {
            a1: string;
            a2: number;
            a3: boolean;
        },
        isAvailable: boolean;
    };
}

__

(EDIT) These two structures above are based, of course, on a real Firebase-Remote-Config Typescript transposition that cannot have arbitrary props (no index signatures) or keys collision (we don't expect to have any logicGroup and logicGroup.anotherProperty).

Properties can be optional (logicGroup.timeout?: number) and we can assume that if all the properties in logicGroup are optional, logicGroup itself can be optional.

We do expect that an optional property (logicGroup.timeout?: number) is going to maintain the same type (logicGroup: { timeout?: number }) and not to become mandatory with the possibility to explicitly accept undefined as a value (logicGroup: { timeout: number | undefined }).

We expect all the properties to be objects, strings, numbers, booleans. No arrays, no unions, no intersections.


I've tried sperimenting a bit with Mapped Types, keys renaming, Conditional Types and so on. I came up with this partial solution:

type UnwrapNested<T> = T extends object
    ? {
        [
            K in keyof T as K extends `${infer P}.${string}` ? P : K
        ]: K extends `${string}.${infer C}` ? UnwrapNested<Record<C, T[K]>> : UnwrapNested<T[K]>;
      }
    : T;

Which doesn't output what I want:

type X = UnwrapNested<MyInterface>;
//   ^?    ===> { logicGroup?: { serverString: string | undefined } | { testMode: boolean | undefined } ... }

So there are two issues:

  1. logicGroup is a distributed union
  2. logicGroup properties hold | undefined instead of being actually optional.

So I tried to prevent the distribution by clothing K value:

type UnwrapNested<T> = T extends object
    ? {
        [
            K in keyof T as K extends `${infer P}.${string}` ? P : K
        ]: [K] extends [`${string}.${infer C}`] ? UnwrapNested<Record<C, T[K]>> : UnwrapNested<T[K]>;
      }
    : T;

This is the output, but it is still not what I want to get:

type X = UnwrapNested<MyInterface>;
//  ^?    ===> { logicGroup?: { serverString: string | boolean | number | undefined, ... }}

One problem goes away and another raises. So the issues are:

  1. All the sub properties get as a type a union of all the values available in the same "logic group"
  2. All the sub properties hold | undefined instead of being actually optional.

I've also tried to play with other optionals in the attempt of filtering the type and so on, but I don't actually know what I am missing.

I also found https://stackoverflow.com/a/50375286/2929433, which actually works on its own, but I wasn't able to integrate it inside my formula.

What I'm not understanding? Thank you very much!

kelsny
  • 23,009
  • 3
  • 19
  • 48
  • Will the keys only be in the form `x.y` or will there be even more deeply nested keys like `x.y.z`? – kelsny May 20 '22 at 14:09
  • @catgirlkelly `x.y.z` but it is not said that one day the depth won't increase. The solution should support recursive and nested navigation for any possible level – Alexander Cerutti May 20 '22 at 14:21
  • Do you care if the types are remapped as `prop: string | undefined` instead of `prop?: string`? – kelsny May 20 '22 at 14:23
  • I do. If they are defined as `props?: string`, they should remain the same – Alexander Cerutti May 20 '22 at 14:25
  • Alright one last thing that decides if this will be easy or not: can a prop originally defined as `string | undefined` be remapped to `prop?: string`? In other words, is the reverse allowed? – kelsny May 20 '22 at 14:28
  • Uhm, I guess that's okay for my specific use case. If you can engineer a solution that include a 1-to-1 mapping, that would be better I guess. Otherwise let's say that would be okay. The only thing I don't know is how this mapping would behave with strict mode enabled – Alexander Cerutti May 20 '22 at 14:31
  • Well [here](https://tsplay.dev/WzOn3w) is a solution that follows my last comment. I'll try to make 1-to-1 mapping next. – kelsny May 20 '22 at 14:34
  • It will prove to be challenging because it needs to work from the deepest leaves to the root, not a simple recursive type like this one. – kelsny May 20 '22 at 14:42
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/244906/discussion-between-catgirlkelly-and-alexander-cerutti). – kelsny May 20 '22 at 14:46
  • 2
    This kind of deeply nested object transformation always brings up loads of edge cases, where either the asker hasn't considered what should happen or where the asker and potential answerers have different assumptions about what should happen. Therefore I ask you to test any solution presented against all your use cases and if anything is different from what you need, [edit] the question to explicitly state the requirement. – jcalz May 20 '22 at 15:14
  • 2
    For example, does [this approach](https://tsplay.dev/wEvZkN) meet your needs? It produces the same type as `ExpandedInterface`, but it's not clear to me what you want to see for other cases. Optional properties, index signatures, properties which are unions of primitives and objects, keys that collide with others (e.g., `{foo: string, "foo.bar": number}`), etc., etc., etc. Check it out, and update the question to spell out any currently unstated requirement. – jcalz May 20 '22 at 15:17
  • @jcalz You are right about the edge-cases. I've edited my post to explicit better the use case. I'm going to give a look at the approach you suggested and answer you asap. Let me know if, meanwhile, you have more use cases in mind that could alter your solution. – Alexander Cerutti May 20 '22 at 15:47
  • @jcalz your solutions seems to fit very well, but I have to understand better how it works! Will you send it as a answer? – Alexander Cerutti May 20 '22 at 15:55
  • Yes I am happy to write up an answer explaining how it works. Just triple check that you don't see a discrepancy between my solution and your desired use cases, because I would be unhappy if I wrote up a whole thing just to realize it doesn't actually suffice. I will come back in a bit to see. – jcalz May 20 '22 at 15:56
  • @jcalz That seems to match perfectly to my current use case (I just tested on it). I mean, it even highlighted missing keys! :D So I guess you can go. Also, I'm getting out of work right now, so I cannot test any further :/ – Alexander Cerutti May 20 '22 at 16:04

2 Answers2

2

This sort of deep object type processing is always full of edge cases. I'm going to present one possible approach for Expand<T> which meets the needs of the asker of the question, but any others that come across this question should be careful to test any solution shown against their use cases.

All such answers are sure to use recursive conditional types where Expand<T> is defined in terms of objects whose properties are themselves written in terms of Expand<T>.

For clarity, I will split the definition up into some helper types before proceeding to Expand<T> itself.


First we want to filter and transform unions of string literal types depending on the location and presence of the first dot character (".") in the string:

type BeforeDot<T extends PropertyKey> =
  T extends `${infer F}.${string}` ? F : never;
type AfterDot<T extends PropertyKey> =
  T extends `${string}.${infer R}` ? R : never;
type NoDot<T extends PropertyKey> =
  T extends `${string}.${string}` ? never : T;

type TestKeys = "abc.def" | "ghi.jkl" | "mno" | "pqr" | "stu.vwx.yz";
type TKBD = BeforeDot<TestKeys> // "abc" | "ghi" | "stu"
type TKAD = AfterDot<TestKeys> //  "def" | "jkl" | "vwx.yz"
type TKND = NoDot<TestKeys> // "mno" | "pqr"

These are all using inference in template literal type. BeforeDot<T> filters T to just those strings with a dot in them, and evaluates to the parts of those strings before the first dot. AfterDot<T> is similar but it evaluates to the parts after the first dot. And NoDot<T> filters T to just those strings without a dot in them.


And we want to join to object types together into a single object type; this is basically an intersection, but where we iterate over the properties of the intersection to join them together:

type Merge<T, U> =
  (T & U) extends infer O ? { [K in keyof O]: O[K] } : never;

type TestMerge = Merge<{ a: 0, b?: 1 }, { c: 2, d?: 3 }>
// type TestMerge = { a: 0; b?: 1; c: 2; d?: 3 }

This is mostly for aesthetics; we could just use an intersection, but then the resulting types do not display very well.


And now here's Expand<T>:

type Expand<T> = T extends object ? (
  Merge<
    {
      [K in keyof T as BeforeDot<K>]-?: Expand<
        { [P in keyof Pick<T, K> as AfterDot<P>]: T[P] }
      >
    }, {
      [K in keyof T as NoDot<K>]: Expand<T[K]>
    }
  >
) : T;

Let's examine this definition in chunks... first: T extends object ? (...) : T; if T is not some object type, we don't transform it at all. For example, we want Expand<string> to just be string. So now we need to look at what happens when T is an object type.

We will break this object type into two pieces: the properties where the key contains a dot, and the properties where the key does not contain a dot. For the keys without a dot, we just want to apply Expand to all the properties. That's the {[K in keyof T as NoDot<K>]: Expand<T[K]>} part. Note that we're using key remapping via as to filter and transform the keys. Because we are iterating over K in keyof T, and not applying any mapping modifiers, this is a homomorphic mapped type which preserves the modifiers of the input properties. So for any property without a dot in the key, the output property will be optional iff the input property is optional. Same with readonly.

For the piece where the property key contains a dot, we need to do something more complicated. First, we need to transform the keys to just the part before the first dot. Hence [K in keyof T as BeforeDot<K>]. Note that if two keys K have the same initial part, like "foo.bar" and "foo.baz", these will both be collapsed to "foo", and then the type K as seen by the property value will be the union "foo.bar" | "foo.baz". So we'll need to deal with such union keys in the property value.

Next, we want the output properties not to be optional at all. If the property with a key like "foo.bar" is optional, we want only the deepest property to be optional. Something like {foo: {bar?: any}} and not {foo?: {bar: any}} or {foo?: {bar?: any}}. At least this is consistent with ExpandedInterface presented in the question. Therefore we use the -? mapping modifier. That takes care of the key, and gives us {[K in keyof T as BeforeDot<K>]-?: ...}.

As for the value of the mapped properties for keys with a dot in them, we need to transform it before recursing down with Expand. We need to replace the union of keys K with the parts after the first dot, without changing the property value types. So we need another mapped type. We could just write {[P in K as AfterDot<P>]: T[P]}, but then the mapping would not be homomorphic in T and we'd lose any optional properties. To ensure that such optional properties propagate downward, we need to make it homomorphic and of the form {[P in keyof XXXX]: ...} where XXXX has only the keys in K but the same modifiers as T. Hey, we can use the Pick<T, K> utility type for that. So {[P in keyof Pick<T, K> as AfterDot<P>]: T[P]}. And we Expand that, so Expand<{[P in keyof Pick<T, K> as AfterDot<P>]: T[P]}>.

Okay, now we have the piece with no dots in the key and the piece with dots in the key and we need to join them back together. And that's the entire Expand<T> definition:

type Expand<T> = T extends object ? (
  Merge<
    {
      [K in keyof T as BeforeDot<K>]-?: Expand<
        { [P in keyof Pick<T, K> as AfterDot<P>]: T[P] }
      >
    }, {
      [K in keyof T as NoDot<K>]: Expand<T[K]>
    }
  >
) : T;

Okay, let's test it on your MyInterface:

type ExpandMyInterface = Expand<MyInterface>;
/* type ExpandMyInterface = {
    logicGroup: {
        timeout?: number | undefined;
        serverstring?: string | undefined;
        timeout2?: number | undefined;
        networkIdentifier?: number | undefined;
        clientProfile?: string | undefined;
        testMode?: boolean | undefined;
    };
    station?: string | undefined;
    other?: {
        otherLG: {
            a1: string;
            a2: number;
            a3: boolean;
        };
        isAvailable: boolean;
    } | undefined;
} */

That looks the same, but let's make sure the compiler thinks so, via helper type called MutuallyExtends<T, U> which only accept T and U that mutually extend each other:

type MutuallyExtends<T extends U, U extends V, V = T> = void;

type TestExpandedInterface = MutuallyExtends<ExpandedInterface, ExpandMyInterface> // okay

That compiles with no error, so the compiler believes that ExpandMyInterface and ExpandedInterface are essentially the same. Hooray!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • That's what I was looking for! A great explanation of yours! I didn't know about this whole thing of homomorphic mapped types. There are two things that are not very clear to me: (1) the Mutually extension test: how does it work? Is it like to say "void | never", void if OK, never if NOT OK. Did I get it right? Or is it just about error? – Alexander Cerutti May 20 '22 at 20:19
  • (2) "-?" modifier. I mean, you said `we want only the deepest property to be optional`, but I said the opposite in the second "edit" paragraph (`Properties can be optional ... and we can assume that if all the properties in logicGroup are optional, logicGroup itself can be optional`). So the `{ foo?: { bar?: any } }` is good for me. Now I feel like I should have added another example to represent this second paragraph, 'cause what you say is consistent with the interface, as you say, but that is not what I wrote in that paragraph... or is there something am I missing? – Alexander Cerutti May 20 '22 at 20:23
  • Another thing about "-?": what do you mean with `Therefore we use the -? mapping modifier. That takes care of the key, and gives us {[K in keyof T as BeforeDot]?: ...}.` ? Doesn't the modifier actually remove optionality? I mean, this seems a bit conflicting – Alexander Cerutti May 20 '22 at 20:25
  • 1
    `MutuallyExtends` is just a test, and the `void` is not important. It's the constraints. If you can write `MutuallyExtends` and there's no error, then the compiler thinks that `X extends Y` and `Y extends X`. – jcalz May 20 '22 at 20:26
  • Yes, you wrote mutually inconsistent things in your question, and I chose to keep it the way you originally asked for. Presumably if you actually wanted it to become optional if all the individual properties were also optional, you'd have changed that interface and gotten back to me to say this my approach does not meet your needs. I suspect and hope it doesn't actually matter to you what happens there. – jcalz May 20 '22 at 20:28
  • 1
    Yes, I had a typo, it's `-?`. – jcalz May 20 '22 at 20:29
  • I hoped that writing it was enough, also because I was getting out of work at that moment ahah Hm, I currently cannot say if that would be a problem. From the last test I made, it wasn't. Just in case and for information, would it be so difficult to add such support in your opinion? I'm was trying to do some attempts, but no luck Meanwhile, thank you very much @jcalz! – Alexander Cerutti May 20 '22 at 20:44
  • If it *is* a problem, then I don't know what to say. I might have to start from scratch, and then rewrite the whole explanation. I had sincerely hoped to avoid such things, which is why I asked up front about edge cases and testing. It never occurred to me that your prototypical example code with required output was something you didn't actually want. I was assuming any edge cases would show up in *other* situations. I'm currently not in the mood to go back over this; maybe I will revisit this later. – jcalz May 21 '22 at 00:42
  • Don't worry! I'm very very grateful for what you did up to now! The explanation is very helpful :) – Alexander Cerutti May 21 '22 at 12:53
1

I managed to get this to work, with help from type-fest's UnionToIntersection:

import type { UnionToIntersection } from 'type-fest';

// Helper type for optional/undefined properties
type PartialOnUndefined<T extends object> = {
    [Key in {
        [K in keyof T]: undefined extends T[K] ? never : K
    }[keyof T]]: T[Key]
} & Partial<T>;

// Not strictly necessary, but makes the result more legible
type Expand<T> = T extends ReadonlyArray<unknown>
    ? number extends T["length"]
        ? Expand<T[number]>[]
        : { [K in keyof T]: Expand<T[K]> }
    : T extends object
        ? { [K in keyof T]: Expand<T[K]> }
        : T;

export type UnwrapNested<T> = Expand<{
    [
        K in keyof T as K extends `${infer P}.${string}` ? P : K
    ]: UnionToIntersection<
        K extends `${string}.${infer C}` ? UnwrapNested<Record<C, T[K]>> : T[K]
    > extends never
        ? UnwrapNested<T[K]>
        : UnionToIntersection<
            K extends `${string}.${infer C}`
                ? PartialOnUndefined<UnwrapNested<Record<C, T[K]>>>
                : UnwrapNested<T[K]>
        >;
    }>;

Playground link for an example.

To be perfectly clear, I don't understand how or why the massive ternary in UnwrapNested works.

Bbrk24
  • 739
  • 6
  • 22
  • Super close! `logicGroup` is marked as optional, though. You also might be interested in [this](https://stackoverflow.com/questions/62262457/control-type-level-evaluation-in-typescript/72309044#72309044) type for expanding the result of `UnwrapNested` into something more readable :) – kelsny May 20 '22 at 14:41
  • I don't think `logicGroup` being optional is necessarily wrong. If you're using a library like [dottie](https://www.npmjs.com/package/dottie) and all of the `logicGroup.x` keys are missing, it's not going to add a key `logicGroup` with a value of `{}`. – Bbrk24 May 20 '22 at 14:45
  • As I was saying in the chat room, `logicGroup` to be optional is okay to me, because we don't have a way to understand if it can be optional, so we can assume that if all the properties in it are optional, it can be optional too – Alexander Cerutti May 20 '22 at 15:13
  • @Bbrk24 thank you. I was looking in the playground the type of Test and... well, that's quite weird. Also, if I try to create a typed object and to add to my first interface an optional object with a mandatory property inside it, mandatory property type is unknown... – Alexander Cerutti May 20 '22 at 15:22
  • @AlexanderCerutti I see what you mean. I've updated the definition slightly; does it work for you now? – Bbrk24 May 20 '22 at 18:22
  • I still think this answer would benefit from the utility type I linked above. [It makes the type far more readable.](https://tsplay.dev/WPZ0ZN). – kelsny May 20 '22 at 18:26
  • @catgirlkelly I see what you mean. I honestly expected TypeScript to leave the tooltip mostly as-is but just wrap it in `Expand<...>` (as I've seen that behavior from other such types). I'll edit my answer to include it. – Bbrk24 May 20 '22 at 18:30
  • I gave a look at the solution you proposed. I guess it might work as well, but I accepted jcalz's solution because it explains everything, but still I want to give you a point just because you tried to help me! Thank you very much anyway :) – Alexander Cerutti May 23 '22 at 13:21