Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Each member of the union type has signatures, but none of those signatures are compatible with each other #33591

Closed
ackvf opened this issue Sep 25, 2019 · 15 comments
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed

Comments

@ackvf
Copy link

ackvf commented Sep 25, 2019

TypeScript Version: 3.6.3

Search Terms:
array.map, expression is not callable, union type signatures,

Code

The following code is the exact same code as in this codesandbox
https://codesandbox.io/s/lingering-sun-7itgj

interface Product_result {
  __typename: 'Product'
  id: number
  description: string | null
}
interface Campaign_result {
  __typename: 'Campaign'
  id: number
  products: (Product_result | null)[] | null
}


interface Product {
  id: number
  description: string
  specific: boolean
}
interface Campaign {
  products: Product[]
}

type CompoundType = Campaign_result | Campaign

/* --- */

const props: { campaign?: Campaign_result } = {}
const emptyCampaign: Campaign = {
  products: []
}

const initialData: CompoundType = props.campaign || emptyCampaign


/*
Cannot invoke an expression whose type lacks a call signature. Type '
    (<U>(callbackfn: (value: Product_result, index: number, array: Product_result[]) => U, thisArg?: any) => U[])
  | (<U>(callbackfn: (value: Product, index: number, array: Product[]) => U, thisArg?: any) => U[])
' has no compatible call signatures.

product inside map is any, but I know `id` is there
*/
initialData.products.map(product => product.id)


// product is { description: string, id: number } - as expected
const product = initialData.products[0] 

// product inside map is { description: string, id: number } - as expected
;(initialData.products as Array<CompoundType['products'][0]>).map(product => product.id)


/* interestingly */

type T01 = Array<CompoundType['products'][0]>   // T01: (Product_result | Product)[]  -  I can actually have mix of both. Products that were fetched and Products added via a Form
type T02 = CompoundType['products']             // T02: Product_result[] | Product[]

declare const v1: T01
v1.map(product => product) // OK

declare const v2: T02
v2.map(product => product) // NOK

Expected behavior:

I know id is there, so this should work
initialData.products.map(product => product.id)

This workaround works
(initialData.products as Array<CompoundType['products'][0]>).map(product => product.id)

Actual behavior:

The map gives a non compatible call signature error (as in the snippet above), and the product inside is any.

Related
TypeScript 3.3 - Improved behavior for calling union types
https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-3.html#caveats

@jack-williams
Copy link
Collaborator

I think this is being tracked here #7294.

@RyanCavanaugh RyanCavanaugh added the Design Limitation Constraints of the existing architecture prevent this from being fixed label Sep 25, 2019
@RyanCavanaugh
Copy link
Member

We don't have any way to check this in a way that's well-bounded in terms of analysis time.

Consider if you had written something like this:

const arr1: string[] | number[] | boolean[] = [];
const arr2: string[] | number[] | boolean[] = [];
const arr3: string[] | number[] | boolean[] = [];

declare function fn(a: string, b: number, c: boolean): void;
declare function fn(a: number, b: number, c: string): void;
declare function fn(a: string, b: boolean, c: boolean): void;
declare function fn(a: number, b: number, c: string): void;

arr1.forEach(a => {
    arr2.forEach(b => {
        arr3.forEach(c => {
            // arr: ????
            const arr = [a, b, c] as const;
            fn(arr[0], arr[1], arr[2]);
        });
    });
});

The only way to correctly check this program is to re-check the body of the inner body 3 * 3 * 3 times (!), and TS would need to clear its cache of expression types on each invocation (something which is currently architecturally impossible). It's not even clear what we'd show for arr's type here!

A typical suggestion is some sort of merging of argument types into unions, but this only works in some cases (it's OK for map but doesn't make sense for filter, for example).

@ackvf
Copy link
Author

ackvf commented Sep 30, 2019

I see, that's understandable. So, is there an official best-practice™ workaround for this or should I resort to manually extract the types like this?

type T01 = Array<CompoundType['products'][0]>

@whizsid
Copy link

whizsid commented Mar 27, 2020

I have solved in that way.

type CompoundType = (Campaign_result | Campaign)&{products:  (Product|Product_result)[]}

ctsstc added a commit to osu-cascades/hackbot that referenced this issue Apr 24, 2020
@Ranguna
Copy link

Ranguna commented May 9, 2020

const arr1: string[] | number[] | boolean[] = [];
const arr2: string[] | number[] | boolean[] = [];
const arr3: string[] | number[] | boolean[] = [];

declare function fn(a: string, b: number, c: boolean): void;
declare function fn(a: number, b: number, c: string): void;
declare function fn(a: string, b: boolean, c: boolean): void;
declare function fn(a: number, b: number, c: string): void;

arr1.forEach(a => {
    arr2.forEach(b => {
        arr3.forEach(c => {
            // arr: ????
            const arr = [a, b, c] as const;
            fn(arr[0], arr[1], arr[2]);
        });
    });
});

It's not even clear what we'd show for arr's type here!

I'm probably missing something here, but to me arr would be [string | number | boolean, string | number | boolean, string | number | boolean]. This would fail to compile because the first parameter of fn doesn't accept a boolean value.

I think #29011 solved exactly that and #31023 should solve remain issues with function overloads (such as .map).

@Fleuv
Copy link

Fleuv commented May 25, 2020

I also experienced this issue with the following use case.

TypeScript Version: 3.9.2

Code

interface Base {
    id: number;
}

interface Book extends Base {
    title: string;
    author: Author;
}

interface Author extends Base {
    name: string;
}

interface Models {
    books: Book[];
    authors: Author[];
}

const author1: Author = {
    id: 1,
    name: 'Foo Bar',
};

const data : Models = {
    books: [
        {
            id: 1,
            title: "hello world",
            author: author1,
        }
    ],
    authors: [
        author1,
    ],
};

// This does work
function getBookById(id: number): Book | undefined {
    return data.books.filter((item: Base) => item.id = id)[0];
}

// This is more generic but does not work
/**
 * Error message:
 * 
 * This expression is not callable. Each member of the union type '
 * { 
 *      <S extends Book>
 *          (callbackfn: (value: Book, index: number, array: Book[]) => value is S, thisArg?: any): S[];
 *          (callbackfn: (value: Book, index: number, array: Book[]) => unknown, thisArg?: any): Book[];
 * } | { ...; }
 * ' has signatures, but none of those signatures are compatible with each other.
 */
function getById<T extends Base>(type: keyof Models, id: number): T | undefined {
    return data[type].filter((item: Base) => item.id = id)[0];
}

Playground Link

@matAtWork
Copy link

And here's another really simple illustration:

interface A {
    a: number;
}
interface B {
    b: string;
}

const c: A[] | (A | B)[] = [];

c.map(z => z) // <- Error
c.forEach(z => z) // <- OK

Note that forEach works here, so with a closure & a little refactor, you can make this work in practice

@jeje42
Copy link

jeje42 commented Aug 10, 2020

Hi, I also have the same problem as @Fleuv here with typescript 3.9.7. Can I ask what is the status of this issue and why it is closed ?

@AndyOGo
Copy link

AndyOGo commented Jan 21, 2021

Same Problem here with Array.prototype.find()

@SephReed
Copy link

SephReed commented Feb 26, 2021

tl;dr: to get around this issue -> flatten your types to fit through whichever generic is the pigeon-hole, then use "type predicates" to make them whole again


Understanding the Problem

Alright, so after poking around for a bit, I've come to figure out what the heck is going on.

Basically (Array<Foo | Bar>) and (Foo[] | Bar[]) are very different things as far as typescript is concerned:

type Foo = { foo: boolean };
type Bar = { bar: number };

// this works just fine
const mappable = [] as Array<Foo | Bar>;
mappable.map(it => it);

// this one can not be mapped
const cannotMap = [] as (Foo[] | Bar[]);
cannotMap.map(it => it); 
// ^^^^^ ERROR: Each member of the union type ... has signatures, but none of those signatures are compatible with each other

// but you can use **non-generic** functions on either... kinda
mappable.forEach(it => null);
cannotMap.forEach(it => null);
// ^^^ ERROR: Parameter 'it' implicitly has an 'any' type.

Typescript Playground Demo Link


Subjective Digression

So, obviously this is kind of ridiculous. As far as any developer is concerned, it would be good enough to be able to map cannotMap and get slightly false typing on the items as (item: Foo | Bar) => any. In reality, the function should be ((item: Foo) => any) | ((item: Bar) => any)..... whew! really saving us from ourselves!

And -- unfortunately -- I've seen enough CLOSED typescript issues to know that this is never going to be changed. It's pedantic and dumb, so they'll say it's for the sake of not being inaccurate. Then on some other issue they'll shirk the whole "preserve runtime behavior of all JavaScript code" accuracy thing, and say that being an actual superset of javascript is too pedantic. So we're just going to have to work around this one.


The Fix

This sucks, BUT: the best way to fix this issue is to just flatten your types, then create type checkers to turn them back into what they're suppose to be. Something like the following:

type Foo = { foo: boolean };
type Bar = { bar: number };

type FooBar = Foo | Bar;

function isFoo(checkMe: FooBar): checkMe is Foo {
    return "foo" in checkMe;
}

function isBar(checkMe: FooBar): checkMe is Bar {
    return "bar" in checkMe;
}

const lame = [] as Array<FooBar>;
lame.map((it) => {
    if (isFoo(it)) {
        it.foo
    } else {
        it.bar
    }
})

Typescript Playground Link

It's a super ugly solution in practice. You have to flatten entire complex types into something that never realistically exists just so it'll fit through the pigeonhole that is ts(2349). But it works, so be it.


Subjective Suggestion

I don't expect anyone with sway to be reading this, but on the off-chance such a thing should happen, I have a suggestion for making everyone happy; something like this:

// tsconfig.json. NOT REAL CODE.  Copy and Paste at your own disappointment.
{
  "compilerOptions": {
    "genericUnionStyle": "entireGenerics" | "genericArguments" = "entireGenerics",
  }
}

Pretty self explanatory, but for completenesses sake:

  • the default remains the same as it is today. nothing changes for the standard user.
  • the new union style would solve the issue of Array<Foo | Bar> being usable, while (Foo[] | Bar[]) is totally broken
// in action, to union the following...
type noLongerBrokenType = (Map<void, null> | Map<number, string);

// you'd end up with this
type sameAsAbove = Map<void | number, null | string>;

And even though this isn't perfectly accurate, it being accurate wouldn't really change the way anyone codes AFAICT. Either way you're going to have to check types. And given that the current way things are you can't even get to the point of checking types, it's not really helping anyone.

So why not give us the option to be able to move forwards at a tiny fraction of loss of accuracy? Surely that's better than everyone having to cannibalize their types just to put them back together on the other side of this error.

Hope this helps someone. <3

@luisanton-io
Copy link

luisanton-io commented May 16, 2021

So, obviously this is kind of ridiculous. As far as any developer is concerned, it would be good enough to be able to map cannotMap and get slightly false typing on the items as (item: Foo | Bar) => any. In reality, the function should be ((item: Foo) => any) | ((item: Bar) => any)..... whew! really saving us from ourselves!

Hope this helps someone. <3

I had an array foo of type A[] or B[].

const foo: A[] | B [] = [...]

When mapping it, this error was showing up.
I had to declare

const _foo: Array<A | B> = foo

and then map _foo.

Thank you so much!

@bombillazo
Copy link

@luisanton-io Marvelous! That worked for me.

@aiham
Copy link

aiham commented Oct 20, 2023

For my use case (A | B)[] and (A[] | B[]) are equivalent so I was happy to workaround it with:

const items = generateVariousItems();
const typedItems: ((typeof items)[number])[] = items;
const result = typedItems.find(item => item.commonField === 'foobar');

This way the types remain inferred from generateVariousItems instead of redefining them.

@scffs
Copy link

scffs commented Jan 31, 2024

I was really surprised when I fix this error by replace A[] | B[] with Array<A | B>

@nicknish
Copy link

nicknish commented Feb 8, 2024

Hey folks, stumbled upon the same issue. I believe this is fixed in TypeScript 5.2:

https://devblogs.microsoft.com/typescript/announcing-typescript-5-2/#easier-method-usage-for-unions-of-arrays

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed
Projects
None yet
Development

No branches or pull requests