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

Array.isArray type narrows to any[] for ReadonlyArray<T> #17002

Open
jinder opened this issue Jul 7, 2017 · 48 comments · Fixed by #39258 · May be fixed by #48228 or #49855
Open

Array.isArray type narrows to any[] for ReadonlyArray<T> #17002

jinder opened this issue Jul 7, 2017 · 48 comments · Fixed by #39258 · May be fixed by #48228 or #49855
Labels
Domain: lib.d.ts The issue relates to the different libraries shipped with TypeScript Fix Available A PR has been opened for this issue In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@jinder
Copy link

jinder commented Jul 7, 2017

TypeScript Version: 2.4.1

Code

declare interface T {
  foo: string;
}

const immutable: T | ReadonlyArray<T> = [];

if (Array.isArray(immutable)) {
  const x = immutable; // Any[]  - Should be ReadonlyArray<T>
} 

const mutable: T | Array<T> = [];

if (Array.isArray(mutable)) {
  const x = mutable; // T[]
} 

Expected behavior: Should type narrow to ReadonlyArray<T>, or at the very least T[].

Actual behavior: Narrows to any[]. Doesn't trigger warnings in noImplicitAny mode either.

@jcalz
Copy link
Contributor

jcalz commented Jul 7, 2017

If you add the following declaration to overload the declaration of isArray():

interface ArrayConstructor {
    isArray(arg: ReadonlyArray<any> | any): arg is ReadonlyArray<any>    
}

the post-checked mutable is still narrowed to T[] and the post-checked immutable is narrowed to ReadonlyArray<T> as desired.

I'm not sure if this or similar would be an acceptable addition to the standard typing libraries or not, but you can at least use it yourself.

@jinder
Copy link
Author

jinder commented Jul 7, 2017

@jcalz yes, I think these overloads should be in the standard library. There are a few other scenarios where ReadonlyArray isn't accepted where it should be (for example Array.concat).

@vidartf
Copy link

vidartf commented Jul 10, 2017

The same narrowing problem also exists for this check:

if (immutable instanceof Array) {
  const x = immutable; // Any[]  - Should be ReadonlyArray<T>
} 

I'm not sure if there exists a similar workaround for this.

@jcalz
Copy link
Contributor

jcalz commented Jul 10, 2017

I would probably do this if I wanted a workaround:

interface ReadonlyArrayConstructor  {
    new(arrayLength?: number): ReadonlyArray<any>;
    new <T>(arrayLength: number): ReadonlyArray<T>;
    new <T>(...items: T[]): ReadonlyArray<T>;
    (arrayLength?: number): ReadonlyArray<any>;
    <T>(arrayLength: number): ReadonlyArray<T>;
    <T>(...items: T[]): ReadonlyArray<T>;
    isArray(arg: any): arg is ReadonlyArray<any>;
    readonly prototype: ReadonlyArray<any>;
}
const ReadonlyArray = Array as ReadonlyArrayConstructor;

And then later

if (ReadonlyArray.isArray(immutable)) {
  const x = immutable; // ReadonlyArray<T>
} 
if (immutable instanceof ReadonlyArray) {
  const x = immutable; //  ReadonlyArray<T>
} 

but of course, since at runtime there's no way to tell the difference between ReadonlyArray and Array, you would have to be careful to use the right one in the right places in your code. ¯\_(ツ)_/¯

@jinder
Copy link
Author

jinder commented Jul 10, 2017

@vidartf As instanceof works at runtime, I'm not sure that should narrow to ReadonlyArray. You're asking if the runtime representation of that is an array, which is true. So I'd expect it to narrow to Array<T>, not ReadonlyArray<T>.

@vidartf
Copy link

vidartf commented Jul 10, 2017

@jinder I didn't state it explicitly, but my code was meant to be based on yours (same variables and types), so it should already know that it was T | ReadonlyArray<T>. As such, instanceof should narrow it to ReadonlyArray<T>.

@mhegazy mhegazy added Suggestion An idea for TypeScript In Discussion Not yet reached consensus Domain: lib.d.ts The issue relates to the different libraries shipped with TypeScript labels Aug 22, 2017
@NN---
Copy link

NN--- commented Sep 20, 2017

What is the best workaround here ?
I just ended up with ugly type assertion:

function g(x: number) {}

function f(x: number | ReadonlyArray<number>) {
    if (!Array.isArray(x)) { 
        g(x as number); // :(
    }
}

@adrianheine
Copy link

I think this is fixed in 3.0.3.

@jinder
Copy link
Author

jinder commented Sep 18, 2018

@adrianheine doesn't seem to be for me.

@adrianheine
Copy link

Oh, yeah, I was expecting the code in the original issue to not compile, bu that's not even the issue.

@bluelovers
Copy link
Contributor

let command: readonly string[] | string;

let cached_command: Record<string, any>;

if (Array.isArray(command))
{

}
else
{
	cached_command[command] = 1;
	// => Error: TS2538: Type 'readonly string[]' cannot be used as an index type.
}

@mamiu
Copy link

mamiu commented May 22, 2019

Temporary solution from @aleksey-l (on stackoverflow) until the bug is fixed:

declare global {
    interface ArrayConstructor {
        isArray(arg: ReadonlyArray<any> | any): arg is ReadonlyArray<any>
    }
}

@lll000111
Copy link

lll000111 commented Jul 20, 2019

It is not only ReadonlyArray: #33700

@jwalton
Copy link

jwalton commented Jul 29, 2019

Here's a concrete example of ReadonlyArray<string> behaving differently than Array. The !Array.isArray() case fails to eliminate the ReadonlyArray<string> when narrowing.

@kambing86
Copy link

kambing86 commented Oct 1, 2019

it should be like this

interface ArrayConstructor {
  isArray(arg: unknown): arg is unknown[] | readonly unknown[];
}

and test it in typescript

const a = ['a', 'b', 'c'];
if (Array.isArray(a)) {
  console.log(a); // a is string[]
} else {
  console.log(a); // a is never
}

const b: readonly string[] = ['1', '2', '3']

if (Array.isArray(b)) {
  console.log(b); // b is readonly string[]
} else {
  console.log(b); // b is never
}

function c(val: string | string[]) {
  if (Array.isArray(val)) {
    console.log(val); // val is string[]
  }
  else {
    console.log(val); // val is string
  }
}

function d(val: string | readonly string[]) {
  if (Array.isArray(val)) {
    console.log(val); // val is readonly string[]
  }
  else {
    console.log(val); // val is string
  }
}

function e(val: string | string[] | readonly string[]) {
  if (Array.isArray(val)) {
    console.log(val); // val is string[] | readonly string[]
  }
  else {
    console.log(val); // val is string
  }
}

@MicahZoltu
Copy link
Contributor

Would a PR that adds the appropriate overload to Array.isArray be reasonable at this point? This issue is marked as In Discussion, but I'm not sure what discussion is necessary. This just feels like a mistake in the definition files, which should be an easy fix that any contributor can submit.

Proposed addition to built-in TS libraries:

interface ArrayConstructor {
    isArray(arg: ReadonlyArray<any> | any): arg is ReadonlyArray<any>    
}

@kg-currenxie
Copy link

Any news here? :|

@jcalz
Copy link
Contributor

jcalz commented Apr 12, 2022

That's just control flow analysis; the type of a has already been narrowed to number upon the assignment of 42. Control flow can't possibly make it into that console.info(a) block unless, somehow, a were both a number and an array, and hence number & any[].

Hmm, you keep editing that comment. Let me know when it stabilizes.

@huan
Copy link

huan commented Apr 12, 2022

@jcalz Thanks for pointing the control flow analysis to me!

After a few tests, I started understanding the a: number & any[] means:

  1. Because in my code above, even the a has a static typing of number | string[], but it only has been assigned a number, which means it can only be a number
  2. In the following code we tested the Array.isArray(a), which it can have to result according to the static typing and the flow control perspective:
    1. from static typing, the a has two types: string[] and number, and if we consider this, the inner block of if(Array.isArray(a)) { /* ... */ } should be string[]
    2. however, from the flow control, the a has only been assigned a number, which means it can not be an Array, so in the if(Array.isArray(a)) { /* ... */ } block, the TypeScript can not infer any array types, but it must be an array, this leads to we got an an[] type.

I'm still trying to test its behavior (you see my modification in the above issue), I feels that:

  1. if we respect the static typing, then we should follow the static typing definition, get a string[]
  2. if we respect the flow control, then we should use an unknown[] instead of any[], which will less confusing.

Here's a similar post: Question about Array.isArray() and unknown[]

Conclusion

My 2 cents will be:

  1. We should not use any[] but an unknown[] will be more suitable
  2. Or, we should use never because those two types (static & flow) have null overlap at all.

@gla23
Copy link

gla23 commented Jun 21, 2022

I couldn't get the above solutions to work for my use case so I made a local isArray function. Here it is incase others find it helpful:

export function isArray(
	arg: ReadonlyArray<any> | any
): arg is ReadonlyArray<any>;
export function isArray(arg: any): arg is any[] {
	return Array.isArray(arg);
}

@JustFly1984
Copy link

@RyanCavanaugh please bring some attention to this issue

@graphemecluster graphemecluster linked a pull request Aug 3, 2022 that will close this issue
@P-Daddy
Copy link

P-Daddy commented Aug 17, 2022

I think I've got a solution that extracts the relevant array type without breaking the any and unknown cases. Actually, I've got two solutions. The first one does slightly break compatibility in the unknown case, but in my opinion, it's a good break. But for those who would consider that unacceptable, I've also got one that maintains full compatibility.

In both cases, they're built upon the backs of the many giants who fell down this rabbit hole before me.

First, the breaking solution:

type IfUnknownOrAny<T, Y, N> = unknown extends T ? Y : N;

type ArrayType<T> = IfUnknownOrAny<
    T,
    T[] extends T ? T[] : any[] & T,
    Extract<T, readonly any[]>
>;

declare global {
    interface ArrayConstructor {
        isArray<T>(arg: T): arg is ArrayType<T>;
    }
}

The breaking change here is that if your starting type is unknown, then you'll end up with unknown[] instead of any[]. I think this is a good thing. If you're using unknown, then you're not looking for the unchecked semantics of any, and the only thing that Array.isArray has told you is that your value is an array of something. Since you don't know yet what that something is, unknown[] seems like the sensible representation.

But the behavior of the current official library does result in giving you an any[], even when you start with unknown. So for those who have dependencies on that behavior that they can't easily change, this version preserves it:

type IfUnknownOrAny<T, Y, N> = unknown extends T ? Y : N;
type IfAny<T, Y, N> = (T extends never ? 1 : 0) extends 0 ? N : Y;

type ArrayType<T> = IfUnknownOrAny<
    T,
    IfAny<T, T[] extends T ? T[] : T[] & T, any[] & T>,
    Extract<T, readonly any[]>
>;

declare global {
    interface ArrayConstructor {
        isArray<T>(arg: T): arg is ArrayType<T>;
    }
}

Static analysis confirms the types I would expect for everything I've thus far tested.

First, we have some tests showing the current behavior, which doesn't result in selecting the preferred type in some cases (primarily when the input type could be a read-only array).

Next, we have the first version above, which opts for the more correct unknown[] type when the starting type is unknown. It's also slightly simpler.

Finally, we have the compatible version that will still produce any[] when the input type is unknown.

@P-Daddy
Copy link

P-Daddy commented Oct 20, 2022

I've found an oversight in my ArrayType<T> above (either version of it). If T is object, then the resolved type is never, which is obviously not right.

const object: object = {};
if (Array.isArray(object))
    typeOf(object).is<never>("🟢 true");  // 😞

So I tried a simplified version of @graphemecluster's implementation:

type ArrayType<T> = Extract<
    true extends false & T ?
        any[] :
    T extends readonly any[] ?
        T :
    unknown[],
    T
>;

This one works correctly for all my previously tested cases, plus ones involving object.

Note that, like my first version, it converts unknown to unknown[], which is incompatible with the current production Array.isArray, which would return any in this case. I still think this is the better behavior, but it's a breaking change.

I also made a more compact, less explicit version of these tests.

@graphemecluster
Copy link
Contributor

@P-Daddy I would like to see unknown being converted to unknown[] too – but unfortunately in my trial there are too many compatibility errors related to unknown[] – see #50454 (comment). Please also see my test case files and see if I'm missing something, and do try out a workable solution yourself with a PR. Remember to deal with ArrayLike and Iterable too. My solution seems to be too complicated that is likely unacceptable by the Team.

@graphemecluster
Copy link
Contributor

@RyanCavanaugh @sandersn Suppose my solution is still not feasible and convincing enough (at least from TypeScript 4.8), has the Team ever considered using an intrinsic type? I guess it must be one of the most probable solutions for this long-standing issue.

@RyanCavanaugh
Copy link
Member

I'll bring it to the design meeting next week. This is incredibly subtle, unfortunately.

@sindresorhus
Copy link

@RyanCavanaugh Just wondering whether this was brought up on the design meeting and whether any decision was made?

@bradzacher
Copy link
Contributor

image

This is one of the more frustrating bugs to keep running into when dealing with readonly arrays.

cc @DanielRosenwasser is this something we can bring back to the table? I understand that it's not going to be a simple problem to solve broadly - but I think there's a lot of value in making this work broadly - there's a lot of usecases where people opt for a mutable array instead of a readonly array purely because you can't narrow the type.

It looks like internally TS itself is bitten by this problem, leading you guys to define your own internal signature to work around it:

/**
* Tests whether a value is an array.
*
* @internal
*/
export function isArray(value: any): value is readonly unknown[] {
// See: https://github.com/microsoft/TypeScript/issues/17002
return Array.isArray(value);
}

@P-Daddy
Copy link

P-Daddy commented Mar 21, 2023

Thanks, @bradzacher, for pinging this. I'd also love to see this addressed.

Just to underscore how nuanced it is, and sympathize with the TypeScript team for not yet providing a solution, here's a situation I ran into just yesterday, in which none of the signatures I tried for Array.isArray worked properly:

type Args<A extends readonly any[]> = A | {args: A};

function foo<A extends readonly any[]>(args: Args<A>): readonly any[] {
    if (Array.isArray(args))
        return args;

    return args.args;
}

Playground link with one example signature that fails.

For the curious, I worked around it with the signature below, which handles this particular case, but fails on a number of other ones. Because of that, I just created a local override of isArray instead of defining it this way globally:

const isArray = Array.isArray as <T extends readonly any[]>(obj: unknown) => obj is T;

@dupasj
Copy link

dupasj commented Apr 30, 2023

Hi everyone,

I was having some trouble overriding the Array.isArray type in my Typescript project. For the story, I refactored all functions/methods on one of my projects to return readonly array and not "classic" array. I noticed this morning that this broke all my existing guarding.

Thanks to @P-Daddy and @graphemecluster comments, here's the file I came up with:

type ArrayType<T> = Extract<
    true extends T & false ?
        any[] :
        T extends readonly any[] ?
            T :
            unknown[],
    T
>;

interface ArrayConstructor {
    isArray<T>(arg: T): arg is ArrayType<T>;
}

I save it in a declaration file and this resolves all my guards without breaking other functions.

Hope this can help someone !

@mjljm
Copy link

mjljm commented Jan 10, 2024

How about:

type ArrayType<T> = T extends unknown ? T extends readonly unknown[] ? T : never: never;

interface ArrayConstructor {
    isArray<T>(arg: T): arg is ArrayType<T>;
}

Simpler and works for me.

@jcalz
Copy link
Contributor

jcalz commented Jan 10, 2024

That would mean Array.isArray(x) narrows x to never when x is of a wide type like unknown.

@mjljm
Copy link

mjljm commented Jan 10, 2024

Correct!

@robertherber
Copy link

It seems like myArray instanceof Array works well with Readonly arrays now

@bradzacher
Copy link
Contributor

@robertherber I believe that doesn't work across windows and the like (eg iframes) (which is the reason that Array.isArray exists).

@mmckenziedev
Copy link

@robertherber I believe that doesn't work across windows and the like (eg iframes) (which is the reason that Array.isArray exists).

That is correct:

This makes it safe to use with cross-realm objects, where the identity of the Array constructor is different and would therefore cause instanceof Array to fail.

also:

Array.isArray() also rejects objects with Array.prototype in its prototype chain but aren't actual arrays, which instanceof Array would accept.

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray#description

There are good reasons why isArray() exists, things a person might not consider until they happen to you at runtime.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment