Skip to content

.includes or .indexOf does not narrow the type #36275

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

Closed
minderov opened this issue Jan 18, 2020 · 23 comments
Closed

.includes or .indexOf does not narrow the type #36275

minderov opened this issue Jan 18, 2020 · 23 comments
Labels
Suggestion An idea for TypeScript Too Complex An issue which adding support for may be too complex for the value it adds

Comments

@minderov
Copy link

minderov commented Jan 18, 2020

TypeScript Version: 3.7.x-dev.201xxxxx

Search Terms: .includes type narrowing, .indexOf type narrowing

Code

interface TextMessage {
    type: 'text',
    text: string
}

interface ImageMessage {
    type: 'image',
    url: string
}

type Message = TextMessage | ImageMessage;

// This is an example to reproduce the error in Playground.
// In practice, assume this message comes from outside our control (e.g. HTTP request)
const message: Message = JSON.parse(prompt('') as string) as Message;

if (message.type === 'text') {
    // No error here
    message.text = message.text.trim();
}

// Same for ['text'].includes(message.type)
if (['text'].indexOf(message.type) > -1) {
    // Error: Property 'text' does not exist on type 'ImageMessage'
    message.text = message.text.trim();
}

Expected behavior:
I expect message to narrow its type to TextMessage inside if (['text'].indexOf(message.type) > -1).

Same way it does inside if (message.type === 'text')

Actual behavior:
message is typed as TextMessage | ImageMessage inside the if block

if (['text'].indexOf(message.type) > -1) {
    // Error: Property 'text' does not exist on type 'ImageMessage'
    message.text = message.text.trim();
}

Playground Link: Provided

Related Issues: #9842

My argument is that if (message.type === 'text') should be considered equivalent to if (['text'].includes(message.type)).

It might seem irrelevant on a small example like this, but if the array (['text']) is large, the workaround is difficult to maintain.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Too Complex An issue which adding support for may be too complex for the value it adds labels Jan 21, 2020
@RyanCavanaugh
Copy link
Member

This pattern doesn't occur often enough in ways that would produce useful narrowings to justify implementing whatever we'd have to do to detect it

@iansan5653
Copy link

iansan5653 commented Jun 12, 2020

If #36352 were revisited, the includes function could be a lot more useful if the return type were an assertion, like:

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

Then, this would be fine:

if(["text"].includes(message.type)) {
  message.type; // "text"
}

IMO this is a much better way to type it than the current restriction, considering that the way it's currently typed makes includes pretty much useless with arrays of enumerated types.

indexOf is a bit trickier and I wouldn't necessarily support changing that definition.

@JGJP
Copy link

JGJP commented Oct 1, 2020

@RyanCavanaugh What is the reason for practically making .includes() useless in conditionals? Is it really "we don't think enough people do this"? What is this assumption based on?

@RyanCavanaugh
Copy link
Member

@JGJP We didn't make it behave this way; this is the behavior absent the implementation of a feature that would cause it behave the way the OP is proposing. None of the existing narrowing mechanics apply here; we're talking about probably a thousand lines of new code to correctly detect and narrow arrays based on these methods, along with a performance penalty paid by every program because the control flow graph would have to be more granular to set up the possible narrowings caused by any method call, along with a bug trail and confusion trail caused by people expecting non-method versions of these functions to also behave the same way.

The proposed includes definition above is not correct because that's now how type guards work. You can trivially induce an unsoundness to occur using that definition:

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();
}

@JGJP
Copy link

JGJP commented Oct 2, 2020

@RyanCavanaugh Thanks for your detailed reply.

I can appreciate that it could be super difficult to implement what is suggested in this issue, but how about just not implicitly typing the array? Why are other variables implicitly typed as any but these arrays here aren't any[]? I would personally prefer that behavior, because it would allow us to use .includes() for its intended purpose (IMO) and we could configure the type error with noImplicitAny. Typescript is supposed to be building on Javascript, but in this case it's restrictive, and I don't see how the default behavior is useful (if not assigned to a variable).

@RyanCavanaugh
Copy link
Member

I'm not sure what you're proposing.

The current typing detects many kinds of errors, like

function fn(s: string) {
  const bannedUsers = ["bob", "alice"];
  if (bannedUsers.includes(s.toLowerCase)) {
    // you are banned

@JGJP
Copy link

JGJP commented Nov 18, 2020

@RyanCavanaugh
Sorry for the late reply. What we want to be able to do is something like this:

function fn(s: string | null) {
	const bannedUsers = ["bob", "alice"]
	if (bannedUsers.includes(s)) {
		// you are banned
	}
}

Here, bannedUsers is being implicitly cast as string[], which IMO is useful in some cases, but here the includes() call gives:

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

It also won't let us do things like bannedUsers.push(somethingThatsNotAString)

What solves these issues is changing the bannedUsers definition to:

const bannedUsers = ["bob", "alice"] as any[]

I would personally prefer it if any[] was the default type of an array, instead of Typescript trying to type it without being able to look into the future to check usage. I can understand, however, that other users won't want this as the default behavior, so how about being able to set an option in tsconfig.json?

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Nov 18, 2020

@JGJP see #26255; the problem here is that includes should really be bivariant instead of covariant. There's no way we would add an option for making all arrays any[]; just use JavaScript if you don't like type errors 😉

@JGJP
Copy link

JGJP commented Nov 19, 2020

@RyanCavanaugh I'm wondering why you're linking that issue, because it just shows the community making reasonable arguments and trying to come up with a solution, whereas your general stance seems to just be just use Javascript. Is this really the best we can do?

@RyanCavanaugh
Copy link
Member

@JGJP Why doesn't TypeScript already have every feature it will eventually need? The answer is that we haven't designed or completed those features yet, which is why we have an issue tracker and are employing a large team of developers to work on it. Having humans work on the product at a finite speed is in fact the best we can do, for now.

@RyanCavanaugh
Copy link
Member

Here's a declaration you can put in your project if you want the proposed "anything goes" behavior

interface Array<T> {
  includes(element: unknown, startIndex?: number): boolean;
}

@iansan5653
Copy link

The proposed includes definition above is not correct because that's now how type guards work. You can trivially induce an unsoundness to occur using that definition.

@RyanCavanaugh Thanks for the example. So just to clarify, the issue here is that there is no way to express something like "if true, element is T, else element may or may not be T"?

@RyanCavanaugh
Copy link
Member

@iansan5653 correct; see #15048

@ilibar-zpt
Copy link

ilibar-zpt commented May 30, 2022

@RyanCavanaugh what do you think about this example?

const superset = ['a','b','c'] as const
const subset = ['a','b'] as const
type Test<T extends typeof superset[number] = typeof superset[number]> = {
    'a': {a:1,t:T},
    'b': {b:2,t:T},
    'c': {c:3,t:T},
}[T]
const test = {} as unknown as Test
if (subset.includes(test.t)) // error 

Array.prototype.includes is intended for testing whether an element of a superset is part of a subset, current lib clearly misses that and requires that subset === superset

re microsoft/TypeScript-DOM-lib-generator#973

PS: Posted here because linked issue doesn't seem to have any discussion going and related question already was under discussion here

@HolgerJeromin
Copy link
Contributor

More simple example:

Argument of type '"string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"' is not assignable to parameter of type '"number" | "bigint"'.
Type '"string"' is not assignable to type '"number" | "bigint"'.

const value = 'hello';
const allowedTypes = ['number', 'bigint'] as const;
const numberIsh = allowedTypes.includes(typeof value);

@jaredrethman
Copy link

Echo'ing @HolgerJeromin findings. Wound up here trying to find information on the below scenario.

const varStr: string | number | object = 7;

if (["string", "number"].includes(typeof varStr)) {
  const foo = varStr; // foo is still inferred as "const foo: string | number | object"
}

@johnsoncodehk
Copy link

Here's a declaration you can put in your project if you want the proposed "anything goes" behavior

interface Array<T> {
  includes(element: unknown, startIndex?: number): boolean;
}

#52451 is an alternative, appreciate if you can help fix tests, otherwise I'll check it out when I have time.

@typescript-bot
Copy link
Collaborator

This issue has been marked as "Too Complex" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@typescript-bot typescript-bot closed this as not planned Won't fix, can't repro, duplicate, stale Jun 21, 2023
@PieterjanDeClippel
Copy link

PieterjanDeClippel commented Jun 23, 2024

Shouldn't tsc be able to simply transform an expression like

// This doesn't work
const quality: 'low' | 'medium' | 'high' | number = 5;
if (['low', 'medium', 'high'].includes(quality)) {
   // quality = 'low' | 'medium' | 'high' | number
} else {
    // quality = 'low' | 'medium' | 'high' | number
}

to

// This works
if ((quality === 'low') || (quality === 'medium') || (quality === 'high')) {
    // quality = 'low' | 'medium' | 'high'
} else {
    // quality = number
}

Internally?

@RyanCavanaugh
Copy link
Member

That's not going to be a very common way to do things -- once you need it more that once, you'll move to the array to a local, and once it's not a literal, it's not sound to negatively narrow. You can legally write

const arr: ('low' | 'medium' | 'high')[] = [];
if (arr.includes(quality)) {
  // not hit, of course
} else {
  // 'quality' can be anything; cannot safely conclude it is number here
}

@Gin-Quin
Copy link

Gin-Quin commented Apr 7, 2025

I think this should be reopened.

I agree with @iansan5653 that the includes method should definitely have this typing:

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

There are many common patterns where you need this.

Example 1: you have an constant array of strings and you want to test if a user message is part of this array.

let [userInput, setUserInput] = useState("")
const forbiddenInputs = ["danger", "warn"] as const

// the following line triggers a TS error
if (forbiddenInputs.includes(userInput)) {
  console.log("Forbidden!")
}

The most common workaround is to do this:

- if (forbiddenInputs.includes(userInput)) {
+ if (forbiddenInputs.includes(userInput as typeof forbiddenInputs[number])) {

But that's obviously not elegant code. You have to lie on the real type of the value just to shut the Typescript compiler.

Another workaround is to remove the as const in the array definition, but maybe you need this as const elsewhere, so that's not a solution.

Example 2: you have a value that is wider than the array.

type Foo = { foo: string }
type Bar = { bar: string }
type FooOrBar = Foo | Bar

const arrayOfFoos: Array<Foo> = [ ... ]

const fooOrBar: FooOrBar = ...

// this code is legit but we get a TS error as well
if (arrayOfFoos.includes(fooOrBar)) {
  ...
}

To solve this, once again we have to lie on the type and use a type assertion:

- if (arrayOfFoos.includes(fooOrBar)) {
+ if (arrayOfFoos.includes(fooOrBar as Foo)) {

I don't know how your typechecker internally works but that seems weird that this is "too complex to implement". You do narrowings in "if statements" already, so that's not a new concept to implement. And I could myself create the right signature and make it work with an utility function:

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

@RyanCavanaugh
Copy link
Member

@Gin-Quin

I agree with @iansan5653 that the includes method should definitely have this typing:

This typing is wrong and would lead to incorrect programs being accepted. This is addressed earlier in the thread

And I could myself create the right signature and make it work with an utility function:

It's not the right signature, though. That's the problem.

@Gin-Quin
Copy link

Gin-Quin commented Apr 14, 2025

Thanks @RyanCavanaugh , I missed that post in the thread.

Interesting situation!

The issue comes from the negation of the narrowing. Fixing this demands indeed some development. Nothing impossible, of course, but it's not trivial.

There are two types of narrowing that I see:

  • if a value IS a type (using the equality symbol),
  • if a value IS IN a set of types (using the includes or has methods, or the in keyword).

I see three levels of fixing the issues mentioned before, from the easiest to the cleanest.

1. Use "unknow" in the signature

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

This solves the issues previously mentioned without having a negative impact on existing codebases (it's more relaxed that current signature).

2. Introduce a new type guard

If we want to really tackle this issue, we need a new kind of type guard. Here can be one syntax, using of (or in) in place of is:

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

This kind of type guard with this of keyword is similar to is, except for the negation part: the type is not narrowed in an else clause (or after a return or a throw in an if).

Example:

function findNumberInArray<V>(array: Array<number>, value: V): V of Array<number> { ... }


let value: string | number

if (findNumberInArray([1, 2, 3], value)) {
  // value is of type number
} else {
  // value's type is unchanged (no narrowing)
}

3. Narrowing negate clause as well

When working with constant tuples, it's possible to narrow the negate clause as well, like with a classic is type guard.

Some examples:

const tupleOfValues = [1, "hello", 3] as const

let value: 1 | "hello" | 4

if (tupleOfValues.includes(value)) {
  // value is 1 | "hello"
} else {
  // value is 4
}

This is some work, nothing trivial (except for solution #1), but that also may be a cool feature to introduce for a next version of TS.

IMO the issues that I mentioned previously are serious issues (forcing a developer to regularly lie on a type with an assertion is very bad), and should not stay in a "not planned" state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Suggestion An idea for TypeScript Too Complex An issue which adding support for may be too complex for the value it adds
Projects
None yet
Development

No branches or pull requests