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`Wall
FIG. 1 — RECIPE EDITOR
1
// Attempt 1 — bidirectional extends. A says it's a B AND B says it's
2
// an A, so they must be the same. Reads airtight.
3
type IsEqualNaive<A, B> =
4
  A extends B
5
    ? (B extends A ? true : false)
6
    : false
7
8
type R1 = IsEqualNaive<string, string>        // true   correct
9
type R2 = IsEqualNaive<string, number>        // false  correct
10
type R3 = IsEqualNaive<string, any>           // true   WRONG
11
type R4 = IsEqualNaive<{ a: 1 }, { readonly a: 1 }>  // true   WRONG
12
type R5 = IsEqualNaive<never, never>          // never  WRONG
— Two lines by the end of the puzzle — one for the body, one for the wrapper —
FIG. 2 — TRUTH TABLE · 8 ROWS
Evaluating
stringstringtrueagrees
stringnumberfalseagrees
stringanytruewrong
{ a: 1 }{ a: 1 }trueagrees
{ a: 1 }{ readonly a: 1 }truewrong
neverneverneverwrong
'a' | 'b''a' | 'b'booleanwrong
1 | 22 | 1booleanwrong
— Flip the toggle to watch four red rows turn green under the wrapper —
FIG. 3 — SPOTLIGHT ROW
AstringvsBany
Bidirectionaltrue
Wrappedfalse
— The single row this act hinges on —
Progress
WallWrappedCapstone

The 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. WRONGstring 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?