Skip to content

never doesn't equal never when never is keys/values from filtering generic object wrapped inside another object by exact value #51145

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

Open
aspic-fish opened this issue Oct 12, 2022 · 8 comments
Labels
Bug A bug in TypeScript
Milestone

Comments

@aspic-fish
Copy link

Bug Report

🔎 Search Terms

never equals filter mapped type

🕗 Version & Regression Information

It's broken in all tested versions of typescript (4.1.5 until 4.9.0-dev.20221011 in TS playground). Versions prior 4.1.5 do not support mapped types keys filtering.

⏯ Playground Link

Playground minimal code

Playground both keyof and of Values<> cases

💻 Code

So wee need to:

  • Wrap generic argument, lets say O, in an object(i.e {anyKey: O} or just [O]).
  • Then filter object keys by exact value via Equals<> in such a way to pass further empty object.
  • Extract keys or values. Since object is empty never will be passed.
  • And finally compare it with never via Equals<>
// helpers start

type Values<O extends object> =
  O extends any[] 
    ? O[number]
    : O[keyof O]
  
type Equals<A, B> = 
  (<T>() => T extends B ? 1 : 0) extends 
  (<T>() => T extends A ? 1 : 0)
    ? true
    : false

// helpers end

type FilterByStringValue<O extends object> = {
  [K in keyof O as Equals<O[K], string> extends true ? K : never]: any
}

type FilteredValuesMatchNever<O extends object>
  = Equals</*never*/Values</*{}*/FilterByStringValue<[O]>>, never>

type filteredValuesMatchNever = FilteredValuesMatchNever<[]> // false wrong

🙁 Actual behavior

filteredValuesMatchNever is false

🙂 Expected behavior

filteredValuesMatchNever must be true.
Actually with the FilterByStringValue rewritten like this:

type FilterByStringValue<O extends object> = {
  [K in keyof O as Equals<O[K], string> extends true ? K : never]: O[K]
}

filteredValuesMatchNever somehow becomes true

@RyanCavanaugh
Copy link
Member

This definition of Equals is very weird -- where did you find this?

Using a more straightforward definition

type Equals<A, B> = [A] extends [B] ? [B] extends [A] ? true : false : false;

this works as expected, though the more-compact form doesn't for some reason:

// Doesn't work
type Equals<A, B> = [A, B] extends [B, A] ? true : false;

@RyanCavanaugh RyanCavanaugh added the Bug A bug in TypeScript label Oct 12, 2022
@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Oct 12, 2022
@fatcerberus
Copy link

@RyanCavanaugh That particular definition of Equals shows up quite a bit in the larger TS community and IIRC specifically takes advantage of assignability rules for deferred conditional types (isTypeIdenticalTo), to work around some edge cases with the more naive definition. I don’t remember what these cases are off-hand, though.

Explanation here:
https://stackoverflow.com/questions/68961864/how-does-the-equals-work-in-typescript

@Andarist
Copy link
Contributor

Yep, this is a fairly popular type - it allows one to easily check if a type is any or not and stuff like that.

It also comes with a funny behavior related to structurally equivalent intersections:
TS playground

And I suspect that this sort-of thing is responsible for the issue here. Even though the type gets reduced~ to never, its identity is not actually never and thus it fails this Equals check. That probably shouldn't be observable publicly though.

This issue looks quite similar to what I've reported in the past (issue) and what got fixed here. However, I've confirmed that the issue is not the same and while my issue was a regression in 4.4, this one here didn't work before that.

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Oct 13, 2022

The fact that it still repros on the tuple form is pretty interesting, since that should be going through the normal relational path. Though I wonder if the first thing that gets checked is the index signature between the two types.

@Andarist
Copy link
Contributor

This is quite interesting!

We have 3 different Equals implementations here, let's call them as follows:

  • nested: type Equals<A, B> = [A] extends [B] ? [B] extends [A] ? true : false : false;
  • short: type Equals<A, B> = [A, B] extends [B, A] ? true : false;
  • identity-based: type Equals<A, B> = (<T>() => T extends B ? 1 : 0) extends (<T>() => T extends A ? 1 : 0) ? true : false

We also have 2 different implementations of the filtering mapped type:

  • any-value: one with any at the value position of the mapped type, note that different types at this position can also exhibit the same, broken, behavior
  • indexed access value: one with O[K] at the value position of the mapped type

I don't intend to explore all combinations of those 2 sets but I will mention particular variants from both of those.

nested Equals + any-value

This one works correctly: TS playground

  1. this one is recognized as "typical nondistributive conditional type" (here)
  2. so the type parameter gets unwrapped from the single-element tuple in the check type (here)
  3. and thus the check type is classified as instantiatable (here)
  4. based on this TypeScript doesn't attempt to resolve the conditional type (here). Thanks to that the conditional type is left roughly untouched and gets finally resolved when the actual type arguments are supplied to it

short Equals + any value

This one doesn't work correctly: TS playground

  1. this isn't recognized as the typical nondistributive conditional type
  2. its check type is left unwrapped
  3. its instantiated check type is [A, B] and it's not classified as generic here. It turns out that a generic tuple type is only a one with a variadic element (isGenericTupleType)
  4. So the TypeScript attempt to resolve this based on the check here
  5. Resolving involves checking the assignability of the permissive intantiations of checkType ([any, never]) and inferredExtendsType ([never, any])
  6. since any is not assignable to never this assignability check fails and falseType gets selected as the result (here)

short Equals + indexed access value

This one works correctly: TS playground

It's kinda interesting because almost everything here is the same as in the variant described above. However, in here checking the assignability of the permissive intantiations returns a different result despite the fact that we seamingly compare the same types ([any, never] and [never, any]).

So why is that? In here, the any type is actually not the "true any" - it's a "wildcard type" and that is assignable to never. So based on that the overall conditional type stays roughly untouched and gets finally resolved when the actual type arguments are supplied to it.

Note: it would be kinda nice if the wildcard type would get printed as any* or something like that. The same could be done about the silentNeverType (and maybe some others). Since they are printed in the same way, it's fairly easy to not notice the difference between them when debugging.

identity-based Equals + any value

This one doesn't work correctly: TS playground

This is fairly similar to the "short Equals + any value" case. The only difference here is what permissive instantiations we are comparing here for checkType (<T>() => T extends never ? 1 : 0) and inferredExtendsType (<T>() => T extends any ? 1 : 0). Since those are not assignable, we simply select the falseType here too.

@aspic-fish
Copy link
Author

I found out something interesting.
Capture

So ts assumes that this type will always be false and use cached value all the time.
Well, it's right about the type being supposed to always return the same value. But it must always be true rather than false.
FilteredValues wraps object type argument in another one so that resulting object has no string values. Then type is passed to FilterByStringValue where non string values are filtered from the result. So FilterByStringValue here always return {} and FilteredValues should always return never. Thus equality check to never should always be never.

If I substitute FilteredValuesMatchNever type call with it's implementation, the result will be true as desired.

type filteredValuesMatchNever = Equals<FilteredValues<O>, never> // true

I also tried passing never as an argument. And it worked.

type FilteredValuesMatch<O extends object, M> = Equals<FilteredValues<O>, M>

type filteredValuesMatchNeverAsArgument = FilteredValuesMatch<[], never> // true

infer works as well.

type FilteredValuesMatchNeverInfered<O extends object> = 
  Equals<O extends infer R ? FilteredValues<O> : never, never> 

type filteredValuesMatchNeverInfered = FilteredValuesMatchNeverInfered<[]> // true

update

@unional
Copy link
Contributor

unional commented Mar 18, 2023

That particular definition of Equals shows up quite a bit in the larger TS community

It doesn't work well. type-fest uses that implementation and failed some of my tests.

In type-plus, I do this:

export type Equal<A, B, Then = true, Else = false> = [A, B] extends [B, A]
	? And<IsAny<A>, IsAny<B>, Then, Or<IsAny<A>, IsAny<B>, Else, Then>>
	: Else

This version checks against any and boolean vs true | false.

btw Equal<A, B> = [A, B] extends [B, A] ? true : false works correctly with TypeScript 5.0, not in 4.9

UPDATE:

type R = Equal<[number], [any]> // false

🤦

@Andarist
Copy link
Contributor

btw Equal<A, B> = [A, B] extends [B, A] ? true : false works correctly with TypeScript 5

Yep, this was fixed here (in response to this very issue here)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Bug A bug in TypeScript
Projects
None yet
Development

No branches or pull requests

5 participants