X Tutup
The Wayback Machine - https://web.archive.org/web/20210102125306/https://github.com/microsoft/TypeScript/issues/36554
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 method definition revamp: Use case collection #36554

Open
RyanCavanaugh opened this issue Jan 31, 2020 · 32 comments
Open

Array method definition revamp: Use case collection #36554

RyanCavanaugh opened this issue Jan 31, 2020 · 32 comments

Comments

@RyanCavanaugh
Copy link
Member

@RyanCavanaugh RyanCavanaugh commented Jan 31, 2020

We've gotten numerous issue reports and PRs to change the methods of Array, particularly reduce, map, and filter. The built-in test suite doesn't cover these very well, and these methods interact with each other and the surrounding contextual type in fairly subtle ways.

@jablko has done a great job at #33645 collecting a variety of issues into a single PR; we need to augment this PR (or something like this) with a proper test suite so we can be sure about what's being changed.

I'd like to create a clearinghouse issue here to collect self-contained (I CANNOT POSSIBLY STRESS THIS ENOUGH, SELF-CONTAINED, DO NOT IMPORT FROM RXJS OR WHAT HAVE YOU) code samples that make use of the array methods.

Please include with your snippet:

  • Compiler settings
  • Compiler version you were testing with
  • The expected behavior (examples that should and should not produce errors are both useful)
  • No imports or exports; snippets need to be self-contained so that we can put them into our test suite without extra complications

Once we've established a critical mass of code snippets, we can start combining the existing PRs into an all-up revamp and assess its impact to real-world code suites to figure out which changes don't result in unacceptable breaking changes.

self-contained

@RyanCavanaugh
Copy link
Member Author

@RyanCavanaugh RyanCavanaugh commented Jan 31, 2020

From #19535: concat should reflect the flattening of its input argument. Tested on 3.8-beta with target: ESNext, strict on

const foo: [number, string][] = [[1, 'one']];
const a = foo.concat([2, 'two']);
// SHOULD ERROR,
// the actual content of 'a' is [[1, 'one'], 1, 'two']
// so a[1] should be number | string | [number, string]
a[1][0];
@RyanCavanaugh
Copy link
Member Author

@RyanCavanaugh RyanCavanaugh commented Jan 31, 2020

From #24579: flat should, well, flatten. Tested on 3.8-beta with target: ESNext, strict on

declare let arr: [[number, boolean], string];
let x0 = arr.flat(0); // Should be [[number, boolean], string] or (number | boolean | string)[]
let x1 = arr.flat(1); // Should be [number, boolean, string] or (number | boolean | string)[] 
let x2 = arr.flat(2); // Should be [number, boolean, string] or (number | boolean | string)[]
@RyanCavanaugh
Copy link
Member Author

@RyanCavanaugh RyanCavanaugh commented Jan 31, 2020

From #26976: concat should at least allow an empty array as a target. Arguably it should allow heterogenous operations? Tested on 3.8-beta with target: ESNext, strict on

// Should be OK (currently an error) and produce string[]
let a1 = [].concat(['a']);
// Should be OK (is) and continue to produce string[]
let a2 = ['a'].concat(['b']);

// Should either error (current behavior) or maybe produce (string | number)[]
let a3 = [1].concat(['a']);
@RyanCavanaugh
Copy link
Member Author

@RyanCavanaugh RyanCavanaugh commented Jan 31, 2020

From #29604: flat shouldn't produce any[] (?!), Array.prototype.concat should be better if possible. Tested on 3.8-beta with target: ESNext, strict on

// Should be an error
const a: boolean[] = [[17], ["foo"]].flat();
// Should be an error (stretch goal)
const b: boolean[] = Array.prototype.concat([17], [19], [21]);  
@RyanCavanaugh
Copy link
Member Author

@RyanCavanaugh RyanCavanaugh commented Jan 31, 2020

From a real-world code suite broken by #33645:

This should error (previously misidentified as "should not error"):

// Should error because add([["a"]], ["b"]) will not produce a string[][]
function add<A>(arr: Array<A>, el: A): Array<A> {
    return arr.concat(el)
}
@RyanCavanaugh
Copy link
Member Author

@RyanCavanaugh RyanCavanaugh commented Jan 31, 2020

From a real-world code suite broken by #33645:

// Did not error; probably shouldn't in the future either
class A {
    flattenTree(option: any, changeOnSelect: any, ancestor = []) {
        let flattenOptions: any = [];
        const path = ancestor.concat(option);
        flattenOptions = flattenOptions.concat(this.flattenTree(option, changeOnSelect, path));
   }
}

This one should be simpler to demonstrate exactly what aspect of things got broken

@falsandtru
Copy link
Contributor

@falsandtru falsandtru commented Feb 1, 2020

([] as any[]).reduce(() => 0, 0); // Expected: number, Actual: any
([] as unknown[]).reduce(() => 0, 0); // Expected: number, Actual: unknown
([] as never[]).reduce(() => 0, 0); // Expected: number, Actual: number
@falsandtru
Copy link
Contributor

@falsandtru falsandtru commented Feb 1, 2020

@RyanCavanaugh This is caused by a mistake of the order of overloads. I made #36570 and looks like it makes no regression.

@jablko
Copy link
Contributor

@jablko jablko commented Feb 3, 2020

From a real-world code suite broken by #33645:

// Should not error
function add<A>(arr: Array<A>, el: A): Array<A> {
    return arr.concat(el)
}

@RyanCavanaugh Shouldn't it? If A is string[], arr is [["a"]] and el is ["b"] then add(arr, el) will return [["a"], "b"], which doesn't match the return type Array<A>?

Should that implementation be updated -> return arr.concat([el])?

@RyanCavanaugh
Copy link
Member Author

@RyanCavanaugh RyanCavanaugh commented Feb 3, 2020

@jablko good point. The actual code was in fp-ts and indeed they've removed it; the implementation now uses a loop instead https://github.com/gcanti/fp-ts/blob/master/src/Array.ts#L373

@jablko
Copy link
Contributor

@jablko jablko commented Feb 3, 2020

From a real-world code suite broken by #33645:

// Did not error; probably shouldn't in the future either
class A {
    flattenTree(option: any, changeOnSelect: any, ancestor = []) {
        let flattenOptions: any = [];
        const path = ancestor.concat(option);
        flattenOptions = flattenOptions.concat(this.flattenTree(option, changeOnSelect, path));
   }
}

This one should be simpler to demonstrate exactly what aspect of things got broken

@RyanCavanaugh I think this should be an error? The reason it currently doesn't error is because option: any is assignable to ConcatArray<never> (at const path = ancestor.concat(option)). However the only way the return type never[] is accurate is if option is [].

I think the flattenTree() signature needs to be ancestor: any[] = [], to be callable with the const path = ancestor.concat(option), where option can be something other than []? That, or the signature must be option: never[].

@bpasero
Copy link
Member

@bpasero bpasero commented Mar 3, 2020

TypeScript Version: 3.8.x

Code

interface Bar {
    property: boolean;
}

let foo: ReadonlyArray<string> | Bar;
if (Date.now() === 0) {
    foo = { property: true};
} else {
    foo = ['Hello'];
}

if (Array.isArray(foo)) {
    console.log(foo[0]);
} else {
    console.log(foo.property); // <-- error
}

Expected behavior:
The else branch correctly understands that foo is of type Bar

Actual behavior:
TypeScript still thinks foo is ReadonlyArray<string> | Bar

Playground Link: link

@HolgerJeromin
Copy link
Contributor

@HolgerJeromin HolgerJeromin commented Mar 3, 2020

@bpasero This has nothing to do with Array stuff:

Old example
let foo: boolean | string;
if (Date.now() === 0) {
	foo = true;
} else {
	foo = "hello";
}
foo; // still of type: boolean | string

Not possible without defining a specialized type "positive number" (no zero and even no negative value, no decimal points)
I was wrong. if (Array.isArray(foo)) should have saved us. Sorry for the noise :-)

@bpasero
Copy link
Member

@bpasero bpasero commented Mar 3, 2020

@HolgerJeromin I just did what @RyanCavanaugh suggested in #37129 (comment) and posted my usecase here.

@xiaoxiangmoe
Copy link

@xiaoxiangmoe xiaoxiangmoe commented Mar 27, 2020

Also , we need Infinity literal type. Here is an example we use:

type Element<T> = {
  isArray: ArrayElem<T extends ReadonlyArray<infer E> ? E : never>;
  notArray: T;
}[T extends ReadonlyArray<any> ? 'isArray' : 'notArray'];

type ArrayElem<T> = T extends ReadonlyArray<any> ? Element<T> : T;

declare function flatInfinity<T>(x: T): Array<ArrayElem<T>>;

const c = flatInfinity(['foo', 1, 2, null, [5, [2], [3, {}, 4]]] as const); // ("foo" | 1 | 2 | 5 | 3 | 4 | {} | null)[]

it will be better if we can use [].flat(Infinity)

@HoldYourWaffle
Copy link
Contributor

@HoldYourWaffle HoldYourWaffle commented Mar 31, 2020

map erases "tuple-ness" of an array (#29841):

type Vec3D = [number, number, number];
let vec: Vec3D = [1, 2, 3];
let scaledVec: Vec3D = vec.map(x => 2 * x); // error: Type 'number[]' is missing the following properties from type '[number, number, number]': 0, 1, 2

The following overload using mapped tuples can be defined as workaround (as seen in #29841):

declare interface Array<T> {
    map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): { [K in keyof this]: U };
}
@proteriax
Copy link

@proteriax proteriax commented Apr 6, 2020

declare interface Array<T> {
  filter(filter: BooleanConstructor): Exclude<T, null | undefined | 0 | "" | false>[]
}
@jdmoody
Copy link

@jdmoody jdmoody commented Apr 26, 2020

In addition to @HoldYourWaffle's map use case, it would be great to support generic tuples of arbitrary arity:

interface Box<T> { value: T }
type BoxTuple<T> = { [K in keyof T]: Box<T[K]> }

const forBoxes = <T extends any[], TValue> (...boxes: BoxTuple<T>) => ({
  useValues: (callback: (v: T) => TValue) => {
    // Argument of type 'any[]' is not assignable to parameter of type 'T'.
    //   'any[]' is assignable to the constraint of type 'T',
    //   but 'T' could be instantiated with a different subtype of constraint 'any[]'.
    return callback(boxes.map((box) => box.value))
    // (Asserting the map call 'as T' fixes the error)
  }
})

const values = forBoxes(
  { value: 'Dog' },
  { value: 1 },
  { value: true }
).useValues(
  ([str, num, bool]) => `My boxes contain ${str}, ${num}, and ${bool}`
)

(Playground Link)

@extmchristensen
Copy link

@extmchristensen extmchristensen commented May 1, 2020

I would like concat.apply in _getEvents to work without the typecast of [] to ReadonlyArray.


export function _getEvents(
  nameSpec: string | RegExp | Array<string | RegExp>
): ReadonlyArray<string> {
  if (Array.isArray(nameSpec)) {
    const allStrings = ([] as ReadonlyArray<string>).concat.apply([], nameSpec.map(a => _getEvents(a)));
    const allUniqueStrings = Array.from(new Set<string>(allStrings).values());
    return [
      ...allUniqueStrings
    ];
  } else {
   // ...
  }

  return [];
}
@jimador
Copy link

@jimador jimador commented Jun 25, 2020

I created #39259 . It appears that 2 of the signatures for reduce are colliding, which breaks inference for the return type of reduce in certain situations. It appears that the conflicting signatures are:

    reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: readonly T[]) => T, initialValue: T): T;
    reduce<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: readonly T[]) => U, initialValue: U): U;

It looks like the first can be dropped in favor of the second, as signature of the second covers the first.

Here is a playground link demonstrating the issue.

@julian-sf
Copy link

@julian-sf julian-sf commented Jun 25, 2020

[@RyanCavanaugh]: @jimador and I were looking at #39259 together. Here is an even shorter repro path (playground link):

function broken(): boolean {
  return ['foobar', ''].reduce(
    (previousValue, currentValue) => !!(previousValue && currentValue),
    true
  )
}

function works(): boolean {
  return ['foobar', ''].reduce<boolean>(
    (previousValue, currentValue) => !!(previousValue && currentValue),
    true
  )
}

function alsoWorks(): boolean {
  const value = ['foobar', ''].reduce(
    (previousValue, currentValue) => !!(previousValue && currentValue),
    true
  )
  return value
}

Which results in the following errors in the broken function: Image 2020-06-26 at 11 09 52 AM

Image 2020-06-26 at 11 13 27 AM

Hopefully, this will help. Academically, I'm very interested to know what mechanism causes the first and third (broken and alsoWorks) examples to behave differently.

EDIT: My working theory is the overload mismatch. Like @jimador said, maybe the simple solution is to only have the inferred generic signature?

@jimador
Copy link

@jimador jimador commented Jun 26, 2020

I'm very interested to know what mechanism causes the first and third (broken and alsoWorks) examples to behave differently.

This has piqued my interest as well.

@jimador
Copy link

@jimador jimador commented Jun 26, 2020

@julian-sf That's interesting, it looks like TS is downcasting (or just plain inferring) the return type as true and not boolean. If it's an inference problem, this should be reproducible in a different context.

Update:

// the return type here is correctly inferred as `boolean`
function works() {
  return new Array('foo', 'bar').reduce(
    (previousValue, currentValue) => !!(previousValue && currentValue),
    true
  )
}


function broken(): boolean {
  return new Array('foo', 'bar').reduce(
    (previousValue, currentValue) => !!(previousValue && currentValue),
    true
  )
}

image

@AndrewMorsillo
Copy link

@AndrewMorsillo AndrewMorsillo commented Jul 16, 2020

Perhaps I'm missing something here, I'm not super experienced with more complex types, but this case looks impossible with TS right now.

I have a const array of strings. I want an object with those strings as keys.

const myKeys = ["foo", "bar"] as const
type TMyTypeWithMyKeys = {
    [key in typeof myKeys[number]]: string
}

//Type '{}' is missing the following properties from type 'TMyTypeWithMyKeys': foo, bar
const myObjWtihMyKeys: TMyTypeWithMyKeys = myKeys.reduce((obj, key) => ({ ...obj, [key]: "initialValue" }), {})

Desired behavior is for TS to infer the return type instead of just using the initial value as the return type.

@jimador
Copy link

@jimador jimador commented Jul 16, 2020

@AndrewMorsillo,

The signature for reduce declares the return type of reduce as the type of the initial value ({} in this case). TS would have to execute the reduce function to infer the return type (kind of like a const exp?). If TS could do something like that, the type of the result would probably be { foo: 'initialValue'; bar: 'initialValue'} which still wouldn't match { foo: string; bar: string}

@julian-sf
Copy link

@julian-sf julian-sf commented Jul 17, 2020

Perhaps I'm missing something here, I'm not super experienced with more complex types, but this case looks impossible with TS right now.

I have a const array of strings. I want an object with those strings as keys.

const myKeys = ["foo", "bar"] as const
type TMyTypeWithMyKeys = {
    [key in typeof myKeys[number]]: string
}

//Type '{}' is missing the following properties from type 'TMyTypeWithMyKeys': foo, bar
const myObjWtihMyKeys: TMyTypeWithMyKeys = myKeys.reduce((obj, key) => ({ ...obj, [key]: "initialValue" }), {})

Desired behavior is for TS to infer the return type instead of just using the initial value as the return type.

Technically you can achieve it with this:

const myKeys = ["foo", "bar"] as const
type TMyTypeWithMyKeys = {
    [key in typeof myKeys[number]]: string
}

const myObjWtihMyKeys = myKeys.reduce<TMyTypeWithMyKeys>((obj, key) => ({ ...obj, [key]: "initialValue" }), {} as any)

But it's honestly not REALLY correct that way. There is no guarantee the object is correctly formed...that's why we think it's related to an overloading issue. IE, the less-useful signature is getting applied.

@nigelsim
Copy link

@nigelsim nigelsim commented Sep 24, 2020

In Typescript 3.9.7.

function toStringArray(a: string[] | number[]) {
    return a.reduce((acc, v) => acc.concat(v.toString()), [] as string[]);
}

v should be inferred as string | number but ends up as any. Likewise, acc should be string[] but is any.

reduce has the error

Each member of the union type '{ (callbackfn: (previousValue: string, currentValue: string, currentIndex: number, array: string[]) => string): string; (callbackfn: (previousValue: string, currentValue: string, currentIndex: number, array: string[]) => string, initialValue: string): string; (callbackfn: (previousValue: U, currentValue: string, ...' has signatures, but none of those signatures are compatible with each other.ts(2349)

@ExE-Boss
Copy link
Contributor

@ExE-Boss ExE-Boss commented Sep 25, 2020

I believe that .map(…) operations on tuple types should still produce a tuple type.

@asbjornh
Copy link

@asbjornh asbjornh commented Nov 11, 2020

Typescript version 4.0.5

This one is a little longer than other examples here (I'm sorry), but I haven't been able to recreate this with a simpler function.

The behaviour is very interesting though. Type inference for my groupBy utility works as I'd expect as long as the type of list elements don't have properties that are of type Array.

const groupBy = <K extends string, V>(func: (el: V) => K) => (
  acc: Record<K, V[]>,
  el: V
): Record<K, V[]> => {
  const key = func(el);
  return { ...acc, [key]: (acc[key] || []).concat(el) };
};

type A = { a: string; b: number };
const listA: A[] = [];
// 'el' is inferred to be of type 'A' as expected
const a = listA.reduce(groupBy(el => el.a), {});

type B = { a: string; b: string[] }; // Note that 'b' is a list
const listB: B[] = [];
// 'el' is inferred to be of type 'string | B'
const b = listB.reduce(groupBy(el => el.a),  {}); // Type error:  Property 'a' does not exist on type 'string | B'

The el argument is also inferred to be string | T if T has properties of type Record or indexed types (and maybe others)

I also found that manually deleting the following override from lib.es5.d.ts makes inference work as expected for both cases:

    reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;
@Retsam
Copy link

@Retsam Retsam commented Dec 3, 2020

With .split, it'd be nice if the return type reflected that the method always (with two caveats) returns an array with at least one string in it. So the return type could be [string, ...string[]] instead of string[].

This would make code like this function better with the noUncheckedIndexedAccess flag:

// currently head is `string | undefined`, but in practice, it's always `string`
const [head, ...rest] = someString.split("/");

There are, AFAIK, two caveats:

  1. someString.split(sep, 0); will return [], because to the limit being 0.
  2. "".split("") returns [] not [""], because... "Life is pain, highness; anyone who tells you differently is selling something" or something.

Technically, those could be addressed with overloads, but at least the second one feels like a reasonable sacrifice of soundness for the sake of pragmatism.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

None yet
You can’t perform that action at this time.
X Tutup