When designing a public generic API, how do you decide between leaving a type parameter unbounded (<T>), bounding to <T : Any>, or bounding to a specific type, and what are the compatibility consequences of changing it later?
answer
- Loosest bound that still does the job
- <T> containers, <T : Any> keys/ids, specific when calling members
- Tightening = source + binary breaking
- Loosening can break overriders
- Bound is part of public signature/erasure
basics
~20 sPick the loosest bound that still lets your code work. Use <T> when null is fine, <T : Any> to ban null, a specific bound when you need that type's methods. Tightening a bound later can break callers.
solid answer
~50 sChoose the **least restrictive upper bound** that still grants the operations your implementation needs, because the bound is both a caller restriction and a body capability. Use bare `<T>` (implicit `Any?`) for pure containers/pass-throughs where null is legitimately a value. Use `<T : Any>` when null must be excluded for invariants (keys, registry entries, non-null caches). Use a concrete bound like `<T : Comparable<T>>` or `<T : Number>` only when you call those members. For evolution: **tightening** a bound (e.g. `<T>` → `<T : Any>`) is a **source- and binary-incompatible** change — existing callers passing nullable types stop compiling, and the erased signature/generic metadata changes. **Loosening** a bound is generally source-compatible for callers but can still break overriding subclasses and is a metadata change. So commit to the right bound early, default to permissive for flexibility, and reserve tighter bounds for genuine invariants. Document the rationale.
code
kotlin · 12 lines// Container: permissive, null allowed
class Box<T>(val value: T)
// Registry key must be non-null
class Registry<K : Any, V> {
private val map = HashMap<K, V>()
fun put(key: K, value: V) { map[key] = value }
}
// Needs ordering -> specific bound
fun <T : Comparable<T>> sortedPair(a: T, b: T): Pair<T, T> =
if (a <= b) a to b else b to ago deeper
Knows you can leave T unbounded or add <T : Any> to ban null.
Picks bounds based on which members the body needs and recognizes <T : Any> for non-null cases.
Applies the loosest-sufficient-bound rule and understands tightening breaks callers.
Reasons about source/binary compatibility, erasure metadata, override impact, platform-type leakage, and prefers additive overloads over signature-tightening on published APIs.
## The decision framework The upper bound simultaneously (a) restricts which type arguments callers may supply and (b) grants the implementation the members it can call. So the rule is: **bound by the loosest type that still lets the body do its job.** ### Option 1 — bare `<T>` (implicit `Any?`) - Most permissive: any type, nullable or not. - Right for pure data structures and pass-throughs: `Box<T>`, `Pair<A, B>`, `List<T>` — they store/forward values and don't need to *do* anything to them. - Cost: the body can only use `Any?` members and must treat values as nullable. ### Option 2 — `<T : Any>` - Forbids nullable type arguments (`String?` is not `<: Any`). - Use when a `null` value would violate an invariant: map keys, cache/registry entries, identifiers. - Body can treat `T` values as non-null (no `?.`). ### Option 3 — specific bound (`<T : Comparable<T>>`, `<T : Number>`, `<T : CharSequence>` …) - Use only when you actually call those members (ordering, numeric conversion, char access). - Most restrictive; excludes otherwise-valid types, so don't over-constrain. ## Compatibility consequences of changing a bound Generic bounds are part of the **public signature** and are encoded in Kotlin metadata (and, via erasure, can change the erased JVM signature — e.g. `<T>` erases to `Object`, `<T : Number>` erases to `Number`). ```kotlin // v1 fun <T> register(item: T) { /* ... */ } // v2 — TIGHTENED fun <T : Any> register(item: T) { /* ... */ } ``` - **Tightening** (`<T>` → `<T : Any>` → `<T : Number>`): - **Source-incompatible**: callers passing now-illegal type arguments stop compiling (e.g. `register<String?>(null)`). - **Binary-incompatible** when erasure changes (`Object` → `Number`): existing compiled call sites can break with `NoSuchMethodError`. - **Loosening** (`<T : Number>` → `<T>`): - Usually **source-compatible** for callers (they can now pass more). - Can break **subclasses that override** the member (their override's bound no longer matches) and is still a metadata/erasure change, so treat as binary-sensitive. ## Practical guidance 1. Default to permissive (`<T>` or `<T : Any>`); add specific bounds only when the implementation demands the members. 2. Decide the null policy deliberately: containers usually `<T>`; keys/ids usually `<T : Any>`. 3. Treat bound changes on published APIs like any other signature change — version and document them; prefer adding a new overload over tightening an existing one. 4. Remember platform types from Java interop can sneak nullable values past an `<T : Any>` expectation; validate at boundaries if it matters. ## Why not always tighten 'to be safe'? Over-constraining hurts reusability (clients with valid but excluded types can't use your API) and makes later loosening a breaking-ish change. The loosest-sufficient-bound rule keeps the API both safe and flexible.
- Is changing <T> to <T : Any> on a published library function a breaking change?Yes — it is source-incompatible (callers passing nullable type arguments stop compiling) and can be binary-incompatible if erasure or metadata changes; prefer adding a new overload instead.
- Why is loosening a bound safer for callers but still risky?Callers gain flexibility and keep compiling, but subclasses overriding the member may no longer match the relaxed bound, and the signature/metadata still changes, so it's not a free operation.
Setting a bound is like a hiring requirement: too strict and you reject good candidates (callers); raise the bar later and you'd have to fire people already hired (break callers).
saying these in an interview costs you the question
- Always tightening bounds 'for safety' without need
- Assuming bound changes are non-breaking on public APIs
- Not knowing the bound affects JVM erasure (Object vs Number)
- Bounding containers with <T : Any> when null is a valid value
- Ignoring Java platform types that can slip nulls past <T : Any>