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.includes type is too narrow #26255

Closed
OliverJAsh opened this issue Aug 7, 2018 · 30 comments
Closed

Array.includes type is too narrow #26255

OliverJAsh opened this issue Aug 7, 2018 · 30 comments
Labels
Duplicate An existing issue was already created

Comments

@OliverJAsh
Copy link
Contributor

TypeScript Version: 2.9.2

Search Terms: array includes es2016 widen

Code

Array.includes should allow the searchElement param to be a subtype of the array element type, e.g.

type A = 'foo' | 'bar' | 'baz'
type ASub = Extract<A, 'foo' | 'bar'>;
declare const aSubs: ASub[];
declare const a: A;
/*
Argument of type 'A' is not assignable to parameter of type '"foo" | "bar"'.
  Type '"baz"' is not assignable to type '"foo" | "bar"'.
*/
aSubs.includes(a);

Expected behavior:

No error.

@RyanCavanaugh RyanCavanaugh added the Duplicate An existing issue was already created label Aug 7, 2018
@RyanCavanaugh
Copy link
Member

'foo' | 'bar' | 'baz' is a supertype of 'foo' | 'bar', not a subtype (subtypes represent subdomains of their supertypes).

See #14520 for discussion of upper-bounded generics.

@OliverJAsh
Copy link
Contributor Author

Thanks, closing as duplicate.

@chharvey
Copy link

chharvey commented Jan 28, 2019

@OliverJAsh @RyanCavanaugh : I’m not sure if this is an exact duplicate, but more to the point, Goal Number 7 says that all runtime behavior of JS should be preserved. Since in normal JS Array#includes always returns, you should be able to call [1,2,3].includes('four') in TS without getting a compile-time error.

Currently, the implementation is this:

interface Array<T> {
    includes(searchElement: T, fromIndex?: number): boolean;
}

but it could be this:

interface Array<T> {
    includes<U>(searchElement: U, fromIndex?: number): U extends T ? boolean : false;
}

That way, [1,2,3].includes('four') wouldn’t throw an error, but its type would be false.

The same goes for Set#has and Map#has:

interface Set<T> {
    has<U>(value: U): U extends T ? boolean : false;
}
interface Map<K, V> {
    has<U>(key: U): U extends K ? boolean : false;
}

@RyanCavanaugh
Copy link
Member

Since in normal JS Array#includes always returns, you should be able to call [1,2,3].includes('four') in TS without getting a compile-time error.

Please read https://stackoverflow.com/questions/41750390/what-does-all-legal-javascript-is-legal-typescript-mean

@Benjamin-Dobell
Copy link

Benjamin-Dobell commented Apr 3, 2019

@RyanCavanaugh Thanks for the link to Stackoverflow; as indicated on Stackoverflow:

TypeScript may issue type warnings on code that it considers incorrect.

However, I'm curious as to why TypeScript would consider:

includes(searchElement: any, fromIndex?: number): boolean;

to be incorrect?

The includes method[1] is defined as:

includes compares searchElement to the elements of the array, in ascending order, using the SameValueZero algorithm, and if found at any position, returns true; otherwise, false is returned.

The optional second argument fromIndex defaults to 0 (i.e. the whole array is searched). If it is greater than or equal to the length of the array, false is returned, i.e. the array will not be searched. If it is negative, it is used as the offset from the end of the array to compute fromIndex. If the computed index is less than 0, the whole array will be searched.

There's no indication this function should not be called with types that do not exist in the array. Somewhat to the contrary, the first step of the aforementioned SameValueZero[2] algorithm is:

If Type(x) is different from Type(y), return false.

As such includes offers behavior specifically guaranteed to return false if the type of searchElement matches no elements in the array. Given the function was designed and documented to have this behaviour, is there any particular reason why TypeScript won't allow us to utilize this function as documented?

At least based on my current understanding, it seems unusual to me that...:

const KNOWN_VALUES = Object.freeze(['a', 'b', 'c'])

function foo(input?: string | null) {
    if (KNOWN_VALUES.includes(input)) { // <== TS2345
        // ... Do thing
    }
}

gives the error...:

TS2345: Argument of type 'string | null | undefined' is not assignable to parameter of type 'string'. Type 'undefined' is not assignable to type 'string'.

given that the ECMAScript spec documents this as being a case in which I can safely expect includes to return false.

Granted, I understand TypeScript goes above and beyond JS; it's inherently adding type-safety to a language (and spec) that never had it in mind. The StackOverflow answer mentioned above even specifically calls out referring to the spec as an invalid reason to impact TypeScript's design. The entire point of TypeScript of course being that it adds additional safety. However, I'm not sure under which circumstances the above error provides additional type safety.

Somewhat to the contrary, to work around the error I must train myself to do something that is unsafe e.g. use a ! postfix or a cast. By definition calling includes does not cause any side effects, so I'm (presently) unsure what benefit this seemingly overly restrictive function type definition affords users of TypeScript. Especially given checking unsanitized input against a set of known values it a very common pattern.

Just to clarify, my query isn't specifically regarding null and undefined (despite the chosen example). I would expect the described pattern to work equally as well if input to foo is of type any i.e I'd want to use this pattern to safely determine whether input is a value my program knows about at runtime. I can then use this information to make further inferences (perhaps even type inferences) at runtime to provide sanitation/safety at runtime.

However, admittedly I'm approaching this from a particular mind-set based on my own experience and use-cases; which quite frankly may not be enough to understand the situation at large. Consequently, there may be something immediately apparent to others that I'm simply not seeing. As such, if I'm off base here, I would absolutely appreciate any time anyone is willing to afford me such that I can be better educated on the matter.

[1] https://www.ecma-international.org/ecma-262/9.0/index.html#sec-array.prototype.includes
[2] https://www.ecma-international.org/ecma-262/9.0/index.html#sec-samevaluezero

@Benjamin-Dobell
Copy link

Benjamin-Dobell commented Apr 3, 2019

Sorry, the tldr; of my post above is...

If the type definition of includes were to be changed to:

includes(searchElement: any, fromIndex?: number): boolean;

would it actually break (or cause someone to "break") a single piece of software written in TypeScript?

My current thinking, is no. However, that may be incorrect.

Even still, that alone isn't reason to change the signature. Assuming the change wouldn't break any real-world software. Would changing the signature worsen or improve code comprehension?

I'm of the opinion (at present) that it would actually significantly improve code comprehension as it would allow a common coding pattern to be utilized without littering code with unnecessary (and technically incorrect) casts and/or unnecessary type-checks and null checks.

@RyanCavanaugh
Copy link
Member

There's no indication this function should not be called with types that do not exist in the array

... includes(searchElement: any, fromIndex?: number): boolean;

You're arguing is that this code is totally fine and has no obvious problems:

function foo(arr: string[], content: string, index: number) {
  if (arr.includes(index)) {
    // Do something important
  }
}

This code is in fact extremely suspicious! Type checking is not a valuable operation if it's not capable of identifying code that's very likely to be wrong.

If/when we do get upper-bounded constraints, it absolutely makes sense to make includes be a bivariant operation. Until then, the current definition is the best available.

would it actually break (or cause someone to "break") a single piece of software written in TypeScript?

Changing every built-in function to accept anys would also "break" no one, but that doesn't make it a good idea. Part of TypeScript's value proposition is to catch errors; failing to catch an error is a reduction in that value and is something we have to weigh carefully against "Well maybe I meant that" cases.

@Benjamin-Dobell
Copy link

Benjamin-Dobell commented Apr 3, 2019

@RyanCavanaugh That's a fairly reasonable example of suspicious code, something I admittedly hadn't considered as being a possibility. I'm not sure how frequently this sort of thing would occur in practice. I suspect this would be highly infrequent, but honestly I can't back that up. If it does occur frequently, then it may well trump everything that follows...

However, my concern is that this function has been given a narrow type constraint simply because this is TypeScript, so there's a desire to add types to existing APIs. However, presumably this should only be done for the purpose of facilitating correctness, if the function already has well defined and well understood semantics, then altering these semantics may not be desirable. In this particular instance I believe it leads developers to write code that is non-obvious, unnatural, and therefore more likely to be error prone.

Take for example the sanitation use case. A developer wants to check if some input is in some collection (technically this applies to sets as well as primitive arrays). The natural way to write this code is:

const KNOWN_VALUES = Object.freeze(['a', 'b', 'c'])

function isKnownValue(input?: string | number) {
	return KNOWN_VALUES.includes(input)
}

It's not clever or complicated code, it matches the developers intention precisely, yet TypeScript rejects the code.

So, what does the developer do to correct this issue? A cast, a type check? Let's try the latter:

const KNOWN_VALUES = Object.freeze(['a', 'b', 'c'])

function isKnownValue(input?: string | number) {
	return typeof(input) === 'string' && KNOWN_VALUES.includes(input)
}

Great, it passes. We've got an extra check in there that will be compiled to JS, but "it's a small price to pay for type safety" 👍

Now consider we want to handle numbers in our known value set:

const KNOWN_VALUES = Object.freeze(['a', 'b', 'c', 1, 2, 3])

function isKnownValue(input?: string | number) {
	return typeof(input) === 'string' && KNOWN_VALUES.includes(input)
}

Uh oh!

This TypeScript compiles without errors, but it's not correct. Where as our original "naive" approach would have worked just fine. Why is that? Where is the breakdown here?

It's because TypeScript's type system got in the way of the developer's initial intent. It caused us to change our code from what we intended to what it allowed.

It was never the developer's intention to check that input was a string and a known value; the developer simply wanted to check whether input was a known value - but wasn't permitted to do so.

Generally TypeScript doesn't get in the way, for the most part it encourages correctness - honestly it's great. However, I think in this particular circumstance, it's not facilitating correctness or behaving how developers would expect.

Note: To work around the initial TS error, I could have used a cast instead of an explicit type-check. This would be even more unnatural as we'd essentially intentionally be performing a cast that we know may be inaccurate - however, it would have the benefit that the error above would have been averted. Of course, reading the code after adding in number handling would then be even more confusing.

Edit: Some details of my example were originally poorly chosen i.e. the example was constructed in a way that developer would probably have done a null check rather than a typeof comparison. I've addressed that now. My apologies to anyone who read this before-hand and thought the example seemed a bit too "fabricated".

@henryhobhouse
Copy link

henryhobhouse commented May 2, 2019

I'm hitting a similar issue but in union types:

const allDogNames = {
  django: 'django',
  tom: 'tom',
  buster: 'buster',
} as const;

// type -> "django" | "tom" | "buster"
type AllDogNames = (typeof allDogNames)[keyof typeof allDogNames]; 

const checkDogOnShortlist = (name: AllDogNames): boolean => {
  return [allDogNames.django, allDogNames.tom].includes(name);
};

Error:(26, 57) TS2345: Argument of type '"django" | "tom" | "buster"' is not assignable to parameter of type '"django" | "tom"'. Type '"buster"' is not assignable to type '"django" | "tom"'.

To get around this you have to declare the shortlist type that it "could" be any of the names even if the defined array clearly is not:

const checkNameForDog = (name: AllDogNames): boolean => {
  // type -> ("django" | "tom" | "buster")[]
  const dogShortList: AllDogNames[] = [allDogNames.django, allDogNames.tom]; 
  return dogShortList.includes(name);
};

No error

Behaviour doesn't seem expected in that it would seem reasonable for typescript to determine if you are comparing against a list of string literals with a string literal then this is Ok?

@benneq
Copy link

benneq commented Jun 18, 2019

How about using this signature?

function includes<T, U extends T>(arr: readonly U[], elem: T): elem is U {
    return arr.includes(elem as any);
}

This isn't too restrictive, and provides a nice type type guard.

const ARR = ['a', 'b', 'c'] as const;

const x = 'a'; // type is "a"
if (includes(ARR, x)) {
  console.log(x); // type is "a"
}

const x = 'x'; // type is "x"
if (includes(ARR, x)) {
  console.log(x); // type is never
}

const x = 'doesnt matter' as string; // type is string
if (includes(ARR, x)) {
  console.log(x); // type is "a" | "b" | "c"
}

@dalen
Copy link

dalen commented Jul 19, 2019

Also hit this issue when trying to look if the users locale setting was in the list of supported locales and otherwise default to the default language. A bit unintuitive having to do type casts before using .includes().

It is a real world case of the example Benjamin-Dobell posted.

@svr93
Copy link

svr93 commented Jul 29, 2019

My hard-coded decision for real-world cases:

type DU = 'a' | 'b' | 'c';

let x: DU;
let y: string;

type THelper<T, U> = T extends string
  ? {[P in T]: P extends U ? P : never}[T] extends {[P in T]: unknown}
    ? U
    : never
  : U;

function includes<T, U extends THelper<T, U> extends never ? unknown : never>(
  arr: readonly T[],
  searchElement: U,
  fromIndex?: number,
): boolean {
  return (arr as any).includes(searchElement, fromIndex);
}

/*1*/ if (includes(['a', 'c'] as const, x)) {
  // no error
  doSmth();
}

/*2*/ if (includes(['a', 'd'] as const, x)) {
  // error
  doSmth();
}

/*3*/ if (includes(['a', 'c'] as const, y)) {
  // no error
  doSmth();
}

/*4*/ if (includes(['a', 'c'] as const, 4)) {
  // error
  doSmth();
}

function doSmth() {}

@OliverJAsh, it seems to be working fine with your example.
@RyanCavanaugh, it brokes supertype/subtype logic but works well as enum replacement:

enum ReallyLongEnumName {
  a,
  b,
  c,
}

let x: ReallyLongEnumName;
let y: string;

/*1*/ if ([ReallyLongEnumName.a, ReallyLongEnumName.c].includes(x)) {
  // no error
  doSmth();
}

function doSmth() {}

@charlescapps
Copy link

charlescapps commented Jun 14, 2020

I agree with @Benjamin-Dobell and others; it's too restrictive if we can't check for any type belonging to an array. I think there are good foundational reasons why Java's signature on all collection types allow any Object (which is the base of all types in Java) with Collection.contains(Object o) . Ditto for indexOf etc.

To give one additional example, suppose I have an exhaustive list of a union type, and I want an is MyType style boolean function for type-checking if a string is a valid member of the union type. Without the ability to check for any belonging to a collection, we need dangerous casts, e.g.

// This is contrived and I'd do it somewhat differently, 
// but it illustrates a 100% valid situation
type Silverware = "fork" | "spoon" | "knife";
const ALL_SILVERWARE: readonly Silverware[] = [ "fork", "spoon", "knife" ];
export const isSilverware(foo: any): foo is Silverware {
  // DANGEROUS cast. 
  // It's valid in this case, but it's bad practice and dangerous 
  // if our code has to do this regularly
  return (ALL_SILVERWARE as any[]).includes(foo);
}

Or... at the very least we could have an additional method, or a type param to allow widening the accepted type, e.g. using type defaults like:

interface Array<T> {
  ...
  includes<U = T>(searchElement: U, fromIndex?: number): boolean;
}

@kevinpeno
Copy link

kevinpeno commented Aug 27, 2020

The root issue here is certain functions that should act like type assertions, but do not do so. Array.includes is an assertion that searchElement is inside the array. What it does currently is demand searchElement be a value of array, which isn't helpful in most use cases. If I know it is within the array at compile time, I don't need the function at all. It is also overly strict when considering runtime validation and its purpose. This is likely a legacy issue from prior to function assertions existing and I'd like to see a fix.

Here's the current implementation:

interface Array<T> {
    /**
     * Determines whether an array includes a certain element, returning true or false as appropriate.
     * @param searchElement The element to search for.
     * @param fromIndex The position in this array at which to begin searching for searchElement.
     */
    includes(searchElement: T, fromIndex?: number): boolean;
}

A change of the interface to the below serves the expected purpose without making assumptions on the array (const or otherwise) and the values it contains.

interface Array<T> {
    /**
     * Determines whether an array includes a certain element, returning true or false as appropriate.
     * @param searchElement The element to search for.
     * @param fromIndex The position in this array at which to begin searching for searchElement.
     */
    includes(searchElement: unknown, fromIndex?: number): searchElement is T;
}

What are the objections to such a change? Is a PR welcome here? I don't even know that this would be a breaking change, as existing code should "just work" given the broadening of the type. I am open to know if there are edge cases though so I can include them in the test cases for a PR.

@ricardomatias
Copy link

Please re-open this issue, the current implementation makes includes unusable in validation scenarios. The following should be perfectly valid:

const Foo = {
	'a': 'foo',
	'b': 'bar',
	'c': 'xyz',
	'd': '123',
} as const;

type Foo = typeof Foo[keyof typeof Foo];

// Foo.c is not assignable to type '"foo' | 'bar'"
const hasC = [Foo.a, Foo.b].includes(Foo.c);

@eyedean
Copy link

eyedean commented Oct 8, 2020

I found the following trick that unblocks me right now in a rush.

const FooArr = [
    "Hi",
    "Bye",
] as const;

const hasC1 = FooArr.includes("Chocolate"); // Doesn't work, TS is too strong in checking -- Argument of type '"Chocolate"' is not assignable to parameter of type '"Hi" | "Bye"'.(2345)

const hasC2 = (FooArr as string[]).includes("Chocolate"); // Doesn't work either -- Conversion of type 'readonly ["Hi", "Bye"]' to type 'string[]' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. The type 'readonly ["Hi", "Bye"]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.(2352)

const hasC3 = ([...FooArr] as string[]).includes("Chocolate"); // Only hasC3 works!

I don't advise this, but since I am in a rush, I'm gonna leave it in my code with a link to this ticket as // Yet another TypeScript's unnecessary freaks: https://github.com/microsoft/TypeScript/issues/26255. :)

@vdh
Copy link

vdh commented Oct 8, 2020

@eyedean Casting as (foo as readonly string[]) will downgrade that overly-restrictive searchElement to just string. It's what I've used as a workaround for a while now, but it sucks that I'm forced to use this big wordy cast all the time for something as straightforward as Array.prototype.includes ought to be.

As mentioned above, it's way too restrictive, and should be changed to a type assertion instead.

P.S. IMO, title case should be for classes / React components / types, and baking types into names like with that Arr prefix is also not my cup of tea either

@rdennis
Copy link

rdennis commented Oct 19, 2020

While I agree this signature needs to change, I'm a little confused by all of these weird (and possibly dangerous) workarounds casting the array instead of casting the search element. Can anyone explain this to me?

type ArrT = 'foo' | 'bar' | 'baz';
const a: ArrT[] = ['bar', 'baz'];

(a as any).includes('hello'); // why this (or some variant) 

a.includes('hello' as any);   // instead of this?

Of course casting at all is not what we want here, but to me it seems like casting the search element is the lesser of two evils. Also, the type guard signature gets my vote for best temporary workaround.

@k-funk
Copy link

k-funk commented Oct 20, 2020

I've only been using typescript a month. So far, I'm a fan, but this particular typing for includes requires some unnecessary verbosity when a naive check is just fine, especially considering that I didn't explicitly define the array type in the first place

What I tried to use:

enum Role { ... }
const show = [Role.Support, Role.Admin].includes(user.role)) // user.role could be undefined

What I ended up using to make typescript happy:

enum Role { ... }
const show = ([Role.Support, Role.Admin] as any).includes(user.role))

Or I could have been "more correct" and used:

enum Role { ... }
const { role } = user
const show = role && [Role.Support, Role.Admin].includes(role))

🤮 I hate when languages make me be clever in the name of safety, but add bloat that makes my code harder to read and easier to introduce bugs.

@kevinpeno
Copy link

kevinpeno commented Nov 25, 2020

@RyanCavanaugh (et. al) I see we keep referencing this issue as a duplicate to others, even referencing my comment's suggested change in some cases, but this issue remains closed. Can we at least re-open this issue if it is going to be referenced as a duplicate given TS now supports more functionality than when originally closed? Failing that, can someone also answer my previous question regarding if setting a type assertion will be accepted as a PR or at least tell me how to make a proper request for the suggested change?

I'd love to help support the team and get this in the code base if it means you won't need to reference a closed ticket as duplicate any longer ;)

@RyanCavanaugh
Copy link
Member

@kevinpeno The way we would fix this is by implementing #14520, so we don't need two issues tracking the same thing. I typically send people here first so they can understand that they could have searched better and found this 🙂. Regarding the type guard change, please open a new issue for that -- this issue is a complaint about being unable to search for supertypes in an array (which should be valid). I don't think we would take that change verbatim since it would allow ["foo"].includes(0) which we want to be an error.

@vdh
Copy link

vdh commented Nov 27, 2020

@RyanCavanaugh But it's not an error…? ["foo"].includes(0) returns false, which is correct because ["foo"] does indeed not include 0. Why shouldn't includes accept any like most type guards do?

@RyanCavanaugh
Copy link
Member

@vdh by that logic, almost anything should be allowed. Why can't I write "hello world".elgnth ? It just produces undefined, there should be no problem with that. Why not allow [1, 2, 3].sort(class { }) ? That just returns an array in some order. Why not allow "42" * true ? That produces 42, totally OK.

TS has many checks that are based on our judgment about what's "over the line" in terms of intentional vs unintentional behavior of JavaScript. If you want includes to accept any argument, you are already free to add that declaration to your project at the risk of missing other errors.

If you don't like the fact that TS disallows many operations that are "allowed" by JavaScript, the good news is that JavaScript still exists.

@kevinpeno
Copy link

I do agree with your response to me that it should allow "supertypes" of the value (existing or not; e.g. string) to be checked but not, say 0. I'll open up an issue on that specifically as well as try to update the example I gave to support your request for some level of strictness.

Thanks for your response.

@vdh
Copy link

vdh commented Dec 1, 2020

@RyanCavanaugh That's a bit of a nasty response to make. Instead of seriously answering whether or not it's a good candidate to type it as a real type guard, you instead mock me as if I'm somehow suggesting all garbage code of any wrong type should be allowed.

Array.prototype.includes has the express designed purpose of determining whether or not a given value is included or not in an array. It's basically a type guard before Typescript existed. I only suggested any within the context of it being a type guard. Or unknown, as the original suggestion mentioned. Please correct me if I'm misinformed, but this is a common practise for type guards.

Of course an edge case of an inlined 0 paired with an inlined ["foo"] array doesn't make sense because it would always return false. The one-line nature of that snippet muddles the issue. The fact that it will always return false for that type combination should be the issue flagged by Typescript, not the value passed into includes. The test value shouldn't be rejected if it has at least some amount of overlap with the array type. If a value is string and your array is readonly ("foo" | "bar")[], rejecting it for not being exactly a type of "foo" | "bar" ruins the useful and common scenario of using includes as a type guard for checking if your value is in fact "foo" | "bar" in the first place.

I apologise for neglecting to detail the nuance of the ["foo"].includes(0) edge case and being curt about it in my rebuttal, but was it really necessary to mock me with an ad hominem over it? I don't want to engage in hostility, I just earnestly want that original suggestion to be taken seriously and not glossed over.

@RyanCavanaugh
Copy link
Member

Please don't misinterpret technical discussions as personal attacks. Anyway, I think you're missing the fact that we already agree here - supertypes absolutely should be allowed inputs to includes, but we lack a mechanism to allow this.

The fact that it will always return false for that type combination should be the issue flagged by Typescript

Absolutely! It should be flagged as an error, by what mechanism? That's the thing here; we already agree that supertypes should be allowed inputs, it's just that we don't have any way to make supertypes allowed without also allowing everything else, which is why this issue is just a manifestation of #14520. Once that was implemented, we absolute would change the signature to something like includes(arg: T | super T). The desired behavior is clear, but we lack the mechanics to enable it.

The options on the table (without implementing #14520) are:

  • Only allow subtypes
  • Allow anything

We (the team) think that "anything" is a bad answer here because of examples like ["foo"].includes(42). If you disagree, you can add the (searchElement: unknown): searchElement is T declaration to your own library and opt in to that behavior. If we made the opposite decision and said anything goes, then no one would be able to opt in to the "subtypes only" behavior because there is no mechanism for removing an overload.

Just to spell this out explicitly: There's no point discussing a type guard in the case where only subtypes are allowed, since that will never narrow the type of the argument.

Regardless of which behavior of the two currently available with existing mechanics you think is clearly preferable, the fact is that one of them allows users to opt into the other, and the other doesn't. When we're faced with that situation, unless the choice is truly obvious (and IMO it's not), then we're going to go with the one that keeps flexibility on the user side.

I just earnestly want that original suggestion to be taken seriously and not glossed over.

That isn't the original suggestion; it's a comment. The original suggestion is at the top of this page and it just says that supertypes should be allowed inputs to includes, which we agree on, but can only implement (and would implement) with #14520. The linked comment is a comment, and I asked the person suggesting it to file a new issue ("Regarding the type guard change, please open a new issue for that ") so it could be considered separately and with proper visibility, which is exactly the process by which we take things seriously.

@RyanCavanaugh
Copy link
Member

You can also review other duplicate proposals for the type guard behavior where it has been pointed out that treating this as a type guard is unsound:

interface Array<T> {
    includes2(searchElement: any, fromIndex?: number): searchElement is T;
}

declare let s: string | number;
if (["foo"].includes2(s)) {

} else {
    // 's' might be "bar" or any other string that's not "foo"
    s.toFixed();
}

For the type guard flavor to be correct, we'd need #15048.

So ultimately you're blocked one of three things here:

  • A feature request for lower-bounded generics
  • A feature request for "one-sided" type guards
  • A fundamental disagreement about whether includes should just take any arbitrary input, for which syntax to opt into that behavior already exists

@noppa
Copy link

noppa commented Dec 2, 2020

I may be overlooking some other case where this would fall apart in some other way, but from what I can tell, TypeScript 4.1's template literal types and some trickery would allow a definition for includes method that would work pretty well specifically for the string enums use case.

interface ReadonlyArray<T> {
  includes<S, R extends `${Extract<S, string>}`>(
    this: ReadonlyArray<R>,
    searchElement: S,
    fromIndex?: number
  ): searchElement is R & S;
}

Playground example

1. Input is correctly refined to be one of the strings in the haystack array

declare let s: number | string

if ((['foo', 'bar'] as const).includes(s)) {
  // s is 'foo' | 'bar' here
  if (s === 'baz') {} // Error as expected, since this is would always be false
}

You do need the as const cast there, but IMO that's a pretty small price to pay. If you have already declared a list of accepted values like

declare let arr: ReadonlyArray<'foo' | 'bar'>
if (arr.includes(s)) {

then that works too.

2. It doesn't refine other than string literal types

The unsoundness @RyanCavanaugh mentioned above isn't here anymore, since this never refines R to be or not be string and only deals with specific string literal types. In the else-branch, type of s is still string | number.

if ((['foo', 'bar'] as const).includes(s)) {
} else {
    // s is string | number here
    s.toFixed(); // [ts] Property 'toFixed' does not exist on type 'string'
}

For this to work properly, we need the template literal in the generics constraint

extends `${ string }`

With just extends string readonly string arrays would fall back to unsound behaviour where they'd "refine" the input value to be a string even if it's not

declare var arr: ReadOnlyArray<string>
if (arr.includes(s)) {
} else {
    s.toFixed(); // We want error here!
}

With the template literal constraint, TS won't use our new overload of the method for normal ReadOnly<string>. Instead, it'll use the original library definition as it is currently defined, so things are as strict as they currently are for arrays of arbitrary strings.

3. Input values that can never even be strings are caught as errors

(['foo', 'bar'] as const).includes(0) // Error as expected, albeit more verbose one than we'd like.

This works thanks to the Extract<S, string> trick. When there is no string in the generic type S, the this-parameter will end up being ReadOnlyArray<never>, which won't match the input array.


Admittedly, this all overloading stuff comes with a pretty big caveat; The error messages for incorrect parameter usage become much worse

declare let arr: ReadonlyArray<number>
arr.includes('a')
No overload matches this call...

No overload matches this call.
  Overload 1 of 2, '(this: readonly `${string}`[], searchElement: string, fromIndex?: number | undefined): searchElement is `${string}` & string', gave the following error.
    The 'this' context of type 'readonly number[]' is not assignable to method's 'this' of type 'readonly `${string}`[]'.
      Type 'number' is not assignable to type '`${string}`'.
  Overload 2 of 2, '(searchElement: number, fromIndex?: number | undefined): boolean', gave the following error.
    Argument of type 'string' is not assignable to parameter of type 'number'.


Still, even if this will never land in TypeScript's builtin types, it may be worth a try in userland type declarations for people following this issue.

@1valdis
Copy link

1valdis commented Dec 18, 2020

Please reopen the issue. I use .includes as a guard often and such narrow typing does not make sense for me, because I have to do silly casts or additional checks. Instead make it possible to pass any type as an argument and warn if the types of the argument and array items don't overlap (so the comparison is always false).

@microsoft microsoft locked as resolved and limited conversation to collaborators Dec 18, 2020
@RyanCavanaugh
Copy link
Member

Locking due to discussion going in circles. Please read the thread before commenting..

Please see #14520 for upper-bounded generics, which would be one path forward
See #15048 for why we don't have the proposed type guard behavior.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Duplicate An existing issue was already created
Projects
None yet
Development

No branches or pull requests