Repository navigation
[Feature request]type level equal operator #48100
Description
Activity
RyanCavanaugh commented
on Mar 3, 2022 MemberMore actionsWhat I see in that thread is that people have differing definitions of "equal" that are possibly in conflict, e.g. #27024 (comment). If you want to reopen this please fill out something more substantial in the template that goes into the shortcomings, current trade-offs, and desired semantics.
Ryan Cavanaugh (@RyanCavanaugh) i don't think there was any disagreement on the definition of "equal" but rather people finding edge cases that the workarounds didn't account for. as far as i can tell everyone in that discussion seems to agree on what types should be considered equal.
regardless, i've updated the issue with more information. can it be re-opened (or can the original issue be re-opened)?
RyanCavanaugh commented
on Mar 4, 2022 MemberMore actions{ p: string | number; } should be considered equal to { p: string } | { p: number; }👀
These types are very much different;
T extends { p: string } ? true : falseistrue | falsefor one andfalsefor the otherReacted by yukinotech and 础砜- addedAwaiting More FeedbackThis means we'd like to hear from more people who would be helped by this featureThis means we'd like to hear from more people who would be helped by this featureSuggestionAn idea for TypeScriptAn idea for TypeScript
on Mar 4, 2022 oops, updated with a better example
// for example, below also is true type M = Equal<{ x: 1 } & { y: 2 }, { x: 3, y: 2 }>// for example, below also is true, incorrect type M = Equal<{ x: 1 } & { y: 2 }, { x: 3, y: 2 }>
So, this solution is not quite right.
export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
// for example, below also is true, incorrect type M = Equal<{ x: 1 } & { y: 2 }, { x: 3, y: 2 }>
So, this solution is not quite right.
export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
if you want to handle this case. you can use this :
type Simplify<T> = T extends unknown ? { [K in keyof T]: T[K] } : T; type Equal<X, Y> = (<T>() => T extends Simplify<X> ? 1 : 2) extends (<T>() => T extends Simplify<Y> ? 1 : 2) ? true : false; type M = Equal<{ x: 3 } & { y: 2 }, { x: 3, y: 2 }>
Playground Link
unfortunately that approach still isn't foolproof. it doesn't recursively fix the types:
type M = Equal<{a: { x: 3 } & { y: 2 }}, {a: { x: 3, y: 2 }}> //false
If you want , you can... Just do a recursive simplify
type Simplify<T> = T extends Record<any,unknown> ? { [K in keyof T]: Simplify<T[K]> } : T; type Equal<X, Y> = (<T>() => T extends Simplify<X> ? 1 : 2) extends (<T>() => T extends Simplify<Y> ? 1 : 2) ? true : false; type M = Equal<{a: { x: 3 } & { y: 2 }}, {a:{ x: 3, y: 2 }}>;
for a complete handling of all simplify use cases, check here this playground Link
Reacted by DetachHeadi jumped down that rabbit hole as well, but i kept finding cases where my solution didn't work. same story with yours, however admittedly it's much cleaner than what i came up with.
// should be false: const false1: Equal<(() => 2), ((value: number) => 1) & (() => 2)> = false // error const false2: Equal<[any, number], [number, any]> = false // error const false3: Equal<<T>(value: T) => T, (value: unknown) => unknown> = false // error // should be true: const true1: Equal<(() => 2) & ((value: number) => 1), ((value: number) => 1) & (() => 2)> = true // error const true2: Equal<number & {}, number> = true // error
we could probably keep adding complexity to the
Simplifytype until these tests pass too (except maybe the generic one, i have no clue how to fix that), but i think it's an uphill battle which is why i think this needs to be supported in the languageReacted by KotlinIslandI understand your point of view. I agree that the false2 should be true. so i corrected it.
I also fixed the function use case withFnA | FnBnot equal toFnB.
You can check it Here
But i did it only for sports. In pratice i would not use this monster.
I think doing those in pratice, could really have a bad impact on typescript performance and could maybe be also really complex to implement for typescript team for a really small audience, and that maybe not worth it ?3 remaining items
sindresorhus/type-fest#537 solves identity intersection/equality (TS Playground):
type Equals<X, Y> = (<T>() => T extends X & T | T ? 1 : 2) extends (<T>() => T extends Y & T | T ? 1 : 2) ? true : false; Equals<{a: 1} & {a: 1}, {a: 1}> //=> true, false previously Equals<{a: 1} | {a: 1}, {a: 1}> //=> true, false previously
type-plus@6.4.0 has an implementation that support all of the cases mentioned in here and related issues.
Here are all the tests: https://github.com/unional/type-plus/blob/main/ts/equal/equal.is_equal.spec.ts
nikelborm commented
on Oct 25, 2024 More actionsReacted by Homa WongAnother data point: neither Matt's original solution nor type-fest's work for the following test:
type Result = Equals< // ^? type Result = false `${string & {tag: string}}`, `${string & {tag: string}}` >
As I mentioned in the original issue however, this behavior might also be a typescript regression starting around v5.1.6.
UPDATE: I fixed this bug in #61113, which is now merged.
Reacted by Axel Rauschmayer and Homa WongAxel Rauschmayer (@rauschma) Found an interesting test case that is broken with most (all?) current type-level equals implementations. Further demonstrating the need for official support for something like this.
Reacted by Axel RauschmayerThinking more about a good utility type
Equal<X, Y>:- The most intuitive definition of two types being equal is them being mutually assignable.
- Additionally,
anycan only be equal to itself – otherwiseEqualcan’t really be used for testing.
Implementing that is relatively straightforward (vs. hacks requiring you to know the internals of tsc):
type Equal<X, Y> = [IsAny<X>, IsAny<Y>] extends [true, true] ? true : [IsAny<X>, IsAny<Y>] extends [false, false] ? MutuallyAssignable<X, Y> : false ; type IsAny<T> = 0 extends (1 & T) ? true : false; type MutuallyAssignable<X, Y> = [X] extends [Y] ? ([Y] extends [X] ? true : false) : false ;
- More information: https://github.com/rauschma/asserttt
Equalhandles all tricky edge cases (that I’m aware of) correctly: https://github.com/rauschma/asserttt/blob/main/test/equal_test.ts
Axel Rauschmayer (@rauschma) Bart Louwers (@louwers) nice find! I had searched for a case where the equals hack was actually less strict than mutual assignability, but hadn't been able to find one until now.
This modification should fix it though:
type MaxInvariance<X> = <T>() => T extends X ? (_: X) => X : 0
(at least with
strictFunctionTypesenabled. WithoutstrictFunctionTypes, I think you'd need to do two checks.)Aside: the bug I mentioned above is now fixed.
Thank you for informative discussion.
To support composite
any(another corner case), I modified Axel Rauschmayer (@rauschma) 's code;type Equal<X, Y> = MutuallyAssignable<X, Y> extends true ? (MutuallyAssignable<DeepAny<X>, DeepAny<Y>>) : false; type IsAny<T> = 0 extends (1 & T) ? true : false; type DeepAny<T> = T extends object ? { [K in keyof T]: DeepAny<T[K]> } : IsAny<T>; type MutuallyAssignable<X, Y> = [X] extends [Y] ? ([Y] extends [X] ? true : false) : false;
Equal<{ x: number }, { x: any }> // -> false (previously true)
cf. TS Playground
Good observation, H.Yamada (@ymd-h)! I hadn’t thought of
anyinside objects and tuples.I see one issue with
DeepAny:T extends objectshould be replaced with[T] extends [object]so that no distributivity happens.
It may be possible to simplify your code even further (playground):
type Equal<X, Y> = MutuallyAssignable<ReplaceAny<X>, ReplaceAny<Y>>; const ANY = Symbol(); type ReplaceAny<T> = IsAny<T> extends true ? typeof ANY : [T] extends [object] ? { [K in keyof T]: ReplaceAny<T[K]> } : T ;
I’m not sure if my
typeof ANYis the best way to obtain a unique type, though.Edit: The following alternative has the benefit of only existing at the type level but it’s also less unique.
type AnyType = {unique: true} & 'AnyType';
Reacted by H.Yamada, Aaron and Massimo ArtizzuIt can not be solved 100% from user land. A fully working solution needs to be native. For a closer one, you can take a look at the implementation in
type-plusIt can not be solved 100% from user land.
Sadly, that’s true. Example that just occurred to me:
type _ = Equal< // true; should be false Set<number>, Set<any> >;
The only practical & reliable solution currently is to use the equals hack, given the assumption that it is at least as strict as mutual assignability, and at most as strict as textual equality (as long as you're ok with anything between those two extremes being an implementation detail).
That assumption is/was broken in only 3 places that I'm aware of:
- Breaks textual equality upper bound: Type containing templated, branded string is not assignable to itself #61098
✅ Fixed in Fix #61098 #61113 (and now released in 5.9!) - Breaks mutual assignability lower bound:
exactOptionalPropertyTypes: strict type equality is too lenient #61547
✅ Fixed in Don't compare "missing" toundefinedincomparePropertiesunderexactOptionalPropertyTypes#61683 - Breaks textual equality upper bound: Declaration merging can break equality tests for named tuples #61162
❌ No fix identified as of yet
If the last one gets fixed, then we have everything we need for equals in user-land (and types like
{a: 1} & {b: 2}can get recursively normalized to{a: 1, b: 2}in user-land if desired to cover the in-between cases, though I personally don't see why that is desirable: all my use-cases merely want to know if two types are exactly identical, i.e. as close to textually identical as is possible to determine.)I personally doubt TypeScript will ever implement an equals operator due to the disagreement over whether
{a: 1} & {b: 2} == {a: 1, b: 2}, so might as well just fix that last issue so that those of us (I suspect the majority) who don't care whether{a: 1} & {b: 2} == {a: 1, b: 2}can move on with a practical solution in place and no changes to the language's grammar.Reacted by H.Yamada- Breaks textual equality upper bound: Type containing templated, branded string is not assignable to itself #61098
I've been experimenting and ended up coming up with what I think is a new approach to the problem. It avoids the use of the famous trick almost entirely as it prioritizes treating types like
{ a: 1; b: 2 }vs{ a: 1 } & { b: 2 }as equal and having a consistent notion of equality at all levels of deeply nested structures.Unfortunately, that comes at the cost of
anybeing treated as equal to all other types when it appears in function parameter or return value types. The reason is that there isn't a known way in Typescript to infer those types from arbitrary functions. The closest we've got is #32164 (comment), but that solution is only for non-generic functions, and sadly that tiny limitation has big consequences. If the issue gets solved one day, then full-featured intuitive user-land equality checking types will finally become possible, but for now this is the best I've got:type IsAny<T> = 0 extends 1 & T ? true : false; // https://stackoverflow.com/a/69850582/9861000 type IsEmptyObject<T> = [T, keyof T] extends [ Record<PropertyKey, unknown>, never, ] ? true : false; type MutuallyAssignable<A, B> = [A, B] extends [B, A] ? true : false; type Identical<A, B> = (<T>() => T extends (A & T) | T ? 1 : 2) extends <T>() => T extends | (B & T) | T ? 1 : 2 ? true : false; // For equality checks to work on recursive types, we have to keep track of // types that have already been encountered. type SeenList = Array<[unknown, unknown]>; type AlreadySeen< T extends [unknown, unknown], Seen extends SeenList, > = Seen extends [infer First, ...infer Rest extends Array<[unknown, unknown]>] ? Identical<T, First> extends true ? true : AlreadySeen<T, Rest> : false; export type Equal<A, B, Seen extends SeenList = []> = IsAny<A> extends true ? IsAny<B> : IsAny<B> extends true ? false : AlreadySeen<[A, B], Seen> extends true ? never : MutuallyAssignable<A, B> extends true ? false extends | EqualObjectHelper<A, B, [[A, B], ...Seen]> | EqualEnumHelper<A, B> ? false : true : false; // Note that here we for the first time force union type distribution. type EqualObjectHelper<A, B, Seen extends SeenList> = A extends object ? B extends object ? MutuallyAssignable<A, B> extends true ? ObjectEqual<A, B, Seen> : never : never : never; type ObjectEqual< A extends object, B extends object, Seen extends SeenList = [], > = IsEmptyObject<A> extends true // for {} vs object ? IsEmptyObject<B> : IsEmptyObject<B> extends true ? false : Equal<keyof A, keyof B> extends true ? false extends { [K in keyof A & keyof B]: Equal<A[K], B[K], Seen>; }[keyof A & keyof B] ? false : true : false; // Enums are mutually assignable with number, so the check is only accurate // if we use the Identical trick. type EqualEnumHelper<A, B> = A extends number ? number extends A ? B extends number ? number extends B ? Identical<A, B> : never : never : never : never;
Here are some differences this solution has to the
Equaltype from type-plus:type R1 = { next?: R1; a: 1 }; type R2 = { next?: R2 } & { a: 1 }; enum Enum {} // Where my implementations is superior compared to type-plus: type A = Equal<[{ a: 1; b: 2 }], [{ a: 1 } & { b: 2 }]>; // true type B = Equal<{ prop: { a: 1; b: 2 } }, { prop: { a: 1 } & { b: 2 } }>; // true type C = Equal<R1, R2>; // true type D = Equal<Enum, number>; // false // Where my implementations is lacking compared to type-plus: type E = Equal<[() => string], [() => any]>; // true (should be false) // But at least it's consistent no matter the nesting depth! type F = Equal<() => string, () => any>; // true (same in type-plus unlike E! But should be false) type G = Equal<Set<string>, Set<any>>; // true (should be false) type H = Equal<() => void, (a?: number) => void>; // true (should be false) type I = Equal<{ a: 1 }, { readonly a: 1 }>; // true (should be false?)
Finally I have to say I haven't measured the performance of my solution with complex data types, but if you want to use it in your code, it's probably something you should pay attention to. I can imagine this approach slowing things down, given its recursive nature and especially how it keeps track of already encountered types.
For me this was more of a fun project, but if you are actually going to use it in your code, I would be happy to hear how well it works for you :) If there are other edge cases I've missed, please let me know!
P.S. If you know the types you will compare don't include functions (like some JSON object schemas for example), I think this might be the best solution there is :) Also you can easily improve performance by getting rid of the seen lists if you know your types won't be recursive.
Reacted by Homa WongFor the case where all you've got are just well-defined types for plain nested data, I've also been thinking about something like this:
type MutuallyAssignable<A, B> = [A, B] extends [B, A] ? true : false; type Identical<A, B> = (<T>() => T extends (A & T) | T ? 1 : 2) extends <T>() => T extends | (B & T) | T ? 1 : 2 ? true : false; type DeepSimplify<T> = T extends unknown ? { [K in keyof T]: DeepSimplify<T[K]> } extends infer B ? B : never : never; export type PlainEqual<A, B> = MutuallyAssignable<A, B> extends true ? Identical<DeepSimplify<A>, DeepSimplify<B>> : false;
DeepSimplifyforces intersections to be evaluated and thus gets rid of the issue whereIdenticalsees{ a: 1; b: 2 }and{ a: 1 } & { b: 2 }as different types.But it also gets rid of really all function signatures, which means types like
Set<string>andSet<number>that are normally differentiated by the types of their methods like.has()/.add()/ etc. are now considered equal. To fight against this, we still do theMutuallyAssignablecheck at the beginning.Is there some problem with this approach that I'm missing? It just cannot be that simple, there has to be a catch! 🤪 right?
@rauschma Found an interesting test case that is broken with most (all?) current type-level equals implementations. Further demonstrating the need for official support for something like this.
Bart Louwers (@louwers) that bug is fixed in #61683, which just got merged. The only remaining equals hack bug (that I'm aware of) is #61162.
Reacted by Bart Louwers
original issue: #27024
workarounds
to summarize the discussion in #27024, the accepted solution was:
however there are many edge cases where it fails:
{}types - [Feature request]type level equal operator #27024 (comment)Equal<{ x: 1 } & { y: 2 }, { x: 1, y: 2 }>- [Feature request]type level equal operator #27024 (comment)there were some other workarounds posted that attempted to address these problems, but they also had cases where they didn't work properly.
what "equal" means
i think it's important for it to treat structurally equal types as equal. for example
{ a: string, b: number; }should be considered equal to{ a: string } & { b: number; }as they behave exactly the same