A mid-level engineer writes “bidirectional extends” and calls it equality: A extends B ? (B extends A ? true : false) : false. On paper it reads: “A is a B AND B is an A, so they're the same.” Looks airtight.
Write a type that decides whether two types are equal. The first shape that falls out of the keyboard looks like the Socratic answer: “A is a B, AND B is an A, so they're the same thing.” Bidirectional extends. Looks airtight. Ships. Passes the obvious tests. Breaks on the types you haven't looked at yet — and the types you haven't looked at yet include any, never, and every shape with a readonly modifier.
// Attempt 1 — bidirectional extends. A says it's a B AND B says it's// an A, so they must be the same. Reads airtight.type IsEqualNaive<A, B> = A extends B ? (B extends A ? true : false) : falsetype R1 = IsEqualNaive<string, string> // true correcttype R2 = IsEqualNaive<string, number> // false correcttype R3 = IsEqualNaive<string, any> // true WRONGtype R4 = IsEqualNaive<{ a: 1 }, { readonly a: 1 }> // true WRONGtype R5 = IsEqualNaive<never, never> // never WRONGThe playground has three panels. The top is a recipe editor — it shows the current version of IsEqual we're working with; it'll morph as the puzzle progresses. Below that, a truth table with eight rows and a toggle: “Bidirectional” is the naive version you'll meet in a second; “Wrapped” is the fix we'll reach by the end. Below that, a spotlight panel showing the row the current act hinges on — the row whose red-vs-green transition tells the whole story. Read the recipe pane. It's the naive bidirectional version — the version that looks correct and isn't.
Feed it IsEqualNaive<string, any>. Returns true. WRONG — string and any are not the same type; any is bidirectionally assignable to EVERYTHING (both string extends any and any extends string evaluate to true). The bidirectional check can't catch any.
Using the naive IsEqualNaive<A, B> = A extends B ? (B extends A ? true : false) : false, what does IsEqualNaive<string, any> return?