This page is also available in Italiano.

Continua in italiano

Designing type-safe tokens without explicit generics

Published on July 24, 2026

A DI container needs some way to identify a service. String keys are the obvious choice, and the easiest to get wrong: two unrelated modules can pick the same string for two different things, and a typo in the string surfaces as a runtime error instead of a compile error.

TinyDI uses tokens instead. The interesting part is not that tokens exist — plenty of containers have some notion of a token. It is how a TinyDI token lets resolve() know the exact return type, without you ever writing resolve<IMailService>(token).

The problem: inferring T with no explicit generic

The goal was for this to just work, with the type on the right entirely inferred from MailServiceToken:

ts
// No explicit generic needed — inferred as IMailService.
const mailService = container.resolve(MailServiceToken);

That means the token itself has to carry IMailService somehow, at the type level, even though at runtime a token is just an identifier — there is nothing to "carry" once the program is actually running.

The token shape: symbol identity, phantom type

ts
export interface Token<T> {
  readonly symbol: symbol;
  readonly description: string;
  readonly __type?: T;
}
 
export function createToken<T>(description: string): Token<T> {
  return {
    symbol: Symbol(description),
    description,
  };
}

__type is the whole trick: it is declared in the interface purely for the type checker, marked optional, and never assigned in the implementation. createToken returns an object with only symbol and description — no __type property exists on the actual runtime object. TypeScript still uses it to carry T through the type, which is exactly what lets resolve(token: Token<T>): T infer the right return type from whatever token you pass it.

A detail that almost snuck in

The first working version of createToken cast its return value explicitly as Token<T>. ESLint's no-unnecessary-type-assertion rule flagged it: the object literal { symbol, description } is already structurally assignable to Token<T>, because __type is optional. The cast was pure noise — deleting it changed nothing about what the type checker accepts.

Identity is the symbol, not the description

description is a plain string, used only as a human-readable label in error messages ("No registration found for token ..."). It is never used as a lookup key. The container maps registrations by token.symbol, so two tokens created with the same description are still two distinct registrations:

ts
const a = createToken<string>('name');
const b = createToken<string>('name');
a.symbol !== b.symbol; // true — distinct tokens, no collision

Consequences

This rules out an entire category of bug that string-keyed containers have to work around by convention (namespacing keys, linting for duplicates): here it is structurally impossible for two unrelated createToken calls to collide, because JavaScript guarantees every Symbol() call produces a unique value.

The cost is close to zero — one extra allocation per token, created once at module load time, not per resolution. That is a rare shape for a trade-off: usually a stronger guarantee costs you something, in runtime overhead or in ergonomics, and here neither cost applies.

This was one of two spots in the core library that needed actual design work instead of an obvious default — the token shape here, and figuring out, after a cycle is detected, how to explain it back to a developer.

to navigate to select Esc to close