1

I have the following type shape: Record<string, Record<string, unknown>>

Which for example can be typed like this:

{
    body: {
        user: {
            name: string;
            age: number;
        }
    },
    query: {
        id: string;
        strict?: boolean;
    },
    params: {
        id: number
        scope: string[]
    }, 
   
}

I would like to introduce an utility type Flatten that accepts two arguments. The first argument would be a single generic type like the type I described above. The second argument would accept a tuple or union type of top level keys of the object that was provided inside the first argument of the utility type.

So the utility type Flatten<object, 'query' | 'params' | 'body'>, where object is the object described above for the sake of simplicty, should turn it in the following type:

{
    id: number;
    strict?: boolean;
    scope: string[]
    user: {
        name: string;
        age: number;
    }
}

As you can see, the values of the top level of objects get spread in the order that was given inside the second argument. The second argument should always contain all the top level keys that are present inside the provided object of the first argument. Duplicate keys get overridden by objects that occurre in the provided order inside the second argument of the utility type.

No recursion should occurre, so the deeper nested objects stay intact. So flattening should only happen once.

How would I accomplish this?

Errol59
  • 1,227
  • 1
  • 10
  • 15
  • How many levels should be flattened? Is it always one? – wonderflame Aug 02 '23 at 16:00
  • @jcalz you are right about the consistency of the observable order. I changed my question. – Errol59 Aug 02 '23 at 16:33
  • @jcalz, good question. Properties that are optional later should be merged inside the first property. Because `a` is required in the first object but optional or a number in the second it can't be `undefined` because it is required to be defined, so `a` would become `string | number` – Errol59 Aug 02 '23 at 17:09
  • Okay, does [this approach](https://tsplay.dev/mZVpPm) meet your needs? If so I'll write up an answer explaining; if not, what am I missing? – jcalz Aug 02 '23 at 17:13
  • @jcalz, it almost works. In the last test if you turn `a` and `b` around in the order of how they should spread, `x` is only typed as a `string`. It should also be `string | number` in that case. – Errol59 Aug 02 '23 at 17:22
  • @jcalz you are right, my mistake. The example you provided is correct. – Errol59 Aug 02 '23 at 17:30
  • 1
    If there are cases where you think it doesn't work, or that your example in the question doesn't cover, then please [edit] the question to show those use cases, the expected output types, and some justification for those types... (e.g., a function that implements `MergeProperties` ends up producing, as shown, say, [here](https://tsplay.dev/NdaGkm)). ... otherwise, can I write up my answer now? – jcalz Aug 02 '23 at 17:31
  • you can write up an answer now so I can approve it :) – Errol59 Aug 02 '23 at 17:38

1 Answers1

1

I'm going to call this operation MergeProperties instead of Flatten, since Flatten might be an ambiguous term. The only way this will work is if the second parameter to MergeProperties<T, K> is a tuple of keys of T instead of a union. Unions have no inherent ordering, and any ordering you do manage to observe from them is just an implementation detail and not guaranteed to be stable (see How to transform union type to tuple type ).

Essentially you want to represent the operation of the following function:

function mergeProperties<T extends object, const K extends readonly (keyof T)[]>(
  obj: T, ...ks: K
): MergeProperties<T, K> {
  return Object.assign({}, ...ks.map(k => obj[k]));
}

where you map each key in ks to a corresponding property in obj, and then use Object.assign() or an iterated spread of those into a resulting object.

So we can think of this as composing two operations. One is MapKeysToProps<T, K>, that takes an object type T and map a tuple of keys K to the corresponding tuple of property value types:

type MapKeysToProps<T extends object, K extends readonly (keyof T)[]> =
  { [I in keyof K]: T[K[I]] };

And the other is Spread<T>, that takes a tuple of object types T and produces the type you'd get if you spread each object type in order. This type is fairly complicated to write, since there are weird situations with optional properties, but luckily I don't have to discuss that here. I can just point you to Typescript, merge object types? and use the Spread<T> type in the answer from there. In case it matters, it looks like this:

type OptionalPropertyNames<T> = {
  [K in keyof T]-?: ({} extends { [P in K]: T[K] } ? K : never)
}[keyof T];

type SpreadProperties<L, R, K extends keyof L & keyof R> =
  { [P in K]: L[P] | Exclude<R[P], undefined> };

type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never

type SpreadTwo<L, R> = Id<
  & Pick<L, Exclude<keyof L, keyof R>>
  & Pick<R, Exclude<keyof R, OptionalPropertyNames<R>>>
  & Pick<R, Exclude<OptionalPropertyNames<R>, keyof L>>
  & SpreadProperties<L, R, OptionalPropertyNames<R> & keyof L>
>;

type Spread<A extends readonly [...any]> =
  A extends readonly [infer L, ...infer R] ?
  SpreadTwo<L, Spread<R>> : unknown

Yuck. Please look at the other question/answer for explanations of how those pieces work, if you care/dare.


Anyway, armed with those, we can define MergeProperties<T, K> simply as:

type MergeProperties<T extends object, K extends readonly (keyof T)[]> =
  Spread<MapKeysToProps<T, K>>

Let's test it on your example:

type Input = {
  body: {
    user: { name: string;  age: number; },
  },
  query: {
    id: string;
    strict?: boolean;
  },
  params: {
    id: number
    scope: string[]
  },
}

type Output = MergeProperties<Input, ["query", "params", "body"]>;
/* type Output = {
    strict?: boolean | undefined;
    id: number;
    scope: string[];
    user: { name: string; age: number; };
} */

Looks good!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360