How to properly type an Array.map callback in TypeScript?

19,938

Typescript is right about the error in your first example. Consider if we force the compiler to accept a modified function:

function eachItem(val: number, i: number) {
    return val.toExponential(3) // only numbers will have toExponential
}
const arr = [4, "more", 6];

// eachItem will get called with numbers and string. the strings will cause a runtime error
// runtime error val.toExponential is not a function
arr.map(eachItem as any); 

So the reason typescript does not allow this behavior is that it can't be proved to be correct for any function with the signature (val: number, i: number) => string but rather it depends on the specific implementation of the function.

Now in your second example, the function could access the fields and it would not get a runtime error. The fields may be undefined but that does not matter much for this implementation. What we could do is create a type in which all possible fields in the union are optional, which is in fact another way of looking at the items in the array (just not the way typescript looks at it by default). Typescript will allow us to pass in function with such an argument, since it is safe, since all fields are optional the function would need to check before using them anyway.

interface IMoney {
    cash: number;
}

interface ICard {
    cardtype: string;
    cardnumber: string;
}

type IMix = IMoney | ICard;

const menu = [
    { cash: 566 },
    { cardtype: "credit", cardnumber: "111111111111111111" }
];

type UnionToIntersection<U> = (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never
type PartialUnion<T> = UnionToIntersection<T extends any ? Partial<T> : never>

menu.map((item: PartialUnion<IMix>, i: number) => (item.cash || item.cardtype));

See here for an explanation of UnionToIntersection (and upvote the answer that makes this possible :) )

The T extends any ? Partial<T> : never will take each member in the union (since conditional types distribute over unions) and make it Partial meaning all fields are now optional. Then we use UnionToIntersection to transform the union of partials into an intersection of partials. The result is a type with all the fields in each member of the union, but with all fields marked as partial.

Share:
19,938

Related videos on Youtube

Szalai Laci
Author by

Szalai Laci

Updated on September 15, 2022

Comments

  • Szalai Laci
    Szalai Laci over 1 year

    I'm using React and TypeScript. When I'm iterating through an array using map(), it seems the types are not always checked. In the following example I pass a string "more" into the eachItem function, where number is expected. But no error is thrown:

    function eachItem(val: number, i: number) {
      return Number(val) / 2;
    }
    const arr = [4, "more", 6];
    arr.map(eachItem);
    

    I think I understand why this happens, but my question is how I can make strict typing on a map function, so that I get this error when passing in the string:

    Argument of type 'string' is not assignable to parameter of type 'number'

    Why no error is thrown is because I'm giving a callback to the map(). This map() expects a callback of this type:

    '(value: string | number, index: number, array: (string | number)[]) => Element'
    

    and I'm giving a callback of this type:

    '(item: number, i: number) => Element'
    

    So instead of checking whether 'number' includes 'string | number', TypeScript checks whether 'string | number' includes 'number' and finds it true. My logic says we need an error, since I'm passing "more" where only numbers are permitted. TypeScript's logic says no error, I'm passing a function that allows numbers where functions that allow strings and numbers are permitted. It doesn't seem to be a big issue, but when your type is a union, you get errors when you're not supposed to. This is an example, and the resulted error:

    interface IMoney {
      cash: number;
    }
    
    interface ICard {
      cardtype: string;
      cardnumber: string;
    }
    
    type IMix = IMoney | ICard;
    
    const menu = [
      {cash: 566},
      {cardtype: "credit", cardnumber: "111111111111111111"}
    ];
    
    menu.map((item: IMix, i: number) => (item.cash || item.cardtype));
    

    Property 'cash' does not exist on type 'IMix'.
    Property 'cash' does not exist on type 'ICard'.

    I know, now I have to do the following, but then I can't express in my code that cash and cardtype exclude each other:

    interface IMix {
      cash?: number;
      cardtype?: string;
      cardnumber?: string;
    }
    
  • Szalai Laci
    Szalai Laci almost 6 years
    No. That would be a payment method that must have cardnumber, cardtype and cash. No such thing. I need either money or card.
  • Jonas Elfström
    Jonas Elfström almost 6 years
    As far as I can see Titians answer converts your union type to an intersection type so I fail to see the difference.
  • VyvIT
    VyvIT over 2 years
    In other words it something like this, right? Partial< IMoney & ICard >