Skip to main content
← Gists
typescript Feb 19, 2026

XOR Types for Mutually Exclusive Options

Enforce exactly one of two shapes at compile time.

Use XOR when two option shapes are mutually exclusive but share no discriminant field. A discriminated union is usually better; XOR covers the cases where you can’t add a type or kind property to distinguish the branches.

Type

xor.ts
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }

type XOR<T, U> = (T & Without<U, T>) | (U & Without<T, U>)

type ById = { id: string }
type ByEmail = { email: string }

type Lookup = XOR<ById, ByEmail>

function getUser(input: Lookup) {
return input
}

getUser({ id: 'u1' })
getUser({ email: 'a@b.com' })
// @ts-expect-error: both provided
getUser({ id: 'u1', email: 'a@b.com' })

How it works

  • Without<T, U> takes every key in T that doesn’t exist in U and maps it to ?: never. This makes those keys “forbidden” on any object that matches U.
  • XOR<T, U> is a union of two branches. The first branch allows T’s keys and forbids U’s extras; the second allows U’s keys and forbids T’s extras. Exactly one branch can match at a time.
  • The @ts-expect-error line proves it: passing both id and email is a compile error because neither branch permits both keys simultaneously.

When to prefer a discriminated union

If you control the shapes, a discriminant field (type: "byId" | "byEmail") is clearer and plays better with switch/if narrowing. XOR is for situations where you inherit the shapes from an external API or a library type you can’t modify.