Search code examples
typescript

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>

Solution

  • 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