TypeLiteral: Smuggling Generics Past the Erasure Checkpoint
Series: Guice for JVM Engineers · II (Advanced & Real-World) — Part 2 of 8
Here’s an uncomfortable fact about the JVM: at runtime, List<String> and List<Int> are the same type. The compiler knows the difference, checks it, and then, having done its job, throws the information away. This is type erasure, and it’s why you can’t write if (x is List<String>) in Kotlin or x instanceof List<String> in Java. By the time the bytecode runs, the <String> is gone. There is only List.
Guice has a problem with this, because Guice’s whole job is to look up bindings by type. When you call bind(List::class.java), the only type Guice receives is java.util.List — raw, erased, generic-less. So if your graph needs both a List<String> of feature flags and a List<User> of admins, raw Class literals can’t tell them apart. They’re both just List.class. Guice would either overwrite one binding with the other or refuse to choose.
The fix is a small, clever class called TypeLiteral, and learning it is the price of admission for everything generic in Guice: multibindings, generic repositories, typed event buses, the lot. Every sample below has a Kotlin and a Java tab; pick your language once and read straight down.
The trick: capture the type before it erases
TypeLiteral<T> is Guice’s way of preserving a full generic type into runtime. The mechanism is a genuinely neat hack: erasure throws away type arguments on variables and calls, but it keeps the type arguments baked into a class’s extends clause. A subclass of Foo<String> remembers, forever, that it extended Foo<String>, and that’s reflectable via getGenericSuperclass().
So you don’t call TypeLiteral; you create an empty anonymous subclass of it, and Guice reads the type parameter back out of the subclass’s supertype. Guice’s own Javadoc spells out the recipe: “Clients create an empty anonymous subclass. Doing so embeds the type parameter in the anonymous class’s type hierarchy so we can reconstitute it at runtime despite erasure.”
import com.google.inject.TypeLiteral
// The trailing `{}` is the whole point: it's an anonymous SUBCLASS,
// not an instance. That subclass carries `List<String>` in its supertype.
val stringList = object : TypeLiteral<List<String>>() {}
val userList = object : TypeLiteral<List<User>>() {}
println(stringList.type) // java.util.List<java.lang.String>
println(stringList.rawType) // interface java.util.List
println(stringList == userList) // false — they're genuinely different nowimport com.google.inject.TypeLiteral;
// The trailing `{}` is the whole point: it's an anonymous SUBCLASS,
// not an instance. That subclass carries `List<String>` in its supertype.
TypeLiteral<List<String>> stringList = new TypeLiteral<List<String>>() {};
TypeLiteral<List<User>> userList = new TypeLiteral<List<User>>() {};
System.out.println(stringList.getType()); // java.util.List<java.lang.String>
System.out.println(stringList.getRawType()); // interface java.util.List
System.out.println(stringList.equals(userList)); // false — genuinely different nowThat equals returning false is the entire payoff. Two TypeLiterals for distinct generic types are distinct objects with distinct hash codes, so Guice can use them as map keys. Erasure took the type away from Class; TypeLiteral smuggled it back in.
In Java you reach for getType() / getRawType(); in Kotlin those JavaBean getters surface as the .type / .rawType properties. Same TypeLiteral class either way — there’s nothing Kotlin-specific about it.
Binding a generic type
Now the useful part. Binder has an overload — bind(TypeLiteral<T>) — that mirrors the familiar bind(Class) but accepts a full generic type. Say you have a generic Repository<T> interface and a UserRepository that implements Repository<User>. You want everyone who asks for a Repository<User> to get the UserRepository:
import com.google.inject.AbstractModule
import com.google.inject.TypeLiteral
interface Repository<T> {
fun findById(id: String): T?
}
class UserRepository : Repository<User> {
override fun findById(id: String): User? = TODO()
}
class RepositoryModule : AbstractModule() {
override fun configure() {
// bind(Repository::class.java) would be ambiguous and lossy.
// The TypeLiteral pins it to Repository<User> exactly.
bind(object : TypeLiteral<Repository<User>>() {})
.to(UserRepository::class.java)
}
}import com.google.inject.AbstractModule;
import com.google.inject.TypeLiteral;
interface Repository<T> {
T findById(String id);
}
class UserRepository implements Repository<User> {
public User findById(String id) { return null; }
}
class RepositoryModule extends AbstractModule {
@Override protected void configure() {
// bind(Repository.class) would be ambiguous and lossy.
// The TypeLiteral pins it to Repository<User> exactly.
bind(new TypeLiteral<Repository<User>>() {})
.to(UserRepository.class);
}
}The .to(...) target is still an ordinary Class — UserRepository isn’t itself generic at the point of use (it’s already fixed Repository<User>), so a plain class literal is fine there. The TypeLiteral only matters on the left side, where Guice records the key it’ll match injections against.
Injecting it is unremarkable, which is the goal. A constructor just declares the generic type as a parameter, and Guice matches it against the binding you registered:
import jakarta.inject.Inject
class UserService @Inject constructor(
private val users: Repository<User>, // matched to the bind() above
) {
fun load(id: String): User? = users.findById(id)
}import jakarta.inject.Inject;
class UserService {
private final Repository<User> users; // matched to the bind() above
@Inject UserService(Repository<User> users) {
this.users = users;
}
User load(String id) { return users.findById(id); }
}No TypeLiteral at the injection site — only at the binding site. Guice reconstructs the generic type of a constructor parameter from the reflectable signature (the parameter’s generic type is retained; it’s local variables and instanceof checks that lose it), turns it into the matching Key, and looks it up. The plumbing is invisible.
Keys and annotated generics
Under the hood, every binding is identified by a Key — the pair (type, optional binding annotation). bind(Class) and bind(TypeLiteral) are conveniences that build a Key for you. When you need the key directly — which you will, the moment you mix generics with qualifiers — Key.get has overloads that take a TypeLiteral:
Key.get(TypeLiteral<T>) // verified in Key.java
Key.get(TypeLiteral<T>, Class<? extends Annotation>)
Key.get(TypeLiteral<T>, Annotation)
Why bother? Because @SomeQualifier Repository<User> and a plain Repository<User> need to be different bindings, and only a Key can carry both halves. Suppose you have a read-replica repository and a primary, both Repository<User>, distinguished by a @ReadOnly qualifier:
import com.google.inject.AbstractModule
import com.google.inject.Key
import com.google.inject.TypeLiteral
class RepositoryModule : AbstractModule() {
override fun configure() {
val userRepo = object : TypeLiteral<Repository<User>>() {}
// Plain Repository<User> -> the primary.
bind(userRepo).to(PrimaryUserRepository::class.java)
// @ReadOnly Repository<User> -> the replica. Same type, different Key.
bind(Key.get(userRepo, ReadOnly::class.java))
.to(ReplicaUserRepository::class.java)
}
}
class ReportRunner @Inject constructor(
@ReadOnly private val users: Repository<User>, // resolves to the replica
) { /* ... */ }import com.google.inject.AbstractModule;
import com.google.inject.Key;
import com.google.inject.TypeLiteral;
class RepositoryModule extends AbstractModule {
@Override protected void configure() {
TypeLiteral<Repository<User>> userRepo =
new TypeLiteral<Repository<User>>() {};
// Plain Repository<User> -> the primary.
bind(userRepo).to(PrimaryUserRepository.class);
// @ReadOnly Repository<User> -> the replica. Same type, different Key.
bind(Key.get(userRepo, ReadOnly.class))
.to(ReplicaUserRepository.class);
}
}
class ReportRunner {
private final Repository<User> users; // resolves to the replica
@Inject ReportRunner(@ReadOnly Repository<User> users) {
this.users = users;
}
}Two bindings, same generic type, told apart by the annotation half of the key. There’s also a shorthand — bind(typeLiteral).annotatedWith(ReadOnly::class.java).to(...) — which builds the same annotated key through the fluent builder; use whichever reads better to you. (annotatedWith lives on AnnotatedBindingBuilder, returned by both bind overloads.) Binding annotations and qualifiers got their own treatment back in Guice I; here the only new wrinkle is feeding a TypeLiteral into the key instead of a Class.
Going deeper: the noise, and a way out
Let’s be honest about the syntax. That object : TypeLiteral<Repository<User>>() {} in Kotlin (or new TypeLiteral<...>() {} in Java) is ceremony. It’s an anonymous-class declaration masquerading as a value, and it reads like the language is fighting you, because in a sense it is. You’re hand-rolling a workaround for a JVM limitation, once per generic binding.
You also can’t shorten it by stuffing the type in a variable and reusing the variable’s Kotlin type — TypeLiteral needs a real anonymous subclass at each {} site to capture the supertype, so the {} has to physically appear in source. There’s no TypeLiteral.of<Repository<User>>() factory in core Guice that captures the parameter for you; the capture is structural, not a method call.
What Kotlin can do is hide the boilerplate behind a reified inline helper, so the call site reads bind(typeLiteral<Repository<User>>()) or even bind<Repository<User>>(). Misk ships exactly this kind of helper, and the technique leans on Kotlin’s reified type parameters plus Guice’s Types utility (Types.newParameterizedType(...), which I confirmed exists in com.google.inject.util.Types) to build the Type without an anonymous class. We’re deferring the full build-it-yourself treatment to the Kotlin-idioms capstone at the end of this series — it deserves its own post, and dropping it here would bury the concept under syntax. For now, write the object : TypeLiteral<...>() {} longhand and know that a tidier wrapper is coming.
If you’re in Java, there’s no reified escape hatch — the anonymous subclass is the idiom, and it’s fine. It’s verbose, but it’s honest and it’s a one-liner. Don’t fight it.
Gotchas
-
The trailing
{}is mandatory, and silent to omit.object : TypeLiteral<List<String>>()without braces (ornew TypeLiteral<List<String>>()in Java) won’t compile in Java — the constructor isprotected— and the failure mode if you find a way around that is a runtime"Missing type parameter."fromgetSuperclassTypeParameter. No anonymous subclass, no captured type. Always include the{}. -
Erasure still bites inside the braces. A
TypeLiteralcaptures whatever concrete type you write between the angle brackets. If you try to parameterize it with a type variable —object : TypeLiteral<List<T>>() {}inside a generic function whereTis erased — you captureList<T>’s erased shape, not the caller’s actual type.TypeLiteraldefeats erasure only for types written out literally at the{}site. -
bind(Repository::class.java)is not the same binding asbind(object : TypeLiteral<Repository<User>>() {}). The raw one and the parameterized one are distinct keys. Injecting a rawRepositorywon’t find yourRepository<User>binding, and vice versa — Guice will report a missing binding rather than guess. -
getRawType()is lossy on purpose. It hands you backList(an erasedClass), notList<String>. It’s there for when you genuinely need the runtime class; reach forgetType()when you need the full genericType. Don’t use the raw type to compare two literals — use the literals themselves;equals/hashCodeare defined over the full type. -
Equality is structural, so canonicalization matters. Two literals for the same generic type are
equaleven if written in different files — Guice canonicalizes the underlyingType. This is what lets a binding in one module satisfy an injection declared in another. Good news, but it means a subtle difference (a wildcard, a different bound) produces a different key and a confusing “no binding” error. -
Don’t reach for
TypeLiteralto bind aList<Something>you assemble yourself. If you’re collecting multiple implementations into aSet<T>orMap<K, V>, that’s what multibindings (Multibinder,MapBinder) are for — they manage the collection’s generics for you. Hand-binding aTypeLiteral<Set<Foo>>to a literal set is a smell; multibindings are the next post’s neighbor and the right tool.
What’s next
TypeLiteral is the key (pun intended) that unlocks the generic corners of Guice — you’ll see it again under multibindings, provider injection, and any typed-collection wiring. The mental model is small: erasure deletes type arguments from values, an anonymous subclass preserves them in its supertype, and TypeLiteral reads them back so Guice can use the full type as a binding key.
Next we tackle a different gap in plain constructor injection: what happens when an object needs both injected dependencies and runtime arguments the injector can’t know — a Connection plus a user-supplied timeout. That’s Part 3 — Assisted Injection, where Guice generates a factory that mixes the two.
Target keyword(s): guice typeliteral, guice generics.
Comments