Metabolic
Metabolic

Reputation: 2914

object[] | object[] type lacks a call signature for 'find(),foreach()'

I have two array variables with following interface:

export interface IShop {
  name: string,
  id:   number,
  type: string,   
}

export interface IHotel {
  name: string,
  id:   number,
  rooms: number,   
}

My typescript code is as following:

let shops: IShop[];
let hotels: IHotel[];
//these variables then gets assigned respective data from an API matching the interfaces

const allRegions = shops.length > 0 ? shops : (hotels.length > 0 ? hotels : []);

allRegions.find(r => r.name === 'name');

at the last line, I get error saying:

Cannot invoke an expression whose type lacks a call signature. Type '{ (predicate: (this: void, value: IShop, index: number, obj: IShop[]) => value is S, thisArg?: any): S; (predicate: (value: IShop, index: number, obj: IShop[]) => boolean, thisArg?: any): IShop; } | { ...; }' has no compatible call signatures.

Same is happening for other Array methods during compilation, though code works fine and I know what the issue means but I am not clear on why Typscript is not recognizing the Array.

On typechecking allRegion, i get IShop[] | IHotel[] both of which are clearly arrays, is there something wrong with the datatype of allRegion?

Upvotes: 2

Views: 794

Answers (2)

VLAZ
VLAZ

Reputation: 29096

Problem with method signature merging for union of arrays

The reason TypeScript complains is because with a type of IShop[] | IHotel[] it will merge all method signatures. In particular the signatures:

Array<IShop>.find(
    predicate: (
        value: IShop, 
        index: number, 
        obj: IShop[]
    ) => unknown, thisArg?: any
): IShop | undefined

Array<IHotel>.find(
    predicate: (
        value: IHotel, 
        index: number, 
        obj: IHotel[]
    ) => unknown, thisArg?: any
): IHotel | undefined

Effectively becomes something similar to:

Array<IShop & IHotel>.find(
    predicate: (
        value: IShop & IHotel, 
        index: number,
        obj: (IShop & IHotel)[]
    ) => unknown, thisArg?: any
): IShop & IHotel | undefined

This means that in order to call it, the callback should accept an item that's both IShop and IHotel at the same time and also will produce both an IShop and IHotel at the same time.

That's not actually possible, thus the compiler concludes that as the type signature is unsatisfiable, it is also uncallable.

This is a bit of a weakness in the way of how the method signatures are merged. It is the correct way to merge the signatures but for many use cases, the resulting types are not what you actually need, nor is the method call unsatisfiable. It's more limited in what can satisfy it but definitely not impossible:

let shops = [{name: "shop1", id: 1, type: "supermarket"}];
let hotels = [{name: "hotel1", id: 2, rooms: 42}];

// see addendum
const allRegions = shops.length > 0 ? shops : hotels;

const result = allRegions.find(r => r.name === 'shop1');

console.log(result);

The issue is that this serves a more localised case and not the more general case where for any variation of calling the method.

The way to go around it is to use explicit typing which will allow you to retain type safety but you have to slightly override the compiler's decision.

Possible solutions

Change from a union of arrays, to an array of union type

Since IShop[] | IHotel[] (an array of IShop or array of IHotel) causes method signature merges that is uncallable, we can change the type to (IShop | IHotel)[] (an array of IShop and IHotel items). This is slightly incorrect, as you don't have a mixed array. However, there is almost no difference in practice. You still need to know what each item is, so it's very similar to having an array of either type.

What makes it work is that IShop | IHotel will allow you to use the shared properties between the two interfaces. In this case, name and id. Therefore, TypeScript will allow the call like allRegions.find(r => r.name === 'name').

const allRegions: (IShop | IHotel)[]  = shops.length > 0 ? shops : hotels;

allRegions.find(r => r.name === 'name'); //allowed

Playground Link

Introduce a super type

Very similar to the above but you'd need to change your types:

interface IDataItem {
  name: string,
  id:   number,
}

export interface IShop extends DataItem {
  type: string,   
}

export interface IHotel extends IDataItem {
  rooms: number,   
}

This is extracting the shared properties to an interface and then both IShop and IHotel extend it. This way you can more directly say that allRegions will contain the supertype. The result is essentially the same as the union type IShop | IHotel but made more explicit.

const allRegions: IDataItem[]  = shops.length > 0 ? shops : hotels;

allRegions.find(r => r.name === 'name'); //allowed

Playground Link

If your data is actually related it might be preferable to represent that in your types. The type union does not convey the information about the relation. However, this still requires you to be able to change the types. If that's not a possibility, then a type union is the better option.

Create a new union that will ensure usable array methods

As a brilliant suggestion in a comment from Linda Paiste:

it's possible to declare const allRegions: (IShop[] | IHotel[]) & (IShop | IHotel)[] so that we get the union signature without losing the restriction that the array elements are of the same type.

Which will give you this:

const allRegions: (IShop[] | IHotel[]) & (IShop | IHotel)[] = shops.length > 0 ? shops : hotels;

allRegions.find(r => r.name === 'name'); //allowed

Playground Link

This is an intersection between two homogenous arrays and a mixed array.

This declaration resolves to (IShop[] & (IShop | IHotel)[]) | (IHotel[] & (IShop | IHotel)[]) which is a union of

  • homogenous IShop array intersected with a mixed IShop | IHotel array
  • homogenous IHotel array intersected with a mixed IShop | IHotel array

The brilliant part is that it behaves exactly the same as IShop[] | IHotel[] - you cannot have a mix. However, at the same time, the type will ensure the method declaration merge works correctly. This means that you get correct type checks for arrays that only have one type of item in them but not mixed:

declare let shops: IShop[];
declare let hotels: IHotel[];
//mixed array
declare let mixed: (IShop | IHotel)[];
//homogenous array of either type
declare let improved: (IShop[] | IHotel[]) & (IShop | IHotel)[];

//something that takes a homogenous array
declare function foo(x: IShop[] | IHotel[]): void;

foo(shops);    //ok
foo(hotels);   //ok
foo(mixed);    //error
foo(improved); //ok

Playground Link

Addendum: clarifying with allRegions initialisation

The line const allRegions = shops.length > 0 ? shops : (hotels.length > 0 ? hotels : []) is superfluous. You only assign an empty array to allRegions is hotels is an empty array (and shops too). Since an empty array is an empty array either case, you can shorten this to const allRegions = shops.length > 0 ? shops : hotels - if hotels is empty, you an empty array anyway. This is what I've used in the code samples above as it makes the code a lot easier to read.

It has the exact same effect as long as you don't plan on mutating the array in-place. That might modify the wrong array.

Upvotes: 3

B45i
B45i

Reputation: 2611

Add Explicit typing to allRegions, You are getting this error because ht empty array that you are passing at the end of the condition don't have any type.

const allRegions: IShop[] |  IHotel[] | any[]  = shops.length > 0 ? shops : (hotels.length > 0 ? hotels : []);

Fiddle

Upvotes: -2

Related Questions