Question

Typings to flatten an object based on its id and label properties

Playground

I want to flatten an object with the following shape

{
  article: 'prova',
  id: 63,
  topology: { id: 'topId', label: 'topLabel' },
  something: { id: 'someId', label: 'someLabel' }
}

into something with the following one

{
  article: "prova",
  id: 63,
  topId: "topLabel",
  someId: "someLabel"
}

Both the Input and the Output types must satisfy strict typings. Basically the following must hold true:

interface Input {
  article: string
  id: number
  abc: { id: string; label: string }
  def: { id: string; label: string }
}
interface Output {
  article: string
  id: number
  topId: string
  someId: string
}
const input1: Input = {
  article: 'prova',
  id: 63,
  abc: { id: 'topId', label: 'topLabel' },
  def: { id: 'someId', label: 'someLabel' }
}
const input2 = {
  article: 'prova',
  id: 63,
  abc: { id: 'topId', label: 'topLabel' },
  def: { id: 'someId', label: 'someLabel' }
}
// Type 'Flattened<string | number | null | undefined, { id: string; label: string; }, Record<string, string | number | { id: string; label: string; } | null | undefined>>' is missing the following properties from type 'Output': article, id, topId, someId
// Argument of type 'Input' is not assignable to parameter of type 'Record<string, string | number | { id: string; label: string; } | null | undefined>'.
// Index signature for type 'string' is missing in type 'Input'.
const output1: Output = flattenObject(input1)
// Type 'Flattened<string | number | null | undefined, { id: string; label: string; }, { article: string; id: number; abc: { id: string; label: string; }; def: { id: string; label: string; }; }>' is missing the following properties from type 'Output': topId, someId
const output2: Output = flattenObject(input2)

I've tried this tentative implementation but TypeScript isn't happy:

function hasId<
  K extends string | number | undefined | null,
  T extends { id: string; label: string }
>(el: [string, K | T]): el is [string, T] {
  return (el[1] as T).id != null
}

type Flattened<
  K extends string | number | undefined | null,
  T extends { id: string; label: string },
  S extends Record<string, K | T>
> = {
  [key in keyof S as S[key] extends T ? S[key]['id'] : key]: S[key] extends T
    ? S[key]['label']
    : S[key]
}

const flattenObject = <
  K extends string | number | undefined | null,
  T extends { id: string; label: string },
  S extends Record<string, K | T>
>(
  obj: S
): Flattened<K, T, S> =>
  Object.fromEntries(
    Object.entries(obj).map<[string, K | string]>((el) =>
      hasId(el) ? [el[1].id, el[1].label] : (el as [string, K])
    )
  ) as Flattened<K, T, S>
 3  50  3
1 Jan 1970

Solution

 0

IMPORTANT CAVEAT: If you require that the input be annotated as type Input as in

interface Input {
  article: string
  id: number
  abc: { id: string; label: string }
  def: { id: string; label: string }
}
const input: Input = { ⋯ };

then it is completely impossible for

const output: Output = flattenObject(input);

to work directly. TypeScript only knows that input above is of type Input, and so the type of input.abc.id is just string, and so is the type of input.def.id. Any information about the values "topId" and "someId" has been lost. The best you'll get is flattenObject(input) to produce a value of a type like {article: string; id: number} & {[x: string]: string}, which isn't sufficient.

If you want this to work, you must let TypeScript know that input.abc.id is of the literal type "topId" and that input.def.id is of the literal type type "someId". You could either do this by modifying Input to be

interface Input {
  article: string
  id: number
  abc: { id: "topId"; label: string }
  def: { id: "someId"; label: string }
}

or by allowing input to be of a narrower type than Input like

const input = { ⋯ } as const satisfies Input;

which uses a const assertion to keep track of the literal types of all string values in the object literal, and the satisfies operator to make sure it's still assignable to Input.

For the rest of this answer I assume you can do that.


I'd write Flattened<T> and flattenObject like

type Flattened<T extends object> =
  { [K in keyof T as T[K] extends IdLabel ? T[K]["id"] : K]:
    T[K] extends IdLabel ? T[K]["label"] : T[K]
  }

interface IdLabel { id: string; label: string }

declare const flattenObject: <T extends object>(
  obj: T
) => Flattened<T>

Essentially, this is a key remapped mapped type the property of T at each key K is checked to see if its an IdLabel or not. If it's an IdLabel then the key is remapped to the id property and the value is mapped to the label property. Otherwise the key and value are left alone.

The actual implementation of flattenObject is probably not in question here, but for completeness, you could write

function isIdLabel(x: any): x is IdLabel {
  return !!x && (typeof x === "object") && ("id" in x) && 
    (typeof x.id === "string") && ("label" in x) && 
    (typeof x.label === "string");
}

const flattenObject = <T extends object>(
  obj: T
): Flattened<T> =>
  Object.fromEntries(Object.entries(obj).map(
    ([k, v]) => isIdLabel(v) ? [v.id, v.label] : [k, v])
  ) as any

Let's test it:

const input = {
  article: 'prova',
  id: 63,
  abc: { id: 'topId', label: 'topLabel' },
  def: { id: 'someId', label: 'someLabel' }
} as const satisfies Input;

/* const input: {
    readonly article: "prova";
    readonly id: 63;
    readonly abc: {
        readonly id: "topId";
        readonly label: "topLabel";
    };
    readonly def: {
        readonly id: "someId";
        readonly label: "someLabel";
    };
} */

const output = flattenObject(input) satisfies Output; 
/* const output: {
    readonly article: "prova";
    readonly id: 63;
    readonly topId: "topLabel";
    readonly someId: "someLabel";
} */

Looks good. The type of output is an appropriately Flattened version of the type of input.

Playground link to code

2024-07-19
jcalz