Lesson 68 of 80
A DI container's `get("token")` looks like it has to return unknown — until you tie the token to the return type with a generic keyed off a registry interface.
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
A dependency injection container is just a registry: you register an instance under a name, and later get it back out. The obvious way to type get is get(token: string): unknown — but that throws away everything. Every caller ends up casting, and nothing catches a typo'd token or a wrong assumption about what comes back.
The fix is to stop treating tokens as plain strings and start treating them as keys into a known map of services, so the return type can be looked up from the key you actually passed.
interface ServiceMap {
logger: Logger;
db: Database;
authService: AuthService;
}
class Container<TMap> {
private instances = new Map<keyof TMap, unknown>();
register<K extends keyof TMap>(token: K, instance: TMap[K]): void {
this.instances.set(token, instance);
}
get<K extends keyof TMap>(token: K): TMap[K] {
const instance = this.instances.get(token);
if (!instance) throw new Error(`No provider registered for ${String(token)}`);
return instance as TMap[K];
}
}
const container = new Container<ServiceMap>();
container.register("logger", new ConsoleLogger());
const logger = container.get("logger"); // logger: Logger — not unknown, not a union
ServiceMap is a plain interface that just lists every token and what it resolves to. The trick is entirely in get's signature: K extends keyof TMap makes K its own type parameter, inferred fresh from whatever literal you pass in ("logger"). Because K is inferred per call, TMap[K] — an indexed access type — resolves to exactly Logger for that call, not to every possible service type at once.
Internally, instances is still a Map<keyof TMap, unknown> — there's no way to make a real runtime Map hold genuinely different value types per key, so the safety is entirely at the boundary. The one as TMap[K] cast inside get is a deliberate, narrow escape hatch: it's safe specifically because register and get share the same generic constraint, so nothing outside the container's own two methods can put a mismatched value under a token.
get(token: keyof TMap): TMap[keyof TMap] is not the same as get<K extends keyof TMap>(token: K): TMap[K]. The first gives you a union of every possible service back regardless of which token you passed; only the generic version narrows per call.as TMap[K] cast is fine here — it's a single, tightly scoped assertion at the one place the type system genuinely can't verify a runtime Map lookup, not a sign the design is unsound.Symbols or class constructors as tokens instead of string literals — the return-type-by-key mechanism is identical no matter what the token's actual runtime value is."Design a get method on a container so it returns the right type for each token" is a genuinely good exercise because the naive answer (unknown, or a union) is what most people reach for first — recognizing that a generic tied to keyof TMap gets inferred per call, rather than resolved once for the whole method, is the specific insight that separates a working answer from a merely plausible one.