Scalable.FYI
ServicesBlogAbout UsCONTACTS

Why Use Scala / Multiversal Equality

Engineering Team · 8 min read

2026-09-17

Why Use Scala / Multiversal Equality

Introduction

In the past blog regarding product types post you may have noticed how, in Python's NamedTuple example, an instance of Summary and one of Window were actually compared, and their result was True just because their properties held the exact same values.

As you can imagine, comparing unrelated types doesn't make much sense. But, despite that, nothing at type level gave us a hint, a warning log, or a compiler error that we were performing an operation that was not logic at all.

For this reason, today we will explore more in detail the concept of multiversal equality: we will use two simple classes, an OrderId and a CustomerId, both of them wrapping a string, and see what happens in the different languages.

Python: a misleading == operator

As we all know, Python will compare anything to anything. For any two values your brain can think of,
a == b always produces an answer: not a type error, not a warning, just an ordinary True or False.

What's happening under the hood, is that Python applies a's __eq__ first: if it doesn't recognise what it is being compared against, in this case the b object, it answers NotImplemented, meaning I don't know how to handle it, and then asks the right operand the same question with the operands swapped. When that answers NotImplemented as well, that means nobody knows how to perform the comparison, therefore Python falls back to object identity: do these variables points to the same object? Two values built separately never are.

Let us stress the fact that this behavior is not accidental: if you ask those same two objects which one is smaller, and you get a TypeError. The object identity fallback exists for == and != only. Equality is the one comparison the language decided should always answer, and answering is exactly the problem.

Python
from dataclasses import dataclass

@dataclass(frozen=True)
class OrderId:
    value: str

@dataclass(frozen=True)
class CustomerId:
    value: str

OrderId("o-1") == CustomerId("o-1")   # False, and no warning anywhere

The version of this we have actually seen break production, though, is the enum one:

Python
from enum import Enum

class Status(Enum):
    PENDING = "pending"
    SHIPPED = "shipped"

Status.PENDING == "pending"    # False

class Status2(str, Enum):
    PENDING = "pending"
    SHIPPED = "shipped"

Status2.PENDING == "pending"   # True. Uh-oh, why is that?

Really odd, isn't it? Two enums, with the same entries, but one returns False, while the other returns True. Why is that?

The thing that decides which one you get is a base class declared in a file which, most likely, you haven't read for a long time. Someone writes if status == "pending" because the value came in as a string from a JSON payload three functions ago, the test suite uses the enum member on both sides, and a business branch of your application becomes dead in production without anybody noticing.

There is a tool for this: mypy's --strict-equality flag reports comparisons between non-overlapping types. It is off by default, it needs somebody to run it in CI and, as we pointed out multiple times already, it is an advice rather than enforcement.

TypeScript: the one that actually checks

Credit where credits are due (we expected this section to go in a different direction): TypeScript is really good here. The compiler raises a dedicated error for it, TS2367, and it fires on more occasions than you would imagine. All four of these fail to compile under strict:

TypeScript
type OrderId = string & { readonly __brand: "OrderId" };
type CustomerId = string & { readonly __brand: "CustomerId" };
declare const o: OrderId, c: CustomerId;

o === c;
// error TS2367: This comparison appears to be unintentional because
// the types 'OrderId' and 'CustomerId' have no overlap.

interface User { id: string; name: string }
interface Order { id: string; total: number }
declare const u: User, ord: Order;
u === ord;                 // TS2367, same reason

declare const s: string, n: number;
s === n;                   // TS2367

type Status = "pending" | "shipped";
declare const st: Status;
st === "shiped";           // TS2367, the typo is caught too

As you just saw branded types are checked, unrelated interfaces are checked, and a misspelled string literal against a union is checked. Much better than Python and, as we will see in a bit, better than Java as well.

However, TypeScript has its limitations as well: the checks stop where structural typing stops.

TypeScript
interface OrderId { value: string }
interface CustomerId { value: string }
declare const o: OrderId, c: CustomerId;

o === c;   // compiles: identical shapes, so no "no overlap" to report.
           // And === on objects is reference equality, so this is false
           // even when both hold value: "o-1".

declare const orderId: string, customerId: string;
orderId === customerId;   // compiles: nobody branded these, they are just strings

So the check is real and works, as long as your types are structurally distinguishable. The moment two things you meant to keep apart happen to have the same shape, or nobody got round to branding them in the first place, and then you will have nasty results.

Java: it checks the operator you must not use

Java has a compile-time check for this, and it is pointed at the wrong half of the language. Comparing two unrelated reference types with == is a compile error:

Java
record OrderId(String value) {}
record CustomerId(String value) {}

OrderId o = new OrderId("o-1");
CustomerId c = new CustomerId("o-1");

o == c;
// error: incomparable types: OrderId and CustomerId

The trouble is what == means for two objects: it compares references, so it is never the comparison you're looking for. The one you want is equals, which every class inherits from Object, which predates generics, and has the awful signature equals(Object), so it can take anything:

Java
o.equals(c);                                  // compiles, false
Objects.equals(o, c);                         // compiles, false
Integer.valueOf(1).equals(Long.valueOf(1));   // compiles, false
List.of(o).contains(c);                       // compiles, false ("contains(Object)")
"42".equals(42);                              // compiles, false

All five lines compile, -Xlint:all included, and all five print false. The saddest part of all of it? Modern IDEs, or even tools such as ErrorProne do catch most of these issues, which proves the information is there, but the compiler doesn't fully utilizes it.

Scala: equality as a relation between two types

Scala 2 behaved like Java: == was universal, everything compared to everything. Scala 3 introduced multiversal equality, whose idea is that a == b should only typecheck when there is evidence that a value of a's type may meaningfully be compared to a value of b's. That evidence is an instance of CanEqual[L, R].

This does not compile in any Scala 3 project:

Scala
val maybe: Option[Int] = Some(3)
maybe == 3
// error: Values of types Option[Int] and Int cannot be compared with == or !=

Comparing an Option against the thing inside it is a mistake every one of us has made at least once, and in Python or Java leads to a silent false. In Scala however it is, out of the box, a build failure.

What the default does not catch is our two id types, because the compiler cannot prove they are unrelated strongly enough to reject the comparison on its own:

Scala
case class OrderId(value: String)
case class CustomerId(value: String)

OrderId("o-1") == CustomerId("o-1")   // compiles, false. Same as everywhere else.

Which brings us to the opt-in half: turn on strict equality, either with a compiler flag (-language:strictEquality) for a whole project, or with an import in a single file, and == now demands a CanEqual instance. A type declares that it is comparable to itself with derives CanEqual, which generates exactly the kind of given instance we built by hand in the typeclasses post:

Scala
import scala.language.strictEquality

case class OrderId(value: String) derives CanEqual
case class CustomerId(value: String) derives CanEqual

OrderId("o-1") == OrderId("o-1")      // fine
OrderId("o-1") == CustomerId("o-1")
// error: Values of types OrderId and CustomerId cannot be compared with == or !=
OrderId("o-1") == "o-1"
// error: Values of types OrderId and String cannot be compared with == or !=

Notice that the second error is the one nothing else on this page catches: an id wrapper compared against the raw string it wraps, which was the production incident we mentioned in Python's paragraph.

And when you really want to compare two different types, say while a legacy id type is being migrated out, you say so once, in one place, and the comparison is allowed everywhere afterwards:

Scala
case class LegacyOrderId(value: String)

given CanEqual[OrderId, LegacyOrderId] = CanEqual.derived

OrderId("o-1") == LegacyOrderId("o-1")   // now fine, and deliberately so

The differences from a linter suppression are:

  • it lives within the same type we want to migrate;
  • it is visible to anyone reading it;
  • t disappears from the codebase the day you delete LegacyOrderId.

It is what makes opaque types opaque

Back in the opaque types post we made OrderId a zero-cost alias for String, distinct at compile time and gone at runtime. Equality is the seam where that guarantee used to leak, because two opaque types over the same underlying type still compare happily under the default rules:

Scala
opaque type OrderId = String
opaque type CustomerId = String

OrderId("x") == CustomerId("x")   // compiles by default
                                  // rejected under strictEquality

A type that is distinct for every operation except the one you use most often is only mostly distinct. Strict equality is last feature to make opaque alias really different, which is why the two features are worth turning on together rather than one without the other.

What does strict equality costs, though?

Strict equality is opt-in for a reason, and the reason becomes obvious right after you enable it on an existing codebase. Every type you compare needs an instance, including for comparing a value against another of its own type:

Scala
case class Plain(value: String)   // no derives clause

Plain("a") == Plain("a")
// error: Values of types Plain and Plain cannot be compared with == or !=

Types from libraries that never added a derives clause need a given written by hand on your side. And the check governs == and != only, so a library method that does equality internally still slips past:

Scala
List(OrderId("a")).contains(CustomerId("a"))   // still compiles, exactly like Java

So this is not a flag you flip across a million lines on a Friday afternoon. It is worth its weight in the modules where a wrong comparison is expensive, which in practice means the domain model: ids, money, states, anything a smart constructor was worth writing for in the first place. The nice property of the file-level import is that you can start there and expand outward, instead of having to decide for the whole repository at once.

The comparison that passes code review

A wrong comparison is close to the perfect blind spot for an agent, and for the human reading its diff. The line looks right. if (order.customerId == customerId) reads exactly like correct code, it is one line long, and reviewing it properly means knowing the declared type of both sides, which the diff does not show you. On top of that, false is a legal answer, so nothing crashes, and any test that exercises the path where the comparison should fail will pass for entirely the wrong reason.

This matters more with generated code than with hand-written code, because an agent picks names from surrounding context and the names are the part that looks plausible. It writes status == "pending" because the string appears in a fixture two files over, and whether that works depends on a base class it never read. Under strict equality, it does not depend on anything: the comparison either has evidence behind it or the build stops, in the same working session, with a message that names both types.

It is the same argument we keep making in this series, applied to the smallest operator of the language. The information needed to catch these bugs already exists in the types. The only question is whether the compiler is allowed to use it, or whether it is left to a linter somebody has to remember to run.