Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why does a tuple union expect `never` as `.includes()` argument?

Tags:

typescript

type Word = "foo" | "bar" | "baz";

const schema = {
  foo: ["foo"] as const,
  bar: ["bar"] as const,
  baX: ["bar", "baz"] as const,
};

const testFn = (schemaKey: keyof typeof schema, word: Word) => {
  const array = schema[schemaKey]
  array.includes(word);
//               ^
// TS2345: Argument of type 'string' is not assignable to parameter of type 'never'.    
//  Type 'string' is not assignable to type 'never'
}

Playground link

Why does this happen? It seems just silly to me... or is there something I'm missing? How do I reasonably work around this? Do I just assert to never, or are there hidden caveats?

like image 419
Michal Kurz Avatar asked Nov 01 '25 21:11

Michal Kurz


1 Answers

Let's take a look at some of the types. We have

array: readonly ["foo"] | readonly ["bar"] | readonly ["bar", "baz"]

Now, the include method on a T[] takes an argument of type T, i.e. an argument of the correct type for the list. This means that, in Typescript, it's not well-typed to ask whether a string is an element of an array of integers, as that sort of code is likely a mistake to begin with.

Now, we don't have an array. We have a union. You can only call a method on a union if the method is applicable to all of the union options.

["foo"] has a method include which takes a "foo" as argument (that is, the type whose only inhabitant is the literal string "foo". Likewise, ["bar"] has a method include which takes a "bar" as argument, and ["bar", "baz"] has a include which takes an element of the type "bar" | "baz" as argument.

But we don't know which of these we have. We could have any of them. So our include has to take something that would be valid for all three of these. That is, our include must take an argument of type

"foo" & "bar" & "baz"

So we can pass any string which is equal to all three of the above strings. I don't know of any such string, and neither does the Typescript compiler, so it rightly says that that type is uninhabited, i.e. never. There is no type-safe way to call include.

You can any-cast your way around it, like you can everything in Typescript. But the smarter technique is to maintain type safety. See, Typescript is inferring a type too specific for your array variable, a common problem when you start dealing with literal types. But it seems you want the type to be

array : readonly Word[]

which, as it happens, is a perfectly valid supertype of of the type I indicated at the top of this post. We can simply use an explicit type declaration to make the code work

const array: readonly Word[] = schema[schemaKey]

Note that this is not a cast. We're not telling the type system "I know better than you, hush". readonly Word[] is a perfectly valid supertype of the literal union Typescript was inferring. This code is still typechecked and typesafe. We just had to give the compiler a little hint as to our intentions.

like image 60
Silvio Mayolo Avatar answered Nov 03 '25 11:11

Silvio Mayolo