Skip to content

Fix: is-equal fails in some cases when intersecting wrapped types (F<[A]> & G<[A]>)#1231

Merged
sindresorhus merged 14 commits intosindresorhus:mainfrom
taiyakihitotsu:fix/equal-tuple-case
Sep 20, 2025
Merged

Fix: is-equal fails in some cases when intersecting wrapped types (F<[A]> & G<[A]>)#1231
sindresorhus merged 14 commits intosindresorhus:mainfrom
taiyakihitotsu:fix/equal-tuple-case

Conversation

@taiyakihitotsu
Copy link
Contributor

Hello.

This PR fixes an edge case of is-equal.d.ts:
(Sorry if I've made any mistakes with the PR or coding, and please forgive me if my English isn't very good...)

Assuming F and G return ['something', true] if the extends condition is matched, or ['something', false] if not.
If we want to intersect thir return types (F<A> & G<A>) and expect to get never so that IsEqual<It, never> evaluates to true, but it instead returns false when A is wraped as [A] and the right side of the extends in both F and G is also wrapped.

This is the case I want to fix.

This case occurs, as shown below, when a type variable is introduced via infer.

A concrete example is easier to understand, so I will provide an overview below.

// // [false, never] case
type __TupleRetTupleBool_wraped0<Fst> = (Fst extends [[0, 2]] ? ['A', true] : ['A', false])
type __TupleRetTupleBool_wraped1<Fst> = (Fst extends [[0, 1]] ? ['A', true] : ['A', false])

type FFF_wraped<Fst> = [IsEqual<(__TupleRetTupleBool_wraped0<[Fst]> & __TupleRetTupleBool_wraped1<[Fst]>), never>, (__TupleRetTupleBool_wraped0<[Fst]> & __TupleRetTupleBool_wraped1<[Fst]>)]
type __TupleRetTupleBool_wrapedr = __TupleRetTupleBool_wraped0<[[0,2]]> // ['A', true]
type __TupleRetTupleBool_wraped1r = __TupleRetTupleBool_wraped1<[[0,2]]> // ['A', false]

type check_FFF_wraped = FFF_wraped<[0,2]> // [false,never] before fix.
type expand_FFF_wraped = IsEqual<(__TupleRetTupleBool_wraped0<[[0,2]]> & __TupleRetTupleBool_wraped1<[[0,2]]>), never> // true before/after fix.
type infer_FFF_wraped = [0,2] extends infer Fst ? IsEqual<(__TupleRetTupleBool_wraped0<[Fst]> & __TupleRetTupleBool_wraped1<[Fst]>), never> : never // false before fix.

// // [true, never] case
type __TupleRetTupleBool0<Fst> = (Fst extends [0, 2] ? ['A', true] : ['A', false])
type __TupleRetTupleBool1<Fst> = (Fst extends [0, 1] ? ['A', true] : ['A', false])

type FFF_notwrap<Fst> = [IsEqual<(__TupleRetTupleBool0<Fst> & __TupleRetTupleBool1<Fst>), never>, (__TupleRetTupleBool0<Fst> & __TupleRetTupleBool1<Fst>)]
type __TupleRetTupleBool0r = __TupleRetTupleBool0<[0,2]> // ['A', true]
type __TupleRetTupleBool1r = __TupleRetTupleBool1<[0,2]> // ['A', false]

type check_FFF_notwrap = FFF_notwrap<[0,2]> // [true,never] before/after fix.
type expand_FFF = IsEqual<(__TupleRetTupleBool0<[0,2]> & __TupleRetTupleBool1<[0,2]>), never> // true before/after fix.
type infer_FFF = [0,2] extends infer Fst ? IsEqual<(__TupleRetTupleBool0<Fst> & __TupleRetTupleBool1<Fst>), never> : never // true before/after fix.

Thanks in advance!

@sindresorhus
Copy link
Owner

Thanks for contributing.

@sindresorhus
Copy link
Owner

AI review:


  • Don’t export internals. _IsEqual and _NonNever should be non-exported helpers to avoid expanding the public surface and accidental consumer imports. Rename _NonNeverNonNever (PascalCase) if you keep it. ([GitHub][1])

  • Restrict recursion to tuples only and support readonly. Your A extends [infer HeadA, ...infer TailA] also matches generic arrays and can recurse forever on T[]. Guard with IsTuple<A>/IsTuple<B> (Type-Fest already has it) and pattern-match readonly [infer …]. This prevents runaway recursion on arrays like number[]. ([GitHub][1])

  • Drop the [] extends TailA & TailB hack. It’s brittle and obscures intent. After guarding for tuples, simply recurse on tails; length mismatch naturally bottoms out when one side stops matching [infer …, ...infer …]. ([GitHub][1])

  • Replace custom never-filter with the standard idiom. _NonNever<A> can be simplified and made clearer via existing IsNever:

    type NonNever<T> = IsNever<T> extends true ? 0 : T;

    This avoids the confusing (<G>() => …) trick and the misleading comment about NonNullable. ([GitHub][1])

  • Keep the proven equality core isolated. Your _IsEqual uses the function-comparison trick and is fine, but it should not be exported and it doesn’t need the long comment. Keep it as the fallback when not both tuples. ([GitHub][1])

  • Fix grammar/typos and tighten comments. For example:

    • “wraped” → “wrapped”
    • “Intersec” → “intersection”
    • Rewrite the _IsEqual comment to a single clear sentence; current text is ungrammatical. Tests should also use readable names instead of __TupleReturnValueTupleBool_wraped0. ([GitHub][1])
  • Add coverage to prevent regressions:

    • IsEqual<number[], number[]> and IsEqual<readonly number[], number[]> (ensure no infinite recursion and expected equality semantics).
    • Mixed readonly tuples: IsEqual<readonly [1,2], [1,2]>.
    • Deep long tuples (e.g., length 30+) to watch instantiation depth. ([GitHub][1])
  • Minor: Keep tests consistent with existing style (expectType<true>(…) is fine) but avoid excessive noise. Collapse the four near-duplicate “wrapped tuple intersection” cases into minimal repros with clear naming.

@taiyakihitotsu
Copy link
Contributor Author

taiyakihitotsu commented Sep 16, 2025

Thank you.

Add coverage to prevent regressions:

Regarding this, I have added several test cases.
Among them, one test case, equalLongTupleNumberAndLongTupleNumber, was a regression caused by my previous commits (sorry about that).

Minor: Keep tests consistent with existing style (expectType(…) is fine) but avoid excessive noise. Collapse the four near-duplicate “wrapped tuple intersection” cases into minimal repros with clear naming.

Regarding this comment, only the test case equalWrappedTupleIntersectionToBeNeverAndNeverExpanded results in an error in the previous definition of IsEqual.
Since there are four branching patterns involving whether the type is inferred and whether it is wrapped as a single-element tuple, and only one of them produces an error, I personally think it is necessary to cover all the error cases for contrast.
While a comment could suffice, I could not find a reason to reduce the related test cases.

PS

During the exploration of the IsEqual definition, I temporarily used the following implementation (which is not included in the current commit):

export type IsEqual<A, B> =
	_NonNeverId<A> extends UnknownArray
		? _NonNeverId<B> extends UnknownArray
			? _IsEqual<IsTuple<_NonNeverId<A>>, IsTuple<_NonNeverId<B>>> extends true
				? A extends [infer HeadA, ...infer TailA]
					? B extends [infer HeadB, ...infer TailB]
						? IsEqual<HeadA, HeadB> extends true
							? IsEqual<TailA, TailB>
							: false
						: _IsEqual<A, B>
					: _IsEqual<A, B>
				: _IsEqual<A, B>
			: _IsEqual<A, B>
		: _IsEqual<A, B>;

With this version, IsEqual becomes boolean rather than false in some cases, and both of the following cases unexpectedly pass type checking:

const notEqualTupleUnionAndTuple: IsEqual<[0, 1] | [0, 2], [0,2]> = false
expectType<false>(notEqualTupleUnionAndTuple);
const notEqualTupleUnionAndTuple: IsEqual<[0, 1] | [0, 2], [0,2]> = true
expectType<true>(notEqualTupleUnionAndTuple);

If this phenomenon is not my oversight, should I open a separate issue and discuss it there?
This relates to the second review comment above, and aside from the observation mentioned here, I have not thoroughly investigated this behavior yet.
Given the uncertainty, it might be safer to add more baseline tests to catch any subtle regressions...

(Translated by AI)

@som-sm
Copy link
Collaborator

som-sm commented Sep 16, 2025

@taiyakihitotsu Even the following solution seems to pass all the existing test cases:

export type IsEqual<A, B> =
	[A] extends [infer A]
		? [B] extends [infer B]
			? _IsEqual<A, B>
			: false
		: false;

type _IsEqual<A, B> =
	(<G>() => G extends A & G | G ? 1 : 2) extends
	(<G>() => G extends B & G | G ? 1 : 2)
		? true
		: false;

So, either we can simplify the existing solution or we need to add tests that fail with the above solution.

@taiyakihitotsu
Copy link
Contributor Author

taiyakihitotsu commented Sep 16, 2025

@som-sm
Thank you!

I'll add other baseline test cases as well, just to be safe.
This bug was found while evaluating a more complex type-level function, so I want to make sure other cases are also covered.

I think it's probably fine.

If I decide to adopt your code, should I just leave a comment to mention that?
Or is there something I should do on Git to reflect that properly? Or else?

Let me know if there's a preferred way to reflect your suggestion properly — I just want to make sure it doesn’t look like I’m claiming credit for it.

@som-sm
Copy link
Collaborator

som-sm commented Sep 16, 2025

If I decide to adopt your code, should I just leave a comment to mention that? Or is there something I should do on Git to reflect that properly? Or else?

Let me know if there's a preferred way to reflect your suggestion properly — I just want to make sure it doesn’t look like I’m claiming credit for it.

No attribution required, feel free to just copy paste the code.

If you really want to, you can add co-authors in your commits.

@taiyakihitotsu
Copy link
Contributor Author

@som-sm

Understood.
I will add the co-authors when I work on it later.
Please let me know if there are any issues afterwards.

@som-sm
Copy link
Collaborator

som-sm commented Sep 16, 2025

Understood.
I will add the co-authors when I work on it later.
Please let me know if there are any issues afterwards.

If you're planning to add me as a co-author, then you'd need my email. Use:

 Co-authored-by: Som Shekhar Mukherjee <49264891+som-sm@users.noreply.github.com>

taiyakihitotsu and others added 2 commits September 17, 2025 08:19
Co-authored-by: Som Shekhar Mukherjee <49264891+som-sm@users.noreply.github.com>
@taiyakihitotsu
Copy link
Contributor Author

I found and added those three test cases, along with one simple baseline case ([never], [never]).
(IsEqual is by @som-sm, and IIIsEqual is my own implementation, which I commited.)

These tests demonstrate that @som-sm's definition is correct.

type Greet = {
  (fullName: string): string;
  (firstName: string, lastName: string): string;
};

type InferGreet = Greet extends infer F ? F extends (0 extends 0 ? F : 1) ? [F] : 2 : 3

type IIexpectTrueIntersectionGreet = IIIsEqual<[Greet] & [Greet], InferGreet>// false
type expectTrueIntersectionGreet = IsEqual<[Greet] & [Greet], InferGreet> // true

type StringArrayReturn = {
  (s: string): string[];
  (ss: string[]): string[];
};
type InferStringArrayReturn = StringArrayReturn extends infer F ? F extends (0 extends 0 ? F : 1) ? [F] : 2 : 3

type IIexpectTrueIntersectionStringArrayReturn = IIIsEqual<[StringArrayReturn] & [StringArrayReturn], InferStringArrayReturn> // false
type expectTrueIntersectionStringArrayReturn = IsEqual<[StringArrayReturn] & [StringArrayReturn], InferStringArrayReturn> // true

type SomeTypeReturn = {
  <T>(value: T): T;
  <T>(value: T[]): T[];
};
type InferSomeTypeReturn = SomeTypeReturn extends infer F ? F extends (0 extends 0 ? F : 1) ? [F] : 2 : 3

type IIexpectTrueIntersectionSomeTypeReturn = IIIsEqual<[SomeTypeReturn] & [SomeTypeReturn], InferSomeTypeReturn> // false
type expectTrueIntersectionSomeTypeReturn = IsEqual<[SomeTypeReturn] & [SomeTypeReturn], InferSomeTypeReturn> // true

(This may be due to the fact that intersection types like [T] & [T] are not simplified in TypeScript.)

I also added @som-sm as a co-author. Please take a look.

@taiyakihitotsu
Copy link
Contributor Author

  • I simplified the definition of IsEqual, as all tests were passing.
  • I also simplified the test output.
  • Replaced const with declare const.

I had completely overlooked the type narrowing problem.
I just learned that using declare const can solve this issue — thank you !

The following remark in your review was exactly about this issue, it seems.

I'm a bit confused, all these three new tests seem to pass with all the implementations of IsEqual, i.e. these tests don't fail with the current implementation, nor with the ones you and I suggested.

Here is the branch where I was testing the failing definition:

https://github.com/taiyakihitotsu/type-fest/tree/fix/equal-tuple-case-wip-false

@som-sm
Copy link
Collaborator

som-sm commented Sep 19, 2025

I'm a bit confused, all these three new tests seem to pass with all the implementations of IsEqual, i.e. these tests don't fail with the current implementation, nor with the ones you and I suggested.

@taiyakihitotsu I'm still not sure if those test cases are required. If not, let's remove them. If they are required, can you tell me with which implementation of IsEqual those cases fail?

Everything else LGTM!

@taiyakihitotsu
Copy link
Contributor Author

@som-sm

Thank you, and apologies for the lack of explanation earlier.

  • The initial definition before this PR.
    failed: equalWrappedTupleIntersectionToBeNeverAndNeverExpanded
  • The definition I committed as a fix for that issue, in turn, failed the following tests.
    failed: equalTrueIntersectionOverloadFunction, equalTrueIntersectionOverloadFunctionStringArrayReturn, equalTrueIntersectionOverloadFunctionSomeTypeReturn.
  • The current definition which you suggested resolves all of the above and passes every test case.

I've kept the three test cases that were previously passed even by the initial definition as a precaution because they could fail with some alternative definitions.

@som-sm
Copy link
Collaborator

som-sm commented Sep 19, 2025

@taiyakihitotsu Ok, gotcha, thanks for the clarification.

I've kept the three test cases that were previously passed even by the initial definition as a precaution because they could fail with some alternative definitions.

Yeah, that's fine!


  • The definition I committed as a fix for that issue, in turn, failed the following tests.
    failed: equalTrueIntersectionOverloadFunction, equalTrueIntersectionOverloadFunctionStringArrayReturn, equalTrueIntersectionOverloadFunctionSomeTypeReturn.

In that case, I don't think this is related to function overloading, looks like the test could be simplified to this:

// [T] & [T] is not simplified
declare const tupleIntersection: IsEqual<[{a: 1}] & [{a: 1}], [{a: 1}]>; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents
expectType<true>(tupleIntersection);

Even this fails with the earlier proposed implementation. Also, I think having just one test for this should be fine, right?

@taiyakihitotsu
Copy link
Contributor Author

taiyakihitotsu commented Sep 19, 2025

@som-sm

In that case, I don't think this is related to function overloading, looks like the test could be simplified to this:

Thanks, I didn’t know that.

I had assumed that both narrowing and intersection were involved in determining the test result, since in the three test cases above, the outcome varied depending on whether declare const or const was used — as was pointed out in a previous review.
↑ Sorry, my comment was off the mark

If the test can be sufficiently covered using just intersection, I think that should be fine too.

Co-authored-by: Som Shekhar Mukherjee <49264891+som-sm@users.noreply.github.com>
@taiyakihitotsu
Copy link
Contributor Author

@som-sm

I pushed this change. #1231 (comment)

Copy link
Collaborator

@som-sm som-sm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, nice work!!

@sindresorhus sindresorhus merged commit 5af60a1 into sindresorhus:main Sep 20, 2025
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants