Kotlin & Java Generics
Generics are one of the most powerful features in modern programming languages, allowing for type-safe collections and functions. When interoperating between Kotlin and Java, understanding how each language handles generics is crucial for writing clean, safe, and effective code.
Introduction to Generics
Generics allow you to write code that works with different types while maintaining type safety. Both Kotlin and Java support generics, but they have some important differences in their implementations.
// A generic function in Kotlin
fun <T> printItem(item: T) {
println(item)
}
// Usage
printItem("Hello") // Works with String
printItem(42) // Works with Int
When working across language boundaries, these differences can sometimes lead to challenges. Let's explore how Kotlin and Java generics interact.
Basic Generics Interoperability
Calling Java from Kotlin
Kotlin can seamlessly work with Java generics. When you use a Java class with generics in Kotlin, the syntax feels natural.
// Using Java's ArrayList in Kotlin
val javaList: java.util.ArrayList<String> = java.util.ArrayList()
javaList.add("Kotlin")
javaList.add("calling")
javaList.add("Java")
println(javaList.joinToString(" ")) // Output: Kotlin calling Java
Calling Kotlin from Java
Similarly, Java can work with Kotlin's generic classes and functions:
// Kotlin file
class Box<T>(var value: T)
fun <T> createBox(value: T): Box<T> = Box(value)
// Java file
public class JavaUsage {
public static void main(String[] args) {
// Using Kotlin's Box class in Java
Box<String> stringBox = KotlinFileKt.createBox("Hello from Java");
System.out.println(stringBox.getValue()); // Output: Hello from Java
}
}
Type Erasure and Reified Types
Both Java and Kotlin use type erasure for generics, which means generic type information is not available at runtime. However, Kotlin offers a way around this with reified type parameters in inline functions.
// Java-like approach with type check
fun <T> printType(value: T) {
// Can't do: if (value is T) - T is not available at runtime
println(value?.javaClass?.name)
}
// Kotlin's reified approach
inline fun <reified T> isType(value: Any?): Boolean {
return value is T
}
// Usage
fun main() {
val number = 42
println(isType<Int>(number)) // Output: true
println(isType<String>(number)) // Output: false
}
When calling Java generic methods from Kotlin, you cannot use reified type parameters because Java doesn't support this feature.
Variance: The Key Difference
One of the most significant differences between Kotlin and Java generics is how they handle variance.
Java: Use-site Variance
Java uses wildcard types for variance:
// Java
// Producer - use extends (read-only)
List<? extends Number> numbers = new ArrayList<Integer>();
Number n = numbers.get(0); // OK
// numbers.add(2); // Error: can't add to List<? extends Number>
// Consumer - use super (write-only)
List<? super Integer> intList = new ArrayList<Number>();
intList.add(10); // OK
// Integer i = intList.get(0); // Error: can't guarantee type
Kotlin: Declaration-site Variance
Kotlin introduces the concepts of out
(covariance) and in
(contravariance) right in the type parameter declaration:
// Kotlin
// Producer - use "out" (covariant)
class Producer<out T>(private val value: T) {
fun get(): T = value
// Cannot have functions that take T as parameter
}
// Consumer - use "in" (contravariant)
class Consumer<in T> {
fun process(value: T) {
// Do something with value
}
// Cannot have functions that return T
}
Interoperability with Variance
When using Java collections in Kotlin:
// Java's List<? extends Number> becomes List<out Number> in Kotlin
fun takeNumbers(numbers: List<out Number>) {
val first: Number = numbers.first() // OK
// numbers.add(2) // Error: can't add to read-only List
}
// Java's List<? super Integer> becomes List<in Int> in Kotlin
fun addNumbers(numbers: MutableList<in Int>) {
numbers.add(10) // OK
// val x: Int = numbers.first() // Error: can't guarantee type
}
When using Kotlin collections in Java:
// Kotlin
class Fruit
class Apple : Fruit()
class Orange : Fruit()
// ReadOnlyBox is covariant (produces T, doesn't consume it)
class ReadOnlyBox<out T>(val item: T)
// Exposing to Java
fun createAppleBox(): ReadOnlyBox<Apple> = ReadOnlyBox(Apple())
fun processFruitBox(box: ReadOnlyBox<Fruit>) {
println("Processing: ${box.item}")
}
// Java
public class JavaClient {
public static void main(String[] args) {
// This works because ReadOnlyBox<Apple> is a subtype of ReadOnlyBox<Fruit>
ReadOnlyBox<Apple> appleBox = KotlinFileKt.createAppleBox();
KotlinFileKt.processFruitBox(appleBox); // Output: Processing: Apple@...
}
}
Star Projections
Kotlin uses star projections (*
) as a safer alternative to Java's raw types:
// Java raw type in Kotlin becomes a star projection
val javaRawList: java.util.ArrayList<*> = java.util.ArrayList<String>()
// You can only read from this list with Any? type
val item: Any? = javaRawList[0]
// But you cannot write to it (except null)
// javaRawList.add("test") // Error
javaRawList.add(null) // OK
Practical Examples
Generic Repository Pattern
This example shows a repository pattern implementation that works across Kotlin and Java:
// Kotlin file: Repository.kt
interface Repository<T> {
fun save(item: T)
fun findById(id: String): T?
fun findAll(): List<T>
}
class InMemoryRepository<T> : Repository<T> {
private val storage = mutableMapOf<String, T>()
override fun save(item: T) {
if (item is Identifiable) {
storage[item.id] = item
}
}
override fun findById(id: String): T? = storage[id]
override fun findAll(): List<T> = storage.values.toList()
}
interface Identifiable {
val id: String
}
data class User(override val id: String, val name: String) : Identifiable
// Java file: JavaUsage.java
public class JavaUsage {
public static void main(String[] args) {
// Using Kotlin's generic repository from Java
Repository<User> userRepo = new InMemoryRepository<>();
User user1 = new User("1", "Alice");
User user2 = new User("2", "Bob");
userRepo.save(user1);
userRepo.save(user2);
// Get all users
List<User> users = userRepo.findAll();
for (User user : users) {
System.out.println(user.getName());
}
// Find by id
User found = userRepo.findById("1");
if (found != null) {
System.out.println("Found user: " + found.getName());
}
}
}
Output:
Alice
Bob
Found user: Alice
Generic Event System
This example demonstrates a generic event system that can be used from both Kotlin and Java:
// Kotlin file: EventSystem.kt
interface Event
class MessageEvent(val message: String) : Event
class ErrorEvent(val error: String, val code: Int) : Event
interface EventListener<in E : Event> {
fun onEvent(event: E)
}
class EventBus {
private val listeners = mutableMapOf<Class<*>, MutableList<EventListener<*>>>()
fun <T : Event> subscribe(eventType: Class<T>, listener: EventListener<T>) {
listeners.getOrPut(eventType) { mutableListOf() }.add(listener)
}
@Suppress("UNCHECKED_CAST")
fun publish(event: Event) {
val eventClass = event.javaClass
listeners[eventClass]?.forEach { listener ->
(listener as EventListener<Event>).onEvent(event)
}
}
}
// Java file: JavaEventUsage.java
public class JavaEventUsage {
public static void main(String[] args) {
EventBus eventBus = new EventBus();
// Java subscribing to Kotlin events
eventBus.subscribe(MessageEvent.class, event -> {
System.out.println("Message received: " + event.getMessage());
});
eventBus.subscribe(ErrorEvent.class, event -> {
System.out.println("Error received: " + event.getError() +
" (code: " + event.getCode() + ")");
});
// Publish events
eventBus.publish(new MessageEvent("Hello from Java!"));
eventBus.publish(new ErrorEvent("Something went wrong", 500));
}
}
Output:
Message received: Hello from Java!
Error received: Something went wrong (code: 500)
Gotchas and Tips
- Array Generics: Arrays in Java are invariant and reified, which can cause issues when working with generics:
// This is valid in Kotlin
fun printArray(array: Array<Any>) {
array.forEach { println(it) }
}
// But this will fail
val stringArray: Array<String> = arrayOf("a", "b", "c")
printArray(stringArray) // Type mismatch error
- Non-null Type Parameters: Kotlin's generics integrate with its null safety system:
// T is nullable by default
class Box<T>(var value: T)
// T is non-nullable
class NonNullBox<T : Any>(var value: T)
// Usage
val nullableBox = Box<String?>(null) // OK
val nonNullBox = NonNullBox<String>("value") // OK
// val illegalBox = NonNullBox<String?>(null) // Compile error
- Type Projections Across Languages:
// Kotlin
fun takesCovariantList(list: List<out Number>) { /*...*/ }
// When called from Java
// In Java, you'd use:
// takesCovariantList(List<? extends Number> list)
Summary
Working with generics across Kotlin and Java requires understanding a few key differences:
-
Variance: Java uses use-site variance with wildcards (
? extends
,? super
), while Kotlin uses declaration-site variance (out
,in
). -
Type Parameters: Kotlin integrates its null safety system with generics, allowing for non-null type parameters with
T : Any
. -
Reified Types: Kotlin's inline functions with reified type parameters provide a way to access type information at runtime, which Java doesn't support.
-
Star Projections: Kotlin's
*
provides a safer alternative to Java's raw types.
By understanding these differences, you can write code that interoperates effectively between Kotlin and Java, leveraging the strengths of both languages.
Exercises
-
Create a generic
Pair<A, B>
class in Kotlin and use it from Java code. -
Write a generic
Converter<From, To>
interface in Kotlin with anconvert(from: From): To
method, and implement it in both Kotlin and Java. -
Implement a generic
Result<T>
class that can represent either a success with a value of typeT
or a failure with an error message. -
Create a Java collection that uses wildcards, and then access and manipulate it from Kotlin code.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)