Search code examples
typescript

Type conversion adding or removing properties


I created a type to be used in a table. This type expect table elements to have an 'id' property. Inside the table, the elements can be selected, so I created another type that expects table elements to also have a 'selected' property.

For some reason, when I extract the items from the table (sorted and filtered), I do not need the 'selected' property anymore and try to remove it with a function. However, TypeScript complains with the following error:

Type 'Omit<SelectableItem<T>, "selected">[]' is not assignable to type 'TableItem<T>[]'.  
  Type 'Omit<SelectableItem<T>, "selected">' is not assignable to type 'TableItem<T>'.   
    Type 'Omit<SelectableItem<T>, "selected">' is not assignable to type 'T'.   
      'T' could be instantiated with an arbitrary type which could be unrelated to 'Omit<SelectableItem<T>, "selected">'

Here are the types and the map functions:

export type TableItem<T> = T & {
  id: string | number;
};

export type SelectableItem<T> = TableItem<T> & { selected: boolean };

export const toSeletableItems = <T>(items: TableItem<T>[]): SelectableItem<T>[] =>
  items.map((i) => ({ ...i, selected: false }));

export const toTableItems = <T>(items: SelectableItem<T>[]): TableItem<T>[] =>
  items.map((i) => {
    const { selected, ...item } = i;
    return item; // as TableItem<T>;
  });

Can someone help me to make it work without using the hack as TableItem<T>?


Solution

  • For generic T, TypeScript cannot verify that Omit<T & {selected: boolean}, "selected"> and T are identical types. Indeed, it's not true, because T might itself have a selected property (e.g., type T = { selected: true }), in which case Omit<T & ⋯, "selected"> will definitely not have that property and the two types will be different. You would like to say that T must not have a selected property itself, but there's no way to directly represent such a constraint.

    Therefore the compiler is right to complain about item not being a valid TableItem<T>. If you allow toTableItems to be of the form

    declare const toTableItems: <T>(items: SelectableItem<T>[]) => TableItem<T>[];
    

    Then you can fool the compiler into thinking the output array has elements with selected in them, when in fact they don't:

    interface Foo {
        id: string,
        selected: true,
        name: string
    }
    const arr: Foo[] = [{ id: "a", name: "b", selected: true }]
    const x = toTableItems(arr);
    //    ^? const x: TableItem<Foo>[]
    x.map(i => i.selected.valueOf()) // <-- this will blow up at runtime
    //    ^? parameter i: TableItem<Foo>
    

    If x is TableItem<Foo>[] then every element is a Foo & {id: string | number} and therefore it has a selected property of type true. That is proven wrong at runtime.


    If you think this is unlikely to happen then you can use a type assertion as in

    const toTableItems = <T,>(items: SelectableItem<T>[]) =>
        items.map((i) => {
            const { selected, ...item } = i;
            return item as TableItem<T>;
        });
    

    but it's not a "hack", or rather, the hack is pretending you know something you don't know about item. And it turns out that unless you're very careful this is actually quite likely to be a problem for you:

    toTableItems(
        [{ id: 1, selected: true }]
    ).map(i => i.selected.valueOf()) // no compiler error
    //    ^? (parameter) i: TableItem<{ id: number; selected: true; }>
    

    The compiler inferred T to be {id: number; selected: true}. You were perhaps hoping for something like {}, but that's not how inference works. So no matter what, you'll probably need to be Omitting things.


    The actually correct typing for your function is the one the compiler would infer for you:

    const toTableItems = <T,>(items: SelectableItem<T>[]) =>
        items.map((i) => {
            const { selected, ...item } = i;
            return item;
        });
    // const toTableItems: <T>(
    //   items: SelectableItem<T>[]
    // ) => Omit<SelectableItem<T>, "selected">[]
    

    The problems above disappear when you do this:

    x.map(i => i.selected.valueOf()) // <-- compiler error!
    //           ~~~~~~~~
    // Property 'selected' does not exist on type 'Omit<SelectableItem<Foo>, "selected">'.
    
    toTableItems(
        [{ id: 1, selected: true }]
    ).map(i => i.selected.valueOf()) // compiler error!
    //           ~~~~~~~~
    //  Property 'selected' does not exist on type '{id: number}'
    

    If you care about type safety, you'll want to use a type like this. If you prefer the convenient fiction that the return type is TableItem<T>, then you'll need to assert it as such to make it clear that you're sidestepping the type system.


    Finally, note that TypeScript's type system isn't fully sound so there are always things you could do to sidestep your problem without using an explicit assertion... but the same runtime issues remain. I'd consider this cheating and not really a solution to your question. For example:

    const toTableItems = <T,>(items: SelectableItem<T>[]): TableItem<T>[] =>
        items.map((i) => {
            const j: TableItem<T> & { selected?: boolean } = { ...i };
            delete j.selected;
            return j;
        });
    

    Here we widen SelectableItem<T> to TableItem<T> & { selected?: boolean }, since it's always "safe" to widen a required property to an optional one. Then we delete the selected property (which is allowed for optional properties). Then we just say that the result is a TableItem<T> because it's always "safe" to widen a property to the omission of that property. And then by doing these "safe" things you get the compiler to happily let you do the broken thing. See this comment on microsoft/TypeScript#42479 for a way in which optional and missing properties allow you to circumvent true type safety.

    So there is almost always a "tidy" way to avoid type assertions, but that's just because TypeScript isn't fully type safe. A type assertion is the most honest way to do what you're doing, because it communicates the intent to future readers.

    Playground link to code