• Home
  • Posts
  • Books
  • About
  • KR

Why Identity Is the Hardest Problem in Programming

Get identity wrong and it won't blow up where you wrote it


Why Identity Is the Hardest Problem in Programming

Not long ago I saw the news that JEP 401, Java’s Value Objects, had been merged into OpenJDK master. In plain terms it’s a proposal to introduce, at the language level, an object type that behaves like a value — one judged by the contents inside it rather than by a memory address or an ID. It isn’t finished, of course. It lands in JDK 28 as a preview and ships properly in March 2027, so for now you need a flag to touch it.

The JEP document was written in 2020, but its root, Project Valhalla, started back in 2014 — twelve years of wrestling with it. Twelve years to graft a single question onto the surface of a language: when, and by what standard, do we call two objects with different memory addresses the same? What the Java community spent all that time on was, at bottom, redefining identity at the language level.

But is that really a twelve-year problem? We already use === every day to decide whether two values are the same, so it’s hard to see what’s supposed to be so hard. Dig a little further, though, and you start to see why these people couldn’t nail down this one thing for twelve years.

This post is about identity, the culprit behind an enormous amount of developer suffering.

Four Bugs From the Same Root

In JavaScript and TypeScript alone — the languages I work in most — there are four different algorithms for deciding whether two things are “equal,” and they give different answers. Nor do we consciously pick which of the four gets called each time we write a line of code.

Identity is less a property sitting inside the data than a contract we impose on it in code. But the moment a language hands you a default like ===, that contract disappears inside the language, and we forget we ever signed one. Which is why bugs don’t show up when you break the contract. They show up at the boundary where two different contracts meet.

That’s hard to follow in the abstract, so let’s start with four bugs you’ll actually run into.

The first is giving key an array index when rendering a list in React. If you write frontend code you’ve probably been told a hundred times not to do this.

// Delete one item in the middle and the input values or focus state of the
// components below it end up attached to the wrong rows.
{todos.map((todo, index) => (
  <TodoItem key={index} todo={todo} />
))}

The second is putting objects into a Set and finding the duplicates still there.

// Clearly the same user as far as we're concerned, but Set doesn't agree.
const users = new Set([
  { id: 1, name: 'evan' },
  { id: 1, name: 'evan' },
]);

console.log(users.size); // 2

The third is putting an object in the dependency array of useMemo or useEffect.

// Nothing about the value changed, yet the effect re-runs on every render.
const options = { sortBy: 'date', order: 'desc' };

useEffect(() => {
  fetchList(options);
}, [options]);

The fourth shows up with ORMs like Prisma or Sequelize that hand back a fresh object on every query.

// The same row with the same id as far as the database is concerned,
// but two distinct object instances in memory, so === says false.
const userA = await prisma.user.findUnique({ where: { id: 1 } });
const userB = await prisma.user.findUnique({ where: { id: 1 } });

console.log(userA === userB); // false

These four happen at different layers with different symptoms. But follow each of them all the way down and they converge: every layer defines “the same” differently, and we wired two of those layers together without noticing the difference.

One thing is worth flagging before we move on. In all four cases we wrote down, somewhere, “treat this and that as the same thing.” Putting an index in key was that declaration. So was dropping a raw object into a Set, and so was dropping one into a dependency array. In our heads, though, we were drawing a list, removing duplicates, and firing an effect. Nobody called any of it a declaration.

JavaScript Has More Than One Idea of “Equal”

Counting only the ones you actually meet in code, JavaScript has four algorithms for deciding whether two values are the same. We consciously use maybe two of them. The other two get called every day whether we notice or not.

Algorithm Where it shows up NaN vs NaN +0 vs -0
Loose Equality == false true
Strict Equality ===, indexOf false true
SameValueZero includes, Set, Map keys true true
SameValue Object.is true false

Read as a table it looks like a handful of edge cases. Write actual code and you run into things considerably sadder than that.

const list = [NaN];

// Same array, same value, and one says it isn't there while the other says it is.
console.log(list.indexOf(NaN));   // -1
console.log(list.includes(NaN));  // true

// === says they're the same, Object.is says they aren't.
console.log(0 === -0);         // true
console.log(Object.is(0, -0)); // false

// And Map hands back what you stored under 0 when you look up -0.
const map = new Map();
map.set(0, 'zero');
console.log(map.get(-0));      // 'zero'

One of these has to be a bug, surely — except that all of it is behaving exactly as specified. indexOf uses Strict Equality while includes and Map use SameValueZero. The same comparison gives different answers depending on which door you walk through.

1 Absurd, yes, and every bit of it faithfully following the spec

Four ways to express “these two are the same,” all behaving differently — it’s disorienting. But which one is right isn’t the point. All four exist for defensible reasons. The real problem is that when we write code, we mostly don’t notice which one we just invoked.

The moment you reach for includes instead of indexOf, you have already chosen an equality algorithm. In your head, all you did was look for a value in an array. That’s how the declaration I mentioned actually happens. There’s no dedicated place where you sit down to declare something. It’s already baked into the ordinary act of choosing a method name.

The Contortions Every Language Goes Through

It isn’t just us getting confused. The people designing languages wrestled with this for a long time too, and the scars are visible in the syntax.

Two terms are worth holding onto up front. The first pair is value and entity. A value is something like two thousand-won bills: if the contents match, calling them the same is fine. An entity is something like a user or an order: identical contents, and they can still be two different things. The second is the equivalence axioms, which for now you can just read as “the laws that ‘equal’ has to obey mathematically.” We’ll get to both properly later; this is enough to walk through the languages.

JavaScript, my daily driver, splits the world into primitives and objects. Primitives behave like values, objects behave like entities. That’s simple and predictable, and it comes with the cost that you have no way to define a value type of your own. Build a Money or a Point and it’s automatically treated as an entity.

// Two identical thousand-won bills, reported as different.
// Once it's an object, it gets entity treatment.
const a = { amount: 1000, currency: 'KRW' };
const b = { amount: 1000, currency: 'KRW' };

console.log(a === b); // false

Java originally made you override equals and hashCode yourself. It punted the definition of the identity contract to the user, and the trouble is that the contract is harder to honor than it looks, and nothing tells you when you’ve broken it.

// In old Java you wrote all of this by hand.
// Override equals and forget hashCode and HashMap breaks without a sound.
public class Money {
    private final int amount;
    private final String currency;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money m)) return false;
        return amount == m.amount && currency.equals(m.currency);
    }

    @Override
    public int hashCode() { return Objects.hash(amount, currency); }
}

// As a record, all that boilerplate collapses into one line.
record Money(int amount, String currency) {}

Java improved with record, and Value Objects goes a step further by letting you declare, in the language, an object with no identity at all. The starting point there wasn’t semantics but performance: strip identity away and the JVM can flatten the fields into contiguous memory instead of going through an object pointer. A place to declare “value or entity” came along as a side effect of that work.

Rust solved the same problem cleanly with a trait system split along two axes. The first axis is ownership and copy behavior. A type carrying Copy, like a number or a coordinate, gets bit-copied on assignment instead of moved, and behaves like a primitive value. A type without Copy is treated as an entity: ownership transfers, or you call .clone() explicitly.

The second axis is how complete the equality is. To use the == operator you need PartialEq, and beneath it Rust added one more trait, Eq. Eq is a marker trait that adds no methods at all; it exists purely to record, at the type level, that “comparison on this type fully obeys the equivalence axioms, reflexivity (a=aa = a) included.” That’s exactly why floating point (f64) implements PartialEq but not EqNaN != NaN, so reflexivity is broken.

// Three declarations packed into one line.
// Copy: this is a value (assignment copies it).
// PartialEq: it can be compared by contents.
// Eq: that comparison obeys the equivalence axioms.
#[derive(Clone, Copy, PartialEq, Eq)]
struct Money { amount: i64, currency: Currency }

So when a Rust developer writes one derive line, they’re spelling out on the surface of the code whether the type is a value or an entity, and whether its comparison respects the mathematical axioms.

Haskell likewise opened Eq up as a type class, and deriving (Eq) gets the compiler to generate field-by-field comparison — effectively doing what Java’s record does, thirty years earlier. What Haskell doesn’t do is enforce the laws. The Haskell Report doesn’t define laws for Eq at all; library documentation merely asks you to respect reflexivity and transitivity. Which is how Haskell’s Double ends up with a perfectly ordinary Eq instance while violating reflexivity thanks to NaN. Same problem, and Rust carved it out of the type system while Haskell shrugged.

It’s hard to say any point on this spectrum is superior. What’s worth noticing is that two different questions are tangled together here. One is who writes the equality logic. The other is who decides whether a type is a value or an entity.

The logic side keeps drifting toward the language. Compared to old Java making you hand-write equals, record derives it from the fields and Rust needs one derive line. The judgment is that a language deriving it mechanically beats a human getting it wrong.

The “which kind is it” side has a much more uneven history. C# gave you struct and class from 1.0 in 2002, and Swift and the ML family had that slot from the beginning — but old Java had no way to say “this type has no identity.” Everything was an object with identity, and making something behave like a value meant imitating one by overriding equals. Java spending twelve years on Value Objects was, in the end, belatedly building a slot everyone else already had.

“The Same” Means Three Different Things

Watching languages work that hard suggests the question should be pushed back a step. What does it even mean for two things to be the same? Set the code aside for a moment and picture an everyday situation.

You’re a detective working a case, and two witness statements have just come in. You need to work out whether the person A saw and the person B saw are the same person.

The surest case is that both witnesses were looking at someone standing in the same place at the same time. Short of parallel universes, there’s nothing left to argue about.

The second is comparing the descriptions the two witnesses give. Height, face, clothing all match, so surely that’s the same person.

The third is ignoring what the witnesses say entirely, lifting the person’s fingerprints, and pulling up their national ID number. Whatever they look like, the same ID number means the same person.

All three are reasonable standards. The trouble is that they can disagree. Identical twins look the same but have different ID numbers, and I look nothing like I did ten years ago while my ID number hasn’t budged.

twins The same person by looks, total strangers by ID number. Our code mixes these two up every day

So “are those two the same person?” doesn’t have one answer. You get an answer only after deciding what you’re going to look at. That is precisely what happens in code, and the three standards have names.

What you look at What it’s called In code
Whether it was one person all along Reference identity Comparing objects with ===
Whether they look alike Structural equality Comparing with JSON.stringify
Whether the ID numbers match Domain identity Checking whether the ids match
const userA = { id: 1, name: 'evan' };
const userB = { id: 1, name: 'evan' };

// 1. Reference identity (place): false — two different objects in memory
console.log(userA === userB);

// 2. Structural equality (looks): true — the contents are identical
console.log(JSON.stringify(userA) === JSON.stringify(userB));

// 3. Domain identity (ID number): true — same id, so the same user
console.log(userA.id === userB.id);

From here on I’ll call them place, looks, and ID number rather than using the formal names. The formal terms are abstract enough that they stop telling you anything after a paragraph or two, while the plain ones keep the actual question in view: what are you looking at right now? I call the first one place because in the end it asks whether two things sit at the same spot in memory.

With those three in hand, the four bugs from earlier look quite different. Giving key an index told React to judge by the place a value sits. Set failed to filter duplicates because we were looking at the ID number while Set looked at the place, and useEffect re-runs for the same reason. Two objects out of an ORM is the same ID number sitting in two different places.

Some ORMs, like Hibernate and EF Core, keep an Identity Map, which is the ORM honoring the contract “if the ID numbers match, I’ll make the places match too” on your behalf. That guarantee holds only inside a session, though. Cross the session boundary and you have two objects again, which is why Hibernate advises implementing equals on a business key yourself. The story about things breaking at the boundary holds here too.

All four were cases where we judged by ID number and the code judged by place. I called that a declaration earlier, and now we can say exactly what the declaration said. Four times over we signed “treat things in the same place as the same thing,” when what we wanted was the ID number. Most identity bugs you meet in practice are that combination. But all three standards rest on an assumption nobody tells you about: both things you’re comparing have to be in front of you right now. The moment that assumption breaks, the story changes completely.

Values and Entities Are Different Kinds of Thing

Explaining why the three standards refuse to collapse into one means going a level deeper. I kept using the words value and entity while walking through the languages; now it’s time to say what they actually mean. The core of it is that the data we handle comes in two kinds.

One is a value. The number 5, the string "hello", the tuple [3, 4]. A value has no ID number — more precisely, there’d be no reason to issue one. What would you do with the distinction between the 5 over here and the 5 over there? For a value, how it looks is what it is.

The other is an entity. Users, orders, sessions. An entity has an ID number, and that number has nothing to do with how it looks. Swap out the name, swap out the email, and as long as the number holds it’s the same user. Conversely, two users with identical names and emails can be complete strangers.

So values should be asked about their looks and entities about their number. And the language usually hands you exactly one operator, ==. Two kinds of question and one window to ask at, so one of them is guaranteed to come out wrong.

What Really Separates Them Is Time

But what exactly divides values from entities? Splitting on “primitive or object” is an implementation detail of the language. There’s a more fundamental difference: a value has no concept of time, and an entity does.

There is no yesterday’s 5. There’s no last year’s [3, 4]. Asking “which 5 do you mean, the old one?” isn’t even a coherent question. 5 is just 5. A user, on the other hand, has yesterday’s user record. An order has the order as it stood just before payment, a session has the session before it was refreshed. Me from ten years ago is the same story. That property is what makes an entity an entity.

Seen that way, the ID number is a rather well-designed thing. The whole reason it exists is that people keep changing as they move through time. Looks give you no way to stitch me-ten-years-ago to me-now, so you attach one number that doesn’t change and bundle everything under it.

Translated into programming vocabulary, that’s what we call identity. The cleanest formulation is Rich Hickey’s three-way split in the Clojure design document Values and Change, which roughly says:

A value is something that doesn’t change. 42 doesn’t change. Identity is a stable logical entity associated with a series of different values over time. State is the value an identity has at a particular moment.

It reads a little stiff, being a design document, but it’s the story we just told. Each of my yearly ID photos is a value, the ID number that bundles those photos as one person’s is the identity, and this year’s photo is the state.

Accept that definition and the question we deferred resolves itself. The reason a user with a changed name is still the same user isn’t that some comparison algorithm ruled it so — it’s that the name user was never attached to one photo in the first place. It was attached to the whole album.

A Value Doesn’t Carry Its Own Timestamp

Let’s see why this distinction turns into a concrete problem in practice. A simple example: editing user information in a form.

// User data loaded from the server (moment A)
const serverUser = { id: 1, name: 'evan' };

// The user edits the name in the form (moment B)
const formUser = { id: 1, name: 'evan (edited)' };

Hold the three standards up to these two objects and you get a strange result. By number, the ids match, so it’s the same person. By looks, the names differ, so they’re strangers. Both are correct, and neither answers the question we actually care about.

“So which of these is the more recent photo?”

We intuitively know formUser is the newer data, of course — but that’s timing information living in the head of whoever wrote the code. The object doesn’t tell you which moment it’s a picture of, and it has no way to. Answering that question means recording the date or the ordering somewhere outside the photo. That’s the reason database tables carry columns like updatedAt or version.

It’s also exactly why React insists you treat state as immutable rather than editing it in place. Mutate the existing object and the photo of the previous moment evaporates. Only by keeping the old snapshot and the new one in order in an album (useState) does React get to reason about change over time.

And it’s why the Ship of Theseus has gone two thousand years without a verdict. Whether a ship with every plank replaced is still the same ship is a question you cannot answer by examining the ship, however hard you look. Nobody ever stamped a registration number on it.

theseus Two thousand years without a registration number turned the Ship of Theseus into the paradox of the ages

What Does It Even Mean to Look the Same?

So how does the world of programming go about defining “the same”?

Start with the one that looks least intimidating of the three: comparing looks. Naively, you just walk every value inside the object and compare them, right? Try implementing it and the traps never stop coming.

const isEqual = (a: unknown, b: unknown) =>
  JSON.stringify(a) === JSON.stringify(b);

// The same object, reported as different because the key order differs.
isEqual({ a: 1, b: 2 }, { b: 2, a: 1 }); // false

// Regexes both serialize to {}, so they're unconditionally "equal".
isEqual(/a/g, /b/g); // true

// toJSON makes a Date equal a string, and undefined drops out of serialization entirely.
isEqual(new Date(0), '1970-01-01T00:00:00.000Z'); // true
isEqual({ a: undefined }, {}); // true

Stringify and compare — you’ve probably reached for this at least once. And it’s wrong in both directions. It calls identical things different, and it insists different things are identical. All of it is JSON.stringify faithfully doing the job it was built for. Serialization is a tool for turning an object into a transmittable string, not for deciding whether two objects are the same, and we pressed it into service as a comparator. Throw in a circular reference and it doesn’t just get it wrong, it throws.

This is the same shape as before. The moment you decide to compare with JSON.stringify, you’ve declared that serialization rules are now your identity rules — and the person who designed those rules never had identity in mind. You signed a contract somebody else drafted for a different purpose.

Fine, then walk the object yourself instead of stringifying. That isn’t easy either. A halfway-serious attempt has to handle at least this much:

const isDeepEqual = (a: unknown, b: unknown): boolean => {
  if (Object.is(a, b)) return true;
  if (typeof a !== 'object' || typeof b !== 'object') return false;
  if (a === null || b === null) return false;
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;

  const keysA = Reflect.ownKeys(a);
  const keysB = Reflect.ownKeys(b);
  if (keysA.length !== keysB.length) return false;

  return keysA.every(
    (key) =>
      keysB.includes(key) &&
      isDeepEqual(Reflect.get(a, key), Reflect.get(b, key)),
  );
};

That feels like enough, and it still isn’t. Objects hiding state in internal slots — Date, Map, Set — yield nothing when you walk their keys, and a circular reference blows the stack.

On top of that, this code has already made several decisions on your behalf. Choosing Reflect.ownKeys decided that Symbol keys count. Comparing getPrototypeOf decided that a different prototype means a different thing. The Object.is on the first line is the same story: had you written === there, two objects holding NaN would be different forever, and the price of avoiding that is that +0 and -0 now count as different.

There’s something to take from this. Deciding that two things look the same isn’t something that falls out of writing good code. It’s something you build up by defining, one at a time, how far “looks” extends. That’s why es-toolkit’s isEqualWith runs to hundreds of lines. Defining “the same” isn’t algorithmically hard. It just takes a great many decisions about where to draw the line.

Sameness Has Three Laws to Obey

Earlier I said Rust records “this comparison obeys the equivalence axioms” in the Eq trait. Time to look at the axioms I kept deferring. In mathematics, an equivalence relation is defined as a relation satisfying three axioms.

Reflexivity: a=aa = a is always true. Everything is equal to itself.

Symmetry: if a=ba = b then b=ab = a. The order of comparison doesn’t change the result.

Transitivity: if a=ba = b and b=cb = c then a=ca = c. Sameness chains.

They’re obvious enough that naming them seems excessive. And yet a surprising share of the comparison functions we use in practice violate at least one of the three without blinking.

The most famous reflexivity violation is NaN. Obviously the same value, and yet === reports the two as different.

const value = NaN;

// Compared with itself, and it comes back different.
console.log(value === value); // false

This isn’t JavaScript being weird. IEEE 754, the floating point standard, defines == to behave that way, so nearly every language with floating point does this.

What’s interesting is that most languages refused to leave it alone. Java has Double.equals, JavaScript has Object.is, and both treat NaN as equal to itself. Postgres went further and simply declared NaN = NaN true so that indexes would work. Every one of them built a detour that respects the axioms right next to a default that breaks them. (a full-body contortion routine for the sake of comparison operators…)

This is exactly why Rust gives floating point PartialEq but not Eq. As a result, trying to plainly sort a vector of floats in Rust is a compile error, because sort() requires Ord, which presupposes Eq. Fairly infuriating the first time you hit it, and it’s the compiler heading off an accident in advance.

More dangerous than a reflexivity violation, though, is a transitivity violation. When comparing floats we usually account for error like this:

const EPSILON = 0.1;

const isEqual = (a: number, b: number) => Math.abs(a - b) < EPSILON;

// a and b are equal
console.log(isEqual(1.00, 1.09)); // true

// b and c are equal too
console.log(isEqual(1.09, 1.18)); // true

// but a and c are not.
console.log(isEqual(1.00, 1.18)); // false

Symmetry this function definitely satisfies. Reflexivity drags along the problem we just saw: isEqual(NaN, NaN) fails, and so does isEqual(Infinity, Infinity), because subtracting infinities gives you NaN.

The real problem is transitivity. As the code shows, the chain a=b=ca = b = c never closes. In human terms, I’m the same person as A, A is the same person as B, and I’m a total stranger to B.

And this isn’t a botched implementation, it’s a limit of epsilon comparison itself. As long as values can sit closer together than the threshold, extending the chain far enough guarantees a moment where the rule breaks. So the escape hatch is on the other side, not the threshold side. Make the positions values can occupy sparse instead. The money example later on is exactly that prescription.

Break an Axiom and Your Data Structures Fail Quietly

At this point you might reasonably ask what any of this buys you. Who cares about bending a mathematical axiom as long as the code runs? This, though, is the part I most wanted to write about.

The data structures we grab without a second thought every day are all designed on the assumption that the equivalence axioms hold.

Take Set and Map. Both are built on the premise that the same thing goes to the same slot, and real engines implement them as hash tables. The last step of that lookup is a comparison asking “is the value I stored the same as the value I’m looking up?” — and if reflexivity is broken, you snag on that final question and can never again find something you carefully put in.

And yet put NaN into a Set and look it up, and you get a surprise.

const set = new Set<number>();
set.add(NaN);

// Wait, why does this work?
console.log(set.has(NaN)); // true

Because JavaScript’s Set uses SameValueZero instead of ===, and that algorithm treats NaN as a special case. The language built yet another separate algorithm just to preserve the axiom. That’s why SameValueZero exists among the four from earlier.

But this is a bad place to relax and think “ah, the language handles it for me.” What the language covered is NaN, and only inside Set and Map. Look up that same NaN with indexOf and you still won’t find it, and put an object in and nothing is covered at all. Everywhere the language appears to have handled things on your behalf, there’s a clause attached like this — and that clause is written down nowhere unless you go looking for it.

Move to sorting and it gets far more dramatic. When a comparison function loses transitivity, the result isn’t merely a wrong order; the algorithm itself frequently malfunctions. Java’s Arrays.sort detects the situation while sorting an object array and outright throws.

java.lang.IllegalArgumentException: Comparison method violates its general contract!

That’s because Tim Sort, which Arrays.sort uses, assumes transitivity to optimize its merge step. Comparing elements one by one when merging two runs is slow, so when one side keeps coming up smaller it concludes “everything over here must be smaller than that side’s first element,” skips ahead in powers of 2n2^n, and once it overshoots, binary searches the gap to find the exact position. Break transitivity and an element that doesn’t fit turns up inside the skipped range, and the slot you’re supposed to insert into stops logically existing. So Java throws on the spot.

JavaScript doesn’t even have that. The spec leaves what happens with an inconsistent comparison function entirely up to the implementation. What you get is the sad outcome where sorting finished, nothing threw, and the elements are simply in the wrong order. I’d honestly rather have Java’s melodramatic exception.

Memoization carries the same risk. If the equality of your cache keys violates transitivity, the same input hits the cache sometimes and misses other times. It looks like a performance problem and it’s really a correctness one — the moment a cached value gets wrongly reused, the result itself is off.

And hash-based data structures carry one more contract on top of the three axioms.

Two equal values must have the same hash.

The reverse doesn’t have to hold. Same hash with different values is called a collision, and the data structure handles it for you. But equal values with different hashes get split into different buckets, and then they never meet again.

In records-office terms, it’s one person’s paperwork filed into two different cabinets — the clerk will never see them side by side in their life. Several people’s paperwork mixed into a single cabinet, on the other hand, is no big deal. You open it and check one at a time.

catalog This is precisely what a hash does. Pick the wrong drawer and no amount of digging inside it will help

This is why Java tells you that overriding equals obliges you to override hashCode too. And breaking that contract still leaves the code running fine for a while.

A bucket is chosen by the hash modulo the bucket count, so even with different hashes the two may happen to land in the same slot and be found anyway. Then the map grows, the bucket count is rebalanced, and lookups that worked perfectly yesterday start failing.

What I want to say in this section comes down to this.

Code that breaks the equivalence axioms does not blow up where it stands.

Instead, the code stacked on top of a broken equivalence relation starts producing subtly strange answers. And because those wrong values appear and vanish depending on the order and size of the data, reproducing them is genuinely brutal. (and this is how you end up staying late again)

Not that an axiom violation always turns into visible misbehavior, of course. Depending on the data structure and the distribution of the data, plenty of them slide by without incident. Which, to me, makes them more dangerous rather than less.

And once you’re here, everything we’ve passed through threads together. Set found NaN for us — inside Set. An ORM hands back the same object — inside a session. The language generates hashCode for you — when you declared a record. Java throws during a sort — when Tim Sort happened to notice. Everywhere a language or library appears to handle things for you, without exception, there’s a clause like that attached.

Not reading the contract does nothing at all on most days. That’s the real problem. There is no mechanism anywhere to tell you that you never read it.

React’s key Is an Identity Declaration

Let’s go back to the React key problem from the start. We can read it differently now.

React’s reconciliation algorithm has to compare the previous tree against the new one and decide “are this node and that node the same component?” But React doesn’t know your domain. It has no way to know which item is the same to-do.

So React delegates that judgment to us, and key is the interface for that delegation. key isn’t a hint for performance optimization. It’s the slot where you declare what counts as the same thing in this list.

Of everything in this post, key is essentially the only case that spreads the contract out in front of you and openly asks for a signature. Everywhere else, the signature was hidden somewhere in the code.

// Equivalent to declaring "same position means same item."
{todos.map((todo, index) => (
  <TodoItem key={index} todo={todo} />
))}

// Equivalent to declaring "same ID number means same item."
{todos.map((todo) => (
  <TodoItem key={todo.id} todo={todo} />
))}

Give key an index and you’ve declared that position is the identity. And React believes that declaration with total sincerity. Delete an item in the middle and, as far as React is concerned, the item in slot 3 is still right there, so it keeps that slot’s component state exactly as it was. We declared it wrong; React merely followed us precisely. Give it todo.id and you’ve declared the ID number is the identity, which in most cases is the contract we actually wanted.

There’s a real difference between memorizing “don’t use an index for key” and understanding that doing so declares identity incorrectly. Understand the second and you can also see how far you can safely go.

An index is fine only when the set of items itself doesn’t change between renders. “No reordering, no insertions or deletions” isn’t enough. Different data of the same length arriving in the same slots still gets believed as the same item. Paging, refetching a list, changing a filter — all of these qualify. That, in practice, is the corner where index-key accidents happen most often.

Identity Isn’t Discovered, It’s Declared

By this point it’s getting a bit bleak. Three standards, four algorithms, axioms that keep breaking, data structures returning quietly wrong answers. And yet there are systems that sidestep this entire mess very cleanly.

The prime example is one we use daily: Git. A commit’s name in Git is a hash of the commit’s contents. Identical contents necessarily get the same name, and different contents get, in practice, reliably different names.

I say “in practice” because cramming infinite content into 160 bits guarantees collisions exist — and in 2017 two different files with the same SHA-1 were actually produced, after which Git started preparing its move to SHA-256. None of which solves the underlying problem, though it does push the probability low enough to ignore.

Either way, in Git the question “are these two commits the same?” is answered by a single string comparison. Docker image digests and every content-addressed store rest on the same idea.

This approach is called content addressing, and in this post’s metaphor it amounts to abolishing the ID number and using looks directly as the number.

Anything that looks even slightly different is treated as a different person outright. I said earlier that a value has no time and that its contents are what it is; content addressing shoves everything under that rule. Then place and looks always agree, and the reason to write a date on the photo disappears too — because modifying something doesn’t modify it, it produces one more new thing.

There’s a price to this, of course. Making an entity into a value requires immutability, and immutability can’t express state that mutably changes.

So Git solves that with a separate mechanism: branches. main points at a different commit today than yesterday, and we still call it the same main, don’t we? Because main isn’t a name attached to one commit, it’s a name attached to the whole lineage of commits. It’s the photo album story, implemented literally.

Git, in other words, has split the world of values and the world of identity into entirely separate layers. Once you’ve seen that structure, you start seeing it everywhere. Variable names and assignment, a database’s primary key and its rows, useState’s state slot and the snapshot taken on each render — all the same shape.

That separation is just as useful on the frontend. It’s the most practical reason Redux and other state management tools insist you treat state as immutable: make the state object a value and a reference comparison alone tells you whether it changed. Then React.memo scanning props, or a selector checking against its previous result, finishes in one cheap comparison.

Turn that around and the useEffect problem from earlier was caused by leaving something that should have been a value sitting as an entity. So how do you turn something that should be a value into one?

If the contents are fixed from beginning to end, like that options, the answer is anticlimactic. Move it outside the component.

// Outside the component, there's only ever one reference.
const OPTIONS = { sortBy: 'date', order: 'desc' } as const;

function TodoList() {
  useEffect(() => {
    fetchList(OPTIONS);
  }, []);
}

It never gets born again on each render, so the effect never re-runs. That’s the cheapest way to declare “this object is a value,” and scope is what enforces the declaration.

The problem is when the contents come from props or state. You can’t hoist it out, so you reach for useMemo.

const options = useMemo(() => ({ sortBy, order }), [sortBy, order]);

For a long time I thought of this prescription as plain performance optimization. Looking at it now, it’s far closer to an attempt to declare “this object is a value.” For as long as the reference survives, that reference serves as the name of the contents.

Except React never promised to honor that declaration. The official docs are emphatic that you should treat useMemo as a performance optimization only, and state that React may discard the cache at any time. Scope enforces it; the hook doesn’t. So we want to declare something, neither the language nor the library gives us a proper slot for it, and we end up imitating one with whatever’s lying around.

There was an attempt to build that slot into JavaScript itself, mind you. The Record and Tuple proposal would have introduced immutable value literals like #{ sortBy: 'date' } that compare by contents and can be dropped straight into a Map as keys.

Create a value literal and its contents become its name — the language doing for every object what Git does for commits. But adding one more primitive type turned out to be too heavy a burden on the engines, so it was withdrawn in April 2025, and a follow-up proposal taking the object route instead of the primitive route has taken over that ground.

Leaving Identity Behind in the Code

So what do you do when the language won’t give you that slot? Identity isn’t a fact you can find by peering at the data, it’s a contract you set using domain knowledge — and if you don’t set it, the language sets it for you.

The problem is the moment the language’s default diverges from your domain, because nothing anywhere signals that it has. Which is why, in practice, I think it’s better to leave identity explicitly in the code. Here are a few things you can do in TypeScript.

First, brand your identifiers so different kinds of id can’t get mixed up.

declare const brand: unique symbol;

type Brand<T, B> = T & { readonly [brand]: B };

type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

const toUserId = (value: string) => value as UserId;
const toOrderId = (value: string) => value as OrderId;

const findUser = (id: UserId) => { /* ... */ };

// At runtime both are just strings, but at the type level they never mix.
findUser(toOrderId('order-1')); // compile error

Two strings happening to hold the same characters can’t possibly make them the same thing. That statement is now made purely in types, without a line of runtime code. And since toUserId needs a cast anyway, putting format validation in there gives you one more filter at the boundary.

Next, lift entity identity checks up into domain code.

const isSameUser = (a: User, b: User) => a.id === b.id;

It looks like a trivial function, but the moment it’s named isSameUser, anyone reading the code knows what’s being compared here is neither reference nor contents but domain identity. Write a.id === b.id inline and that information is gone.

Value types are the reverse — their looks are their identity — so make them immutable and spell out the comparison by contents.

interface Money {
  readonly amount: number;
  readonly currency: 'KRW' | 'USD';
}

const isSameMoney = (a: Money, b: Money) =>
  a.amount === b.amount && a.currency === b.currency;

Leave amount as a float here and the reflexivity and transitivity problems from earlier come right along with it. That’s why money is normally handled in its smallest unit as an integer, and that too is ultimately a choice made to protect identity. People usually explain finance’s avoidance of decimals purely as a precision issue, but I think the more fundamental reason is that the moment the equivalence relation breaks, every aggregation and reconciliation built on top of it starts to wobble.

Handing the judgment to a data structure takes one more step. JavaScript gives you no way to define hashCode, and Map and Set effectively do reference comparison on objects, so removing duplicates by ID number means building a key yourself and handing it over.

const dedupeBy = <T, K>(items: T[], toKey: (item: T) => K) =>
  Array.from(new Map(items.map((item) => [toKey(item), item])).values());

const users = [
  { id: 1, name: 'evan' },
  { id: 1, name: 'evan (edited)' },
  { id: 2, name: 'moon' },
];

// Of the two with the same id the later one survives, leaving 'evan (edited)' and 'moon'.
dedupeBy(users, (user) => user.id);

What toKey does here is translate the ID number into something Map can understand. It looks like hashCode, but the requirement is one notch stricter. hashCode has equals backing it up when a collision happens; Map has no such fallback, so matching keys end the judgment right there.

Which means you mustn’t get the direction backwards when building a composite key. In dedupeBy we decided two users with the same id are the same, so collapsing them onto one key was correct. A seat, on the other hand, has its row and column as its identity, so the moment two different seats share a key, one of them vanishes without a sound.

// Drop the separator and row 1 seat 23 and row 12 seat 3 both become "123".
const toSeatKey = (seat: { row: number; column: number }) =>
  `${seat.row}:${seat.column}`;

Assembling a string like this may look janky, but stated precisely, what this code does is a miniature version of the content addressing from earlier. Strings are the only type the language treats as a value, so you convert the seat’s contents into one and use it as the name. Removing exactly this chore is what the Record and Tuple proposal was for.

There’s a cost to all of this, naturally. Overuse brand types and conversion functions multiply, casts creep into every domain boundary, and the code gets verbose. Maintaining one identity function per type is no small burden either.

So this isn’t something to apply uniformly to every type. It’s better to invest first at the boundaries where two layers meet: where an API response enters your domain model, where the domain model leaves for the view, where cache keys get built. Remember that all four bugs from the top of this post went off at exactly those boundaries and the reason becomes obvious.

Wrapping Up

Identity is hard because it isn’t in the data. Data doesn’t tell you what it’s the same as. Deciding that is always on us.

And programming languages very kindly hand us a default apiece. There’s ===, there’s equals, there’s ==. So every time we type === we’re signing off on “I’ll decide whether those two are the same person by looking at where they sit” — stamping the seal hundreds of times a day without once reading the document.

And every clause in that document where the language promises to handle things for you comes with a condition attached. Only in Set. Only within the session. Only when you declared a record. Only when it happened to notice. Someone who doesn’t know they signed a contract has no chance of seeing those conditions.

Java spending twelve years on Value Objects, Rust bothering to split Eq from PartialEq, React handing key over to us — all of it was building the same slot. A place on the surface of the code to write down whether this type is a value or an entity, and what counts as the same thing in this list. How to compare is something a language can handle on its own. What to count as the same is something no language can decide for you.

So when a comparison shows up while you’re reading code, it’s worth stopping to ask once. Is this code looking at the place, at the looks, or at the ID number? Most of the time one of the three answers itself immediately. And the spots where the answer doesn’t come easily are usually the spots where a bug is growing.

The deeper you go into this topic the more it opens onto others. It runs into floating point, into hash-based data structures, into immutability, and eventually into domain modeling. I think I understand now why the Java community spent twelve years on it. This was never about adding one feature. It was about deciding how a language carves up the world. So the next time you type ===, it’d be nice to remember that it isn’t just three equals signs — it’s a contract we sign every day without thinking.

And with that, I’ll bring this post on why identity is the hardest problem in programming to a close.

ProgrammingFrontendIdentityEqualityJavaScriptTypeScriptDomain ModelingMathematics

관련 포스팅 보러가기

Jan 25, 2026

Why Do Type Systems Behave Like Proofs?

Programming
May 03, 2017

[Simulating Celestial Bodies with JavaScript] Implementing Planetary Motion

Programming/Graphics
Feb 07, 2026

Beyond Functors, All the Way to Monads

Programming
Oct 30, 2021

[All About tsconfig] Compiler options / Emit

Programming/Tutorial/JavaScript
Aug 22, 2021

[Everything About tsconfig] Compiler Options / Modules

Programming/Tutorial/JavaScript