Lesson 25 · Module 4.2 — Library-grade patterns

The builder that accumulates a type as you chain

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.

The reading move

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.

The whole class

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.

How the type grows: intersection per call

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
TMap isn't "inferred" from anything — it's accumulated through type-level operations. Each .set() updates it by intersection; get reads it. The compiler is keeping a ledger.

Why "phantom"? It's compile-time only

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 typeTMap 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.

Read the return type, trust the body less

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.

Check yourself

Recall, don't re-read. (Answers reveal on click.)

What makes .get("jim") a compile error after only matt/jools/brandi were set?
Each .set() returns TypeSafeStringMap<TMap & Record<K, string>>. The & means…
Why is TMap called a "phantom" type here?

Primary source

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.

Try it yourself

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.