skip to content

When designing a public API, how do you decide whether a class should be generic, and what are the long-term consequences of that choice under type erasure?

level: principalimportance: nice to knowfreq 28%

answer

  1. Generic when a caller-chosen type flows through in+out (container); else not
  2. One varying method -> generic method, not generic class
  3. Public type parameter = frozen contract; can't add/remove/reorder freely
  4. Erasure limits: no new T(), no new T[], no instanceof Box<String> -> use Class<T> token
  5. Variance is use-site (wildcards, PECS); class itself is invariant

basics

~20 s

Make a class generic when it genuinely works with a caller-chosen type that should flow through its API without casts, like a container. Avoid it when the type is fixed or only one method needs it. Because Java erases generics, once a type is public it is hard to change later without breaking callers.

solid answer

~50 s

Make a class generic when it is a true abstraction over a caller-supplied type — a container, holder, or pipeline where that type should appear in fields, parameters, and return types so callers get safety without casts (Box<T>, Cache<K,V>). Keep it non-generic when the type is fixed, when only a single method varies (use a generic *method* instead), or when adding a parameter only adds noise. The long-term cost is API stability: under type erasure the parameter is part of the public signature, so you cannot freely add, remove, or reorder type parameters later without source/binary incompatibility, and you cannot overload on different parameterizations (they erase identically). You also inherit erasure limitations — no new T(), no T[] creation, no instanceof Box<String> — which sometimes forces a Class<T> token. So the decision is partly about genuine reuse and partly a commitment: a public type parameter is forever.

go deeper

for a junior

Can state that some classes are generic and some are not, and that containers are the typical generic case.

for a middle

Distinguishes 'make the class generic' from 'make a method generic', and knows generics are a compile-time feature.

for a senior

Chooses generic-class vs generic-method appropriately, knows the core erasure limitations (no new T(), no overload-by-arg, Class<T> tokens) and applies PECS at method boundaries.

for a principal

Treats a public type parameter as a long-term API commitment: weighs evolvability, anticipates erasure consequences (tokens, bridge methods, variance, no parameterization-overload), and designs signatures that stay compatible and ergonomic over time.

## The question behind the question Beginners ask "how do I write a generic class?"; API designers ask "*should* this be generic, and what am I committing to?" Because Java implements generics by **type erasure** (type parameters are checked at compile time and then removed, leaving one runtime class with `T` replaced by its bound), the choice has consequences that outlive the first release. ## When a class *should* be generic Make the class generic when **a caller-chosen type genuinely flows through its API**: - A **container/holder**: `Box<T>`, `Optional<T>`, `List<E>`, `Cache<K,V>`. The element type belongs to the caller and should appear in inputs and outputs so no casts are needed. - A **producer/consumer pipeline** where the same type threads through multiple methods (`Stream<T>`, `Future<T>`). - A type whose **relationships** must be preserved — e.g. `put(K,V)` and `V get(K)` must agree. The test: *would callers otherwise have to cast, or could they accidentally mix types?* If yes, generics pay for themselves. ## When a class should *not* be generic - **The type is fixed.** A `JsonParser` that always returns `JsonNode` gains nothing from a parameter. - **Only one method varies.** Prefer a **generic method** over a generic class: `static <T> T parse(Class<T>, String)` keeps the class simple and binds `T` per call. - **The parameter would be unused or phantom**, appearing in the declaration but adding no safety — pure noise to every caller. - **It would force awkward bounds** on every user. If most callers must write `<T extends Something>` to use you, reconsider the shape. ## The long-term consequences (the real principal-level content) ### 1. A public type parameter is part of the signature — and is hard to change Adding, removing, or reordering type parameters on a published type breaks callers' source and often binary compatibility. `Foo` → `Foo<T>` forces existing users into raw types (warnings) or edits. You cannot evolve this casually. Treat the parameter list as a frozen part of the contract. ### 2. No overloading by parameterization Because `Box<String>` and `Box<Integer>` erase to the same `Box`, you cannot have two methods differing only by `process(List<String>)` vs `process(List<Integer>)` — identical erased signatures, compile error. This constrains API shape. ### 3. Erasure limitations leak into your design Inside a generic class you **cannot**: `new T()`, `new T[]` (array creation), `T.class`, or `o instanceof Box<String>`. When the implementation needs the runtime type, you must pass a **type token** `Class<T>` (the idiom behind `EnumMap`, `Class.cast`, and frameworks like Jackson's `TypeReference`). Decide early whether your class needs one, because adding it later changes constructors/signatures. ### 4. Variance shows up at the use site, not your declaration Java has **use-site variance** via wildcards (`List<? extends Number>`). As an API author you can guide callers (the PECS rule — Producer Extends, Consumer Super) by choosing wildcard parameters on *methods*, but the class declaration itself is invariant. Designing method signatures with `? extends`/`? super` is part of making a generic class pleasant to consume. ### 5. Bridge methods and inheritance When a generic type is subclassed with a concrete argument, the compiler synthesizes **bridge methods** to preserve polymorphism after erasure. This is usually invisible but matters for reflection, byte-code tooling, and subtle override puzzles. ## A decision checklist 1. Does a caller-chosen type appear in **multiple** API points (in and out)? → lean generic. 2. Does only one operation vary? → generic **method**, not class. 3. Will I ever need the runtime type inside? → plan a `Class<T>` token now. 4. Can I commit to this parameter list **forever**? → if not, defer adding it. 5. Will callers usually need wildcards to use it ergonomically? → design `extends`/`super` into the method signatures. ## Why it matters Generics are leverage, but under erasure they are also a **standing commitment** baked into your public signatures. The senior skill is writing one; the principal skill is judging *when the abstraction is real*, anticipating erasure's limits (tokens, no overload-by-arg, variance, bridge methods), and shaping an API that stays evolvable for years.

  • What is a type token and when does a generic class need one?
    A type token is a Class<T> (or richer TypeReference) passed into the class so it can recover the runtime type that erasure removed. You need it when the implementation must create instances, cast safely, or branch on the actual type — e.g. Class.cast, EnumMap(Class), or deserialization that must produce a real T. Design it into the constructor/factory from the start, since adding it later changes signatures.
  • Why can't you overload process(List<String>) and process(List<Integer>) in the same class?
    After erasure both become process(List), an identical signature, so the two methods would collide — the compiler rejects it as a name clash. You must give them different names or a different non-erased parameter to distinguish them.
  • How does the PECS rule guide a generic API's method signatures?
    Producer Extends, Consumer Super: if a method only reads T out of a parameter, accept ? extends T to admit subtypes; if it only writes T into a parameter, accept ? super T to admit supertypes. This use-site variance, chosen by you on method signatures, makes the generic class flexible for callers even though the class type itself is invariant.

saying these in an interview costs you the question

  • Adding type parameters 'just in case' — phantom parameters add noise and lock the API
  • Assuming you can overload methods on different parameterizations (they erase identically)
  • Expecting to change a type's parameter list later without breaking callers
  • Reaching for reflection/instanceof on parameterized types instead of a Class<T> token
  • Thinking the class declaration controls variance — it's invariant; wildcards live at use sites

context