Skip to content

fix: IsEqual, {a: t, b: s} and {a: t} & {b: s} are equal#1338

Open
taiyakihitotsu wants to merge 35 commits intosindresorhus:mainfrom
taiyakihitotsu:fix/is-equal-20260127
Open

fix: IsEqual, {a: t, b: s} and {a: t} & {b: s} are equal#1338
taiyakihitotsu wants to merge 35 commits intosindresorhus:mainfrom
taiyakihitotsu:fix/is-equal-20260127

Conversation

@taiyakihitotsu
Copy link
Contributor

@taiyakihitotsu taiyakihitotsu commented Jan 27, 2026

Current: #1338 (comment) -> #1338 (comment) -> #1338 (comment) -> #1338 (comment) -> #1338 (comment)


This PR is separated from #1336.

(See #1336 (comment))

Major Changes

The previous (meaning before this PR) IsEqual<{a: 0} & {b: 0}, {a: 0; b: 0}> returns false.
This PR prevents it to return true via SimplifySimplifyDeep if both are extends object.

(intersection of objects, it's deprecated though.
https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#intersection-types)

Minor Changes

Refactor: delete unused import

@taiyakihitotsu
Copy link
Contributor Author

taiyakihitotsu commented Jan 27, 2026

I found this definition is incorrect, so made this PR to draft now.

I reopen it after fixing it.

Reopened.

@taiyakihitotsu taiyakihitotsu marked this pull request as ready for review January 27, 2026 18:33
@taiyakihitotsu
Copy link
Contributor Author

taiyakihitotsu commented Jan 27, 2026

@som-sm

I've finally fixed it.

Now IsEqual<A, B> returns true correctly if A and B are objects and mutually assignable.
This means IsEqual now treats {a: t, b: u} as equal to {a: t} & {b: u} recursively.

I also removed the test case (commited in dabe872) below because the four new test cases in this PR already cover it. (Please let me know if I missed anything.)

// Distinct whether an object is merged by `&` or via `Simplify`, to ensure Branded Types are handled strictly.
export type IntersectionMerge<tuple extends readonly unknown[]> = Except<tuple, 'length'> & {__brand: 'tag'};
type SampleTuple = [0, 1, 2];

expectType<true>({} as IsEqual<{a: 0} & {b: 0}, {a: 0; b: 0}>);
expectType<true>({} as IsEqual<Simplify<IntersectionMerge<SampleTuple>>, Merge<IntersectionMerge<SampleTuple>, IntersectionMerge<SampleTuple>>>);

Thanks!

…urns `true` with intersection object and expanded object
@taiyakihitotsu
Copy link
Contributor Author

taiyakihitotsu commented Jan 27, 2026

I found this case failed even if just using SimplifyDeep in IsEqual.

expectType<true>({} as IsEqual<{aa: {a: {x: 0} & ({y: 0} | {y: 0})} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents
export type IsEqual<A, B> =
	[A, B] extends [B, A]
		? [A, B] extends [object, object]
			? _IsEqual<SimplifyDeep<A>, SimplifyDeep<B>>
			: _IsEqual<A, B>
		: false;

For example, this is a simple case.
https://github.com/taiyakihitotsu/type-fest/blob/fb14a98d577d543ba3eebab454f28fda5a0b5b5f/source/internal/type.d.ts#L218

type NT = _IsEqual<{z: {a: 0}}, {z: {a: 0} | {a: 0}}>; // => false

Using SimplifyDeep can eliminate intersections of A & A which should be equal to A, and {a: t} & {b: u} which should be equal to {a: t, b: u}.
But it cannot remove unions like A | A which should be equal to A.

To remove duplicated types in union, I defined UniqueUnionDeep in internal/type.d.ts.
(I don't know this helper will be needed for the other definitions though.)


Please let me know if I've missed any cases.

@taiyakihitotsu taiyakihitotsu marked this pull request as draft January 28, 2026 05:35
@taiyakihitotsu
Copy link
Contributor Author

taiyakihitotsu commented Jan 28, 2026

After a sleep, I've found another error in the definition of UniqueUnionDeep.

export type UniqueUnion<U> = _UniqueUnion<U, never>;
type _UniqueUnion<U, R> =
	LastOfUnion<U> extends infer K
		? [K] extends [never]
			? R
			: _UniqueUnion<Exclude<U, K>, K extends R ? R : R | K>
		: never;

extends is completely wrong here.

 _UniqueUnion<Exclude<U, K>, K extends R ? R : R | K>

I will reopen after fixing it.

Reopened.

@taiyakihitotsu taiyakihitotsu marked this pull request as ready for review January 28, 2026 21:23
@taiyakihitotsu
Copy link
Contributor Author

taiyakihitotsu commented Jan 28, 2026

Status

I've added 5 helper type functions in source/internal/type.d.ts.

  • MatchOrNever: used to define ExcludeExactly
  • ExcludeExactly: used to define UniqueUnion, which would "fix" Exclude / ExcludeStrict as below.
  • LastOfUnion: used to define UniqueUnion
  • UniqueUnion: used to define UniqueUnionDeep
  • UniqueUnionDeep: used to define IsEqual

This PR passes those test cases (which fails before this PR):

// Ensure `{a: t; b: s}` === `{a: t} & {b: s}`, not equal to `{a: u} & {b: v}` if `u` !== `t` or `v` !== `s`.
expectType<true>({} as IsEqual<{a: 0} & {b: 0}, {a: 0; b: 0}>);
expectType<true>({} as IsEqual<{aa: {a: {x: 0} & {y: 0}} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>);
// Ensure `{a: t} | {a: t}` === `{a: t}`
expectType<true>({} as IsEqual<{a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents
expectType<true>({} as IsEqual<{aa: {a: {x: 0} & ({y: 0} | {y: 0})} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents

I've submitted this to fix IsEqual, ready for review.
And I need a discussion about ExcludeExactly.

Background

I found the bug described above, where unions inside recursive objects cannot be compared directly by the previous IsEqual definition which used SimplifyDeep.

To resolve this, I implemented UniqueUnion to deduplicate types within unions.

export type UniqueUnion<U> = _UniqueUnion<U, never>;
type _UniqueUnion<U, R> =
	LastOfUnion<U> extends infer K
		? [K] extends [never]
			? R
			: _UniqueUnion<
				Exclude<U, K>, // (1)
				(<G>() => G extends K & G | G ? 1 : 2) extends
				(<G>() => G extends R & G | G ? 1 : 2)
					? [R, unknown] extends [never, K]
						? K
						: R
					: R | K>
		: never;

I think this is the idiomatic way to iterate over union types, but it didn't work as expected.
I finally found that Exclude (at (1)) doesn't distinguish between {readonly a: 0} and {a: 0} for the following reason:: https://www.typescriptlang.org/docs/handbook/utility-types.html#excludeuniontype-excludedmembers

Constructs a type by excluding from UnionType all union members that are assignable to ExcludedMembers.

ExcludeStrict in type-fest is also the same.
(I commented those details in the ExcludeExactly section in source/internal/type.d.ts.)

So I decided to define ExcludeExactly to satisfy requirement (1) which must distinguish readonly and optional avoid incorrectly removing {a: 0} when compared to {readonly a: 0}.

And I added the helper function: MatchOrNever.
This MatchOrNever has a similar construct to _IsEqual, so it has the same limitations as noted in the comments.

export type MatchOrNever<A, B> =
	[unknown, B] extends [A, never]
		? A
		// This equality code base below doesn't work if `A` is `unknown` and `B` is `never` case.
		// So this branch should be wrapped to take care of this.
		: (<G>() => G extends A & G | G ? 1 : 2) extends (<G>() => G extends B & G | G ? 1 : 2)
			? never
			: A;

I've finished creating the strict IsEqual.

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

@som-sm

PR for IsEqual here

Now IsEqual is able to judge the four cases described above correctly.

But this is involving many diffs in internal.
I want to point them out, e.g., in case I missed some test cases.

PR for ExcludeExactly

Should I separate this PR and create another branch to fix ExcludeStrict?
I think this is not necessarily "a bug" because the ExcludeStrict comment refers to assignability here:

A stricter, non-distributive version of extends for checking whether one type is assignable to another.

But I also think that another version of Exclude is needed to maintain strict types.

If there is no need to touch ExcludeStrict, I would like to propose adding ExcludeExactly as a new type.

Is that okay?
Or should it be kept as a local helper function in this PR?

Thanks for reading!

@taiyakihitotsu
Copy link
Contributor Author

taiyakihitotsu commented Feb 3, 2026

Note:
Open to draft because of #1345

Reopen.

@taiyakihitotsu taiyakihitotsu marked this pull request as ready for review February 3, 2026 16:39
@taiyakihitotsu taiyakihitotsu marked this pull request as draft February 5, 2026 15:19
@taiyakihitotsu
Copy link
Contributor Author

taiyakihitotsu commented Feb 5, 2026

See this PR: #1349


And I found another bug in UnionToTuple:

type DifferentModifiers = {a: 0} | {readonly a: 0} | {a?: 0} | {readonly a?: 0};
expectType<UnionToTuple<DifferentModifiers>[number]>({} as DifferentModifiers);

Expected this to pass, but npm test says:

  test-d/union-to-tuple.ts:16:0
  ✖  16:0  Parameter type { readonly a?: 0; } is not identical to argument type DifferentModifiers.

UnionToTuple in this PR uses ExcludeExactly instead of the built-in Exclude or ExcludeStrict in type-fest to fix it.


To separate this point from this PR, LastOfUnion and ExcludeExactly should be defined independently.

And we should avoid duplicate definitions.
LastOfUnion was defined as a local in IsNever.

So I will make a separate PR for it, including:

  • LastOfUnion: Useful to define recursion of union types.
  • ExcludeExactly: Stricter version of built-in Exclude and type-fest's ExcludeStrict.
  • UnionToTuple: Fix the above case, improve performance.

@taiyakihitotsu
Copy link
Contributor Author

taiyakihitotsu commented Feb 5, 2026

NOTE

944a74b

This change is a temporary fix to trigger the CI.
I’m keeping this PR as a Draft until the PR #1347 is merged, even if there are no conflicts here.


#1347 was merged, but this PR waits for #1349 as well.

@taiyakihitotsu
Copy link
Contributor Author

@sindresorhus

Thank you!

I’ve left source/exclude-exactly.d.ts part as is because it’s waiting for #1349 to be merged.
These will be resolved automatically.

While adding the tests you suggested, I discovered a similar issue about lambda arguments and included an update for it as well.
(I'll post about it here.)

@taiyakihitotsu
Copy link
Contributor Author

Found another edge case where lambda arguments and return types are identical unions or intersections.

This isn't a regression; the previous UniqueUnionDeep simply didn't support it, so I've included a fix.


https://www.typescriptlang.org/play/?#code/JYWwDg9gTgLgBAbwKYA8xIMYwCoE90C+cAZlBCHAOQwDOAJpQNwCwAUGzPknAHICuIAEZIoAGQCGQuuLgBeOAAoAbuIA2fJAC5E47QDsBwqAQCUcgHyJB+wyIItWndL1tjJg6QEk9METUwwwBB6cooq6lo6NkJ2cABkUXAGMcZmspYI1kmu9hxcLikSUuIAqnpBIfLKahraCLrZKUQAPonJRqYWVtEdDk7c-IXu0gBKSDB8UHrevlD+WBWh1RF1De12aRlZ68bx3Y29ec6DRkUe4mMTU2WLVeG1bTmb+zstLzkOR9wA+p40AKIARz4agAPABBAA0cAAQpZZGwAJAKUEAcXMCmeqLgqF8ejoNDg4L22Na2IA-HAAIxwbQAJjMuKQ+JoSJR6MxXWxTJZsJJcDJcEpNPpJiRiMpMCgGnF2mIan8n1YqHQWDw6FBUo0GIQRHEhN+AOBYJOIjOXh8fgCFWhprcxXMJgcAHpnekcVAyFA2CqAuqkJrpUgdXqDX8gSDVKC7ebSuVgrbXLHHS63ZYRF6fWg-VxA9qFLq4Pq4IaIyak8MLuNJtNLXNrQmCqdKym2K73RnoFnVThc1rgwXQyXw8aozHK5cazdG+OHU622mPV6gA

import {expectType} from 'tsd';

type NumberLambda = (value: {a: number}) => {b: number};
type NumberLambdaIntersection = (value: {a: number} & {a: number}) => {b: number};
type NumberLambdaUnion = (value: {a: number} | {a: number}) => {b: number};
type NumberLambdaReturnIntersection = (value: {a: number}) => {b: number} & {b: number};
type NumberLambdaReturnUnion = (value: {a: number}) => {b: number} | {b: number};

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

expectType<true>({} as _IsEqual<NumberLambdaIntersection, NumberLambda>);
//=> error
expectType<true>({} as _IsEqual<NumberLambdaUnion, NumberLambda>);
//=> error
expectType<true>({} as _IsEqual<NumberLambdaReturnIntersection, NumberLambda>);
//=> error
expectType<true>({} as _IsEqual<NumberLambdaReturnUnion, NumberLambda>);
//=> error

@taiyakihitotsu taiyakihitotsu force-pushed the fix/is-equal-20260127 branch from 5a40423 to 6497043 Compare March 20, 2026 19:20
@taiyakihitotsu
Copy link
Contributor Author

I've reopen this PR.

I decided to re-introduce my definition ExcludeExactly to resolve this following error in test-d/exact.ts, despite our previous discussion: #1349 (comment)

  test-d/exact.ts:554:1
  ✖  554:1  Type of property a circularly references itself in mapped type SimplifyUniqueUnionDeep<A>.

I'm not entirely sure if this is the ideal solution.

It seems the error is triggered by the Exact definition using the new IsEqual introduced in this PR.
If Exact doesn't require extreme strictness, we can move _IsEqual from source/is-equal.d.ts to source/internal/type.d.ts then export it under a new name for use within Exact.

I've verified this approach also resolves the circular reference issue.

// source/is-equal.d.ts
type _IsEqual<A, B> =
	(<G>() => G extends A & G | G ? 1 : 2) extends
	(<G>() => G extends B & G | G ? 1 : 2)
		? true
		: false;

@som-sm
What do you think?


As a side note, the current IsEqual in this PR now correctly handles identical union/intersection types within tuples, records, and lambda arguments/returns.

@taiyakihitotsu taiyakihitotsu marked this pull request as ready for review March 21, 2026 13:10
@sindresorhus
Copy link
Owner

IsEqual now runs function types through the deep union-deduping helper, which rebuilds callables from Parameters<T> and ReturnType<T>. That drops extra callable structure instead of preserving it.

Minimal repro:

type A = ((value: number) => void) & {foo?: 1};
type B = (value: number) => void;

type C = {
	(value: 'a'): 1;
	(value: string): 1;
};
type D = (value: string) => 1;

On this branch, IsEqual<A, B> and IsEqual<C, D> both evaluate to true.
With the current main equality implementation, both evaluate to false.

So this change makes distinct function types compare equal. That also means any utility that short-circuits on IsEqual can now accept mismatched callables a bit too early.

@taiyakihitotsu
Copy link
Contributor Author

@sindresorhus

Thanks!

Firstly, I added these test cases in test-d/is-equal.ts.

I pasted them in 2 separate sections.
The tail comments were removed in the latest commits.
Note that the (not) type error comments represent the status as of 8a7056b.

// Branded Type with Tuple
expectType<false>({} as IsEqual<[0, 1] & {foo?: 1}, [0, 1]>) // circular ref
expectType<true>({} as IsEqual<[0, 1] & {foo?: 1}, [0, 1] & {foo?: 1}>) // circular ref
// Branded Type with Primitive
expectType<false>({} as IsEqual<1 & {foo?: 1}, 1>); // not type error
expectType<true>({} as IsEqual<1 & {foo?: 1}, 1 & {foo?: 1}>); // not type error
// Branded Type with Lambda
expectType<false>({} as IsEqual<{bar: 'a'} & {foo?: 1}, {bar: 'a'}>); // not type error
expectType<false>({} as IsEqual<((value: number) => void) & {foo?: 1}, (value: number) => void>); // type error

// Overload
expectType<false>({} as IsEqual<{(value: 'a'): 1; (value: string): 1;}, (value: string) => 1>); // type error
expectType<false>({} as IsEqual<{(value: 'a'): 1; (value: string): 1; key: 'value'}, (value: string) => 1>); // not type error
expectType<false>({} as IsEqual<{(value: 'a'): 1; (value: string): 1; key: 'value'}, {(value: string): 1; key: 'value'}>); // type error

The first two "Branded Type with Tuple" cases were caused by SimplifyDeep (used in UniqueUnionDeep in source/internal/type.d.ts).
I have fixed this by removing them in commit: eb8828b

The second set of cases addresses the points raised in the review (I also added more branded type cases).
I implemented logic similar to PartialDeep logic to fix this by avoiding the evaluation of objects that include overloads.
The commit: 4f490d2

T extends (...arguments_: any[]) => unknown
	? IsNever<keyof T> extends true
		? T // For functions with no properties
		: HasMultipleCallSignatures<T> extends true
			? T
			: ((...arguments_: Parameters<T>) => ReturnType<T>) & PartialObjectDeep<T, Options>

My next steps will be:

  • Instead of completely bypassing the evaluation of objects containing overloads, I'll simplify identical union/intersection types within their non-overload elements.
  • Address Set and similar structures, at least those handled in PartialDeep.

To keep things on track, I'm posting this as an interim report in response to the review.

I'll follow up once the two points above are resolved.

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