TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

[Feature request]type level equal operator

Open DetachHead opened this issue 3 years ago • 22 comments

original issue: #27024

Search Terms

  • Type System
  • Equal

Suggestion

T1 == T2

Use Cases

TypeScript type system is highly functional. Type level testing is required. However, we can not easily check type equivalence. I want a type-level equivalence operator there.

It is difficult for users to implement any when they enter. I implemented it, but I felt it was difficult to judge the equivalence of types including any.

Examples

type A = number == string;// false
type B = 1 == 1;// true
type C = any == 1;// false
type D = 1 | 2 == 1;// false
type E = Head<[1,2,3]> == 1;// true(see:#24897)
type F = any == never;// false
type G = [any] == [number];// false
type H = {x:1}&{y:2} == {x:1,y:2}// true
function assertType<_T extends true>(){}

assertType<Head<[1,2,3]> == 1>();
assertType<Head<[1,2,3]> == 2>();// Type Error

Checklist

My suggestion meets these guidelines:

  • [x] This wouldn't be a breaking change in existing TypeScript / JavaScript code
  • [x] This wouldn't change the runtime behavior of existing JavaScript code
  • [x] This could be implemented without emitting different JS based on the types of the expressions
  • [x] This isn't a runtime feature (e.g. new expression-level syntax)

workarounds

to summarize the discussion in #27024, the accepted solution was:

export type Equals<X, Y> =
    (<T>() => T extends X ? 1 : 2) extends
    (<T>() => T extends Y ? 1 : 2) ? true : false;

however there are many edge cases where it fails:

  • doesn't work properly with overloads (function type intersections) - https://github.com/microsoft/TypeScript/issues/27024#issuecomment-568233987
  • doesn't work properly with {} types - https://github.com/microsoft/TypeScript/issues/27024#issuecomment-778623742
  • doesn't work properly with Equal<{ x: 1 } & { y: 2 }, { x: 1, y: 2 }> - https://github.com/microsoft/TypeScript/issues/27024#issuecomment-907601804

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

DetachHead avatar Mar 03 '22 13:03 DetachHead

What I see in that thread is that people have differing definitions of "equal" that are possibly in conflict, e.g. https://github.com/microsoft/TypeScript/issues/27024#issuecomment-1044193168. 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.

RyanCavanaugh avatar Mar 03 '22 19:03 RyanCavanaugh

@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)?

DetachHead avatar Mar 04 '22 04:03 DetachHead

{ p: string | number; } should be considered equal to { p: string } | { p: number; }

👀

These types are very much different; T extends { p: string } ? true : false is true | false for one and false for the other

RyanCavanaugh avatar Mar 04 '22 21:03 RyanCavanaugh

oops, updated with a better example

DetachHead avatar Mar 05 '22 01:03 DetachHead

// for example, below also is true
type M = Equal<{ x: 1 } & { y: 2 }, { x: 3, y: 2 }>

HeavenSky avatar Jul 19 '22 12:07 HeavenSky

// 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;

HeavenSky avatar Jul 19 '22 12:07 HeavenSky

// 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

ecyrbe avatar Jul 23 '22 23:07 ecyrbe

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

DetachHead avatar Jul 24 '22 02:07 DetachHead

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

ecyrbe avatar Jul 24 '22 07:07 ecyrbe

i 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 Simplify type 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 language

DetachHead avatar Jul 24 '22 12:07 DetachHead

I understand your point of view. I agree that the false2 should be true. so i corrected it. I also fixed the function use case with FnA | FnB not equal to FnB. 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 ?

ecyrbe avatar Jul 24 '22 14:07 ecyrbe

As much as I would like to have a reliable equality check, I have to agree with Ryan about people disagreeing on what should be equal.

For instance, I don't think intersection of functions should be equal, because this operation is not commutative:

type A = ((a: number) => string) & ((a: 1|2|3) => 'Maria')
type B = ((a: 1|2|3) => 'Maria') & ((a: number) => string)

declare const fooA:A;
declare const fooB:B;

const barA = fooA(1) // string
const barB = fooB(1) // "Maria"

If you want an equal operator in order to catch bugs in your types, you will probably want Equal<A, B> to be false. If you want it for some other reason, maybe your definition of "equality" in that specific context will be different.

Concerning performance, if this kind of check only appears in a test suite, of course you want the said test suite to run fast, but it is not as critical as making the type checking of your production code fast, so I would not mind a slow workaround if it were reliable.

I have learned to use conditional types in times when using an intersection would have made logical sense, because intersections have lots of quirks. What would "equality" mean in an unsound type system?

geoffreytools avatar Jul 24 '22 15:07 geoffreytools

interesting, i wasn't aware that it resolves to the first matching overload instead of the best one.

in that case, i agree that those two types should not be considered equal, according to my definition of "equal" in the OP that the types must "behave exactly the same"

DetachHead avatar Jul 25 '22 01:07 DetachHead

This issue was really hard to follow, since half the discussion is about what type equality means and half is about whether it's possible to work around this gap using existing features. I think the original request for shortcomings, tradeoffs, and semantics is the current blocker.

Doesn't Typescript already internally have a concept of type equality? I think the following definition is simple and would be consistent with other language features: two types A and B are equal if

declare let a: A;
declare let b: B;
a = b;
b = a;

doesn't produce any type checking errors.

The Typescript type checker has no issue with some of these supposedly failing cases: C, D, G. I have no thoughts on whether this is right or wrong, but it seems reasonable that if these are expected to fail then this is a Typescript bug.

@RyanCavanaugh 's test above

type J={ p: string | number; };
type J2= { p: string } | { p: number; };

fails as expected.

type K = ((a: number) => string) & ((a: 1|2|3) => 'Maria')
type K2 = ((a: 1|2|3) => 'Maria') & ((a: number) => string)
declare let fooA:K;
declare let fooB:K2;
fooA = fooB;
fooB = fooA;

passes.

That being said, I think most people would be happy if someone from the TS team would just decide a definition, regardless. As pointed out earlier, the edge cases probably don't matter for most code.

I assume the definition is the major blocker here, or else the TS team doesn't want to do this for other unstated reasons (usefulness?). I'm not sure what trade-offs/shortcomings would be relevant here. Maybe a (another?) use-case would help?

Use case

I'm designing a form widget for type T, where the caller provides metadata for each P in keyof T, the required metadata of a conditional type T[P] extends string ? ....

For string form fields, assigning the initial value of type T[P] in the metadata to the <input>'s value works fine. Reading the value from the <input> value into the constructed object of type T during form submission fails because string is a superset of T[P].

Reversing the conditional to string extends T[P] fails in the opposite order (initial value fails, constructing object succeeds).

T[P] extends string && string extends T[P] doesn't work, syntactically.

The above type Equals<A, B> = ... workarounds do not work in conditional types, I assume also for syntactic reasons.

An operator with the above definition would solve this use case (bi-directional assignment).

Trade-offs, shortcomings

I'm not sure what they'd be. The expected operator == isn't currently allowed, so this wouldn't introduce ambiguity. I can't imagine this would be harder to implement than extends.

andrewbaxter avatar Sep 01 '22 16:09 andrewbaxter

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

tommy-mitchell avatar Mar 03 '23 04:03 tommy-mitchell

[email protected] 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

unional avatar Mar 22 '23 18:03 unional

UPD @unional: new link https://github.com/unional/type-plus/blob/main/packages/type-plus/src/equal/equal.is_equal.spec.ts

nikelborm avatar Oct 25 '24 09:10 nikelborm

Another 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 https://github.com/microsoft/TypeScript/pull/61113, which is now merged.

HansBrende avatar Jan 28 '25 20:01 HansBrende

@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.

https://github.com/microsoft/TypeScript/issues/61547

louwers avatar Apr 07 '25 10:04 louwers

Thinking more about a good utility type Equal<X, Y>:

  • The most intuitive definition of two types being equal is them being mutually assignable.
  • Additionally, any can only be equal to itself – otherwise Equal can’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
  • Equal handles all tricky edge cases (that I’m aware of) correctly: https://github.com/rauschma/asserttt/blob/main/test/equal_test.ts

rauschma avatar Apr 09 '25 15:04 rauschma

@rauschma @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 strictFunctionTypes enabled. Without strictFunctionTypes, I think you'd need to do two checks.)

Aside: the bug I mentioned above is now fixed.

HansBrende avatar May 08 '25 19:05 HansBrende

Thank you for informative discussion.

To support composite any (another corner case), I modified @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

ymd-h avatar Jun 10 '25 11:06 ymd-h

Good observation, @ymd-h! I hadn’t thought of any inside objects and tuples.

I see one issue with DeepAny:

  • T extends object should 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 ANY is 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';

rauschma avatar Jun 19 '25 20:06 rauschma

It 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-plus

unional avatar Jun 19 '25 21:06 unional

It 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>
>;

rauschma avatar Jun 19 '25 23:06 rauschma

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:

  1. Breaks textual equality upper bound: #61098 ✅ Fixed in #61113 (and now released in 5.9!)
  2. Breaks mutual assignability lower bound: #61547 ✅ Fixed in #61683
  3. Breaks textual equality upper bound: #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.

HansBrende avatar Jun 20 '25 01:06 HansBrende

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 any being 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 https://github.com/microsoft/TypeScript/issues/32164#issuecomment-1146737709, 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 Equal type 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.

aweebit avatar Aug 31 '25 21:08 aweebit

For 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;

DeepSimplify forces intersections to be evaluated and thus gets rid of the issue where Identical sees { 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> and Set<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 the MutuallyAssignable check 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?

aweebit avatar Aug 31 '25 22:08 aweebit

@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.

@louwers that bug is fixed in #61683, which just got merged. The only remaining equals hack bug (that I'm aware of) is #61162.

HansBrende avatar Sep 30 '25 20:09 HansBrende