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

isArray() preserve mutability and element type #48228

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

jablko
Copy link
Contributor

@jablko jablko commented Mar 11, 2022

Reopening #42316, with any and unknown backward compatibility: #42316 (comment)

There are a couple of related issues here:

  1. Array.isArray() narrows readonly T[] -> mutable any[]
  2. Consequently the false branch doesn't remove readonly T[]
  3. It loses the element or iterated type of wider types ArrayLike<T> and Iterable<T>

#17002 concerns issues 1 and 2. 1 and 3 are pretty similar --- #33700 calls out issue 3, the still-wider type Iterable<T> vs. readonly T[].

#42316 fixed #17002 (1 and 2) but not #33700 (issue 3), by changing the return type from arg is any[] -> arg is readonly unknown[]. That preserves mutability and therefore removes it from the false branch. It narrows:

  • T[] -> T[] & readonly unknown[] (which is mutable because the T[] component is mutable)
  • readonly T[] -> readonly T[] & readonly unknown[] (immutable)

It also preserves the element type of readonly T[].

#42316 was closed because it narrowed any and unknown -> readonly unknown[] vs. any[] (which is the current behavior), breaking code that relies on unknown narrowing to mutable, or narrowing to elements of type any:

declare const u: unknown;
if (Array.isArray(u)) {
  u[1] = 12;
  u[1].length;
}

The current behavior is exceedingly permissive in those cases. The change added errors that weren't there before, but aren't necessarily correct or incorrect, because any and unknown can be anything. The fix wasn't worth the breakage: #42316 (comment)

🩹 Proposed solution

This PR addresses backward compatibility, and also fixes #33700 (issue 3), by instead adding overloads for:

    isArray<T>(arg: ArrayLike<T>): arg is readonly T[];
    isArray<T>(arg: Iterable<T>): arg is readonly T[];

This fixes 1, 2, and 3, for ArrayLike<T>, Iterable<T>, and their narrower types, including readonly T[], while preserving the current behavior for everything else, including any and unknown.

Fixes #17002
Fixes #33700
Fixes #41808

⏯️ Playground link

Workbench repro.

🧑‍💻 Code

// @target: es2015
// @errors: 2322 4104

//interface ArrayConstructor {
//  isArray<T>(arg: ArrayLike<T>): arg is readonly T[];
//  isArray<T>(arg: Iterable<T>): arg is readonly T[];
//}

// https://github.com/microsoft/TypeScript/issues/17002
// Preserves mutability, false branch removes arrays, mutable or not

declare const mutable: string | string[];
if (Array.isArray(mutable)) {
  const stillMutable: string[] = mutable;
} else {
  const narrowed: string = mutable;
}

declare const immutable: string | readonly string[];
if (Array.isArray(immutable)) {
  const notMutable: string[] = immutable; // Should fail: readonly string[] isn't assignable to string[]
} else {
  const narrowed: string = immutable;
}

// https://github.com/microsoft/TypeScript/issues/33700
// Preserves element or iterated type of wider types

declare const arrayLike: string | ArrayLike<string>;
if (Array.isArray(arrayLike)) {
  const arrayOfElementType: readonly string[] = arrayLike;
  const notArrayOfAny: readonly void[] = arrayLike; // Should fail: string isn't assignable to void
}

declare const iterable: string | Iterable<string>;
if (Array.isArray(iterable)) {
  const arrayOfIteratedType: readonly string[] = iterable;
  const notArrayOfAny: readonly void[] = iterable; // Should fail: string isn't assignable to void
}

// https://github.com/microsoft/TypeScript/pull/42316#discussion_r823218462
// any and unknown backward compatibility

declare const any: any;
if (Array.isArray(any)) {
  const mutableArrayOfAny: void[] = any;
  const notAny: void = any; // Should fail: any[] isn't assignable to void
}

declare const unknown: unknown;
if (Array.isArray(unknown)) {
  const mutableArrayOfAny: void[] = unknown;
  const notAny: void = unknown; // Should fail: any[] isn't assignable to void
}

🙁 Actual behavior

// https://github.com/microsoft/TypeScript/issues/17002
// Preserves mutability, false branch removes arrays, mutable or not

declare const immutable: string | readonly string[];
if (Array.isArray(immutable)) {
  const notMutable: string[] = immutable; // ❌ No error: You're free to mutate immutable
} else {
  const narrowed: string = immutable; // ❌ False branch removes mutable arrays only
}

// https://github.com/microsoft/TypeScript/issues/33700
// Preserves element or iterated type of wider types

declare const iterable: string | Iterable<string>;
if (Array.isArray(iterable)) {
  const arrayOfIteratedType: readonly string[] = iterable;
  const notArrayOfAny: readonly void[] = iterable; // ❌ No Error: Iterable<string> -> any[]
}
$ tsc --target es2015 input.ts 
input.ts:8:9 - error TS2322: Type 'string | readonly string[]' is not assignable to type 'string'.
  Type 'readonly string[]' is not assignable to type 'string'.

8   const narrowed: string = immutable; // ❌ False branch removes mutable arrays only
          ~~~~~~~~


Found 1 error.

🙂 Expected behavior

With this PR:

// https://github.com/microsoft/TypeScript/issues/17002
// Preserves mutability, false branch removes arrays, mutable or not

declare const immutable: string | readonly string[];
if (Array.isArray(immutable)) {
  const notMutable: string[] = immutable; // ✔️ readonly string[] isn't assignable to string[]
} else {
  const narrowed: string = immutable; // ✔️ False branch removes arrays, mutable or not
}

// https://github.com/microsoft/TypeScript/issues/33700
// Preserves element or iterated type of wider types

declare const iterable: string | Iterable<string>;
if (Array.isArray(iterable)) {
  const arrayOfIteratedType: readonly string[] = iterable;
  const notArrayOfAny: readonly void[] = iterable; // ✔️ string isn't assignable to void
}
$ node built/local/tsc.js --target es2015 input.ts 
input.ts:6:9 - error TS4104: The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.

6   const notMutable: string[] = immutable; // ✔️ readonly string[] isn't assignable to string[]
          ~~~~~~~~~~

input.ts:17:9 - error TS2322: Type 'readonly string[]' is not assignable to type 'readonly void[]'.
  Type 'string' is not assignable to type 'void'.

17   const notArrayOfAny: readonly void[] = iterable; // ✔️ string isn't assignable to void
           ~~~~~~~~~~~~~


Found 2 errors in the same file, starting at: input.ts:6

@typescript-bot typescript-bot added the For Milestone Bug PRs that fix a bug with a specific milestone label Mar 11, 2022
@typescript-bot
Copy link
Collaborator

The TypeScript team hasn't accepted the linked issue #33700. If you can get it accepted, this PR will have a better chance of being reviewed.

1 similar comment
@typescript-bot
Copy link
Collaborator

The TypeScript team hasn't accepted the linked issue #33700. If you can get it accepted, this PR will have a better chance of being reviewed.

@RyanCavanaugh
Copy link
Member

@DanielRosenwasser let's discuss this next week - seems promising

@sandersn sandersn added this to Not started in PR Backlog via automation Mar 21, 2022
@sandersn sandersn moved this from Not started to Waiting on reviewers in PR Backlog Mar 21, 2022
@SPGoding
Copy link

SPGoding commented Jun 2, 2022

Not sure if I get this right, but the immutable case only seems to work because both string and readonly string[] are Iterable<string>. If you change string to number instead the type checker reverts back to the old behavior:

// https://github.com/microsoft/TypeScript/issues/17002

declare const immutable: number | readonly number[];
if (Array.isArray(immutable)) {
  const notMutable: number[] = immutable; // ❌ No error: type of immutable is `any[]`
} else {
  const narrowed: number = immutable; // ❌ Error: `readonly number[]` is not removed from false branch.
}

@@ -1443,7 +1443,8 @@ interface ArrayConstructor {
(arrayLength?: number): any[];
<T>(arrayLength: number): T[];
<T>(...items: T[]): T[];
isArray(arg: any): arg is any[];
isArray<T>(arg: ArrayLike<T>): arg is readonly T[];
isArray(arg: unknown): arg is any[];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall it be unknown instead, since arg is unknown?

Suggested change
isArray(arg: unknown): arg is any[];
isArray(arg: unknown): arg is unknown[];

@@ -41,7 +41,7 @@ tests/cases/conformance/es6/destructuring/destructuringParameterDeclaration4.ts(
a1(...array2); // Error parameter type is (number|string)[]
~~~~~~
!!! error TS2552: Cannot find name 'array2'. Did you mean 'Array'?
!!! related TS2728 /.ts/lib.es5.d.ts:1470:13: 'Array' is declared here.
!!! related TS2728 /.ts/lib.es5.d.ts:1471:13: 'Array' is declared here.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please clarify why it has been changed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
For Milestone Bug PRs that fix a bug with a specific milestone
Projects
PR Backlog
  
Waiting on reviewers
5 participants