typescripttype-level-computation

Mapping object type but properties types are not the same


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!


Solution

  • 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