Lesson 69 of 80
A type parameter that never shows up in any stored data, used purely to make the type checker enforce a sequence of operations or forbid mixing incompatible values.
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
The branded-types lesson covers stamping a single flat value — a string — with a fake identity so two structurally identical primitives (UserId, PostId) can't be swapped. A phantom type takes the same "type-space-only marker" instinct and applies it to an entire generic class or pipeline instead of one value: an extra type parameter that exists purely to carry compile-time state through a chain of operations, with zero runtime representation anywhere in the object.
Where branding answers "these two strings shouldn't mix," phantom types answer "this object shouldn't let you call execute() until you've called validate() first" — or "you shouldn't be able to add a distance in meters to a distance in feet." The type checker enforces a sequence or a compatibility rule, not just an identity.
class Distance<Unit extends "meters" | "feet"> {
private readonly _phantom?: Unit; // never assigned — exists only to differentiate the type
constructor(private readonly value: number) {}
static meters(v: number): Distance<"meters"> {
return new Distance(v);
}
static feet(v: number): Distance<"feet"> {
return new Distance(v);
}
add(other: Distance<Unit>): Distance<Unit> {
return new Distance<Unit>(this.value + other.value);
}
}
const a = Distance.meters(5);
const b = Distance.feet(3);
a.add(a); // ✅ Distance<"meters">
a.add(b); // ❌ Distance<"feet"> is not assignable to Distance<"meters">
Unit is never stored, read, or logged — _phantom is declared but genuinely never assigned a value. It exists solely so that when TypeScript structurally compares Distance<"meters"> against Distance<"feet">, it has something in the member list where the two disagree. Without _phantom (or Unit appearing in some other member, like add's parameter), the two instantiations would have identical shapes and TypeScript's structural typing would treat them as interchangeable — the phantom parameter would be silently ignored.
this typesclass Query<Phase> {
private constructor(private sql: string) {}
static create(sql: string): Query<"unvalidated"> {
return new Query(sql);
}
validate(this: Query<"unvalidated">): Query<"validated"> {
return new Query(this.sql);
}
execute(this: Query<"validated">): string[] {
return [`ran: ${this.sql}`];
}
}
Query.create("SELECT * FROM users").execute(); // ❌ needs Query<"validated">
Query.create("SELECT * FROM users").validate().execute(); // ✅
Phase is never stored either — the this parameter on validate and execute is what restricts which phase of Query each method can even be called on.
Unit were truly unused everywhere, Distance<"meters"> and Distance<"feet"> would be structurally identical and freely interchangeable._phantom is undefined forever; Phase and Unit are erased on compile. All the enforcement happens at the type checker, before the code ever runs."Make it a compile error to call execute() before validate(), without adding a runtime flag" is a sharp exercise — it tests whether someone can reach for a type-only mechanism (phantom type + this parameter typing) instead of reflexively adding a runtime isValidated boolean and an if check, which solves the same problem a whole class of bugs later than the type checker could have.