- Book: Kotlin and Java to TypeScript
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A Kotlin grid keyed by Point is about as ordinary as code gets: a cell type and a map from cell to whatever lives there.
data class Point(val x: Int, val y: Int)
fun main() {
val labels = hashMapOf(Point(1, 2) to "spawn")
println(labels[Point(1, 2)]) // spawn
}
Two different Point instances, one lookup, a hit. The Kotlin compiler derives equals() and hashCode() from every property in the primary constructor, so the second Point(1, 2) lands in the same bucket as the first and compares equal. Java gives you the same deal with records, final since JDK 16.
Now you port it to TypeScript. The compiler is happy, and the lookup returns undefined with no error anywhere.
Why the Map can't see your Point
Here is the port, as most JVM developers write it on day one:
type Point = { readonly x: number; readonly y: number };
const labels = new Map<Point, string>();
labels.set({ x: 1, y: 2 }, "spawn");
labels.get({ x: 1, y: 2 }); // undefined
labels.size; // 1
const seen = new Set<Point>();
seen.add({ x: 1, y: 2 });
seen.add({ x: 1, y: 2 });
seen.size; // 2
JavaScript's Map compares keys with the SameValueZero algorithm. MDN spells out what that means for objects: "for object keys, equality is based on object identity. They are compared by reference, not by value." Two object literals with the same fields are two objects, so they are two keys. Set works the same way, which is why your "deduplicated" set holds the same point twice.
The JVM reflex is to add an equals method and move on. It does nothing here:
class Pt {
readonly x: number;
readonly y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
equals(o: Pt): boolean {
return this.x === o.x && this.y === o.y;
}
}
const m = new Map<Pt, string>([[new Pt(1, 2), "spawn"]]);
m.get(new Pt(1, 2)); // undefined
There is no equals/hashCode protocol in JavaScript collections. Map never calls your method, and there is no hook to make it. On the JVM, the Object contract says equal objects "must produce the same integer result" from hashCode, and HashMap is built on that promise.
The type checker won't save you either. TypeScript's type system is structural: any { x, y } is a valid Point, so labels.get({ x: 1, y: 2 }) type-checks perfectly.
One more trap while you are here. If you reach for a plain object as a dictionary instead, every property name gets coerced to a string, and every plain object becomes "[object Object]". Now every point matches every other point. You traded "never matches" for "always matches".
Fix 1: derive a string key
The smallest fix is to stop using the object as the key and use a string computed from it:
const keyOf = (p: Point): string => `${p.x},${p.y}`;
const grid = new Map<string, string>();
grid.set(keyOf({ x: 1, y: 2 }), "spawn");
grid.get(keyOf({ x: 1, y: 2 })); // "spawn"
Strings are primitives, and SameValueZero compares primitives by value, so this works. For numeric fields the comma is safe because a JavaScript number never stringifies with a comma in it.
String fields are where this goes wrong. With a naive template, { org: "a,b", name: "c" } and { org: "a", name: "b,c" } both produce "a,b,c". Encode the fields as a JSON array in a fixed order instead:
type Member = { readonly org: string; readonly name: string };
const memberKey = (m: Member): string =>
JSON.stringify([m.org, m.name]);
JSON escaping makes the encoding unambiguous, and the array fixes the order. Don't call JSON.stringify(m) on the whole object: property order follows Object.keys(), which is insertion order for string keys, so { org, name } and { name, org } give you two different keys for the same member.
The cost of Fix 1: the map now holds strings. Iterate it and you get "1,2" back, not a Point. And nothing stops a teammate from calling grid.get("1, 2") with a stray space.
Fix 2: a KeyedMap that takes a key function
If you miss HashMap<Point, V>, write the small wrapper that moves hashCode out of the class and into a function you pass in. It keeps the original key next to the value, so iteration hands you real Point objects:
class KeyedMap<K, V> {
readonly #entries = new Map<string, [K, V]>();
readonly #keyOf: (key: K) => string;
constructor(keyOf: (key: K) => string) {
this.#keyOf = keyOf;
}
set(key: K, value: V): this {
this.#entries.set(this.#keyOf(key), [key, value]);
return this;
}
get(key: K): V | undefined {
return this.#entries.get(this.#keyOf(key))?.[1];
}
has(key: K): boolean {
return this.#entries.has(this.#keyOf(key));
}
delete(key: K): boolean {
return this.#entries.delete(this.#keyOf(key));
}
get size(): number {
return this.#entries.size;
}
[Symbol.iterator]() {
return this.#entries.values();
}
}
Using it reads like the Kotlin version again:
const labels = new KeyedMap<Point, string>(keyOf);
labels.set({ x: 1, y: 2 }, "spawn");
labels.get({ x: 1, y: 2 }); // "spawn"
for (const [point, label] of labels) {
console.log(point.x, point.y, label);
}
One JVM rule carries over unchanged. The java.util.Map docs warn that "the behavior of a map is not specified if the value of an object is changed in a manner that affects equals comparisons while the object is a key in the map." Here, if you mutate a key after set, the stored string is stale and the entry is unreachable by its new value. Keep key types readonly, as Point is above.
Fix 3: branded value types
Fixes 1 and 2 still let anyone pass a raw { x, y } or a hand-built string. Branding closes that door at compile time. The TypeScript playground's nominal-typing example intersects a type with a __brand property, a convention that makes a plain value unassignable to the branded type.
Start with the key string. Brand it, and make one function the only way to produce it:
type PointKey = string & { readonly __brand: "PointKey" };
const pointKey = (p: Point): PointKey =>
`${p.x},${p.y}` as PointKey;
const spawns = new Map<PointKey, string>();
spawns.set(pointKey({ x: 1, y: 2 }), "spawn");
spawns.get(pointKey({ x: 1, y: 2 })); // "spawn"
// spawns.get("1,2"); // compile error: not a PointKey
The brand exists only in the type system: at runtime a PointKey is an ordinary string and the map behaves exactly as in Fix 1, while a hand-typed "1, 2" is rejected by the compiler because it has no brand.
You can go one step further and brand the value itself. If a factory hands out one canonical frozen object per coordinate, reference equality becomes value equality, and the plain Map from the first snippet starts working:
type GridPoint = Point & { readonly __brand: "GridPoint" };
const pool = new Map<string, GridPoint>();
function gridPoint(x: number, y: number): GridPoint {
const key = `${x},${y}`;
let p = pool.get(key);
if (p === undefined) {
p = Object.freeze({ x, y }) as GridPoint;
pool.set(key, p);
}
return p;
}
const owners = new Map<GridPoint, string>();
owners.set(gridPoint(1, 2), "spawn");
owners.get(gridPoint(1, 2)); // "spawn"
gridPoint(1, 2) === gridPoint(1, 2); // true
// owners.get({ x: 1, y: 2 }); // compile error: no brand
This is interning: one shared instance per distinct value. The brand is what makes it safe: a Map<GridPoint, V> refuses object literals, so the day-one bug from the first section can't compile. The trade-off is memory. The pool keeps every point it ever created alive, so use it for bounded domains like grid cells, currency pairs or enum-like tuples, not for arbitrary user input.
Records and Tuples will not fix this for you
For years the answer to "when will JavaScript get value-equal objects?" was the Records and Tuples proposal, with its #{ x: 1, y: 2 } syntax. That proposal is gone. At the TC39 plenary on April 14, 2025, the committee reached consensus to withdraw it; it "was at stage 2 and unable to gain further consensus for adding new primitives to the language." The inactive-proposals list records the rationale as "Subsumed by Composites".
The Composites proposal is the successor, and it is listed at Stage 2 as of October 2026. Its pitch is this exact problem. Composite({ x: 1, y: 4 }) returns an interned, frozen object, so two composites built from the same values are the same object, === is true, and they work as Map and Set keys. It is the gridPoint factory above, proposed as a language feature.
Read the fine print before you wait for it. Stage 2 in the TC39 process means "refining the solution": the design is a draft that may still change significantly. The README mentions experimental implementations and a polyfill, not a shipping engine. Nested plain objects are compared by reference, so Composite({ v: {} }) !== Composite({ v: {} }). Composites can't be WeakMap keys. And every object you get from JSON.parse, a database driver or an HTTP client will still be a plain object, so you'd convert at the boundary anyway, which is what Fix 3 already does.
Which fix to pick
Reach for Fix 1 when the key is used in one function and never iterated. KeyedMap is the one to pick when you want HashMap<K, V> ergonomics and need the original keys back. Use branded keys or interned values when the map crosses module boundaries and you want the compiler, not code review, to catch the raw object literal.
If this was useful
Map keys are one of those places where Kotlin and Java instincts compile cleanly in TypeScript and then fail quietly at runtime. Kotlin and Java to TypeScript walks through these translation gaps one by one: variance, null safety, sealed classes to unions, coroutines to async/await. If you came to TypeScript from the JVM, it's the one to start with.
The rest of the collection, so you can see which one or two fit you:
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — JVM developers moving to TypeScript
- PHP to TypeScript — PHP 8+ developers moving to TypeScript
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
Books 1 and 2 are the core path. Kotlin and Java to TypeScript stands in for them if you think in JVM terms, and The TypeScript Type System is where branded types like PointKey get the full treatment. Book 5 is for anyone shipping TypeScript at work. Browse all five on the collection page.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)