Lesson 25 · Module 4.2 — Library-grade patterns
Here is a class whose generic isn't inferred once and frozen — it grows on every
.set() call, so that later .get() only accepts keys you've actually
set. The trick: each .set() returns this typed as the
intersection of the old map type with the new key. The accumulated type is a
"phantom" — it exists only at compile time and vanishes at runtime.
When a method returns SomeClass<TOld & Something> instead of plain
this, it's accumulating type information. Read the return type,
not the body — the body is usually just return this. The compile-time type is a
running total of everything you've done.
A classic example — Matt Pocock's TypeSafeStringMap. Read the two signatures, not
the implementations:
class TypeSafeStringMap<TMap extends Record<string, string> = {}> {
private map: TMap;
constructor() { this.map = {} as TMap; }
get(key: keyof TMap): string { // only keys ALREADY in TMap
return this.map[key];
}
set<K extends string>(
key: K,
value: string
): TypeSafeStringMap<TMap & Record<K, string>> { // ← the magic is here
(this.map[key] as any) = value;
return this; // runtime: same object; type: widened
}
}
Two things to notice. First, the default = {} means a fresh map starts knowing
zero keys. Second, get takes keyof TMap — so what
get allows is exactly whatever TMap has accumulated so far.
set's return type is the key. It captures the new key as its own parameter
K, then hands back the class re-parameterised with
TMap & Record<K, string> — the old map plus one more key. Chain three
calls and the intersections pile up:
const map = new TypeSafeStringMap() // TMap = {}
.set("matt", "pocock") // TMap = {} & Record<"matt", string>
.set("jools", "holland") // & Record<"jools", string>
.set("brandi", "carlile"); // & Record<"brandi", string>
// final TMap = { matt: string; jools: string; brandi: string }
map.get("matt"); // ✅ "matt" is in keyof TMap
map.get("jim"); // ❌ "jim" was never set — not in keyof TMap
TMapisn't "inferred" from anything — it's accumulated through type-level operations. Each.set()updates it by intersection;getreads it. The compiler is keeping a ledger.
At runtime, every .set() does the same dull thing: assign to an object and
return this. There is one object the whole time. The "growing" only happens in
the type — TMap never appears in emitted JS. That's what makes it a
phantom type: a parameter that carries information for the checker and is erased before
execution.
The body is return this — runtime-identical every call. If you only read
bodies you'd think nothing changes. The signature (TMap & Record<K,
string>) is where the design lives. In phantom-type builders, the types tell the
story the code hides.
Recall, don't re-read. (Answers reveal on click.)
.get("jim") a compile error after only matt/jools/brandi were set?.set() returns TypeSafeStringMap<TMap & Record<K, string>>. The & means…TMap called a "phantom" type here?The handbook background for the moving parts is
Intersection Types
and Generic Classes.
This exact builder is from Matt Pocock's
Total TypeScript. Best way to feel it:
paste the class into the TS Playground, chain
a few .set()s, and hover the result — watch the intersection grow.
Seeing a method that returns this but with a fancier generic? Read the return type and
ask whether it's accumulating a phantom type. Next lesson (4.3) takes the intersection trick the other
direction — using & with a unique brand to fake nominal typing.