This is part of our Java 21 Interview Preparation series. We’ll explore local variable type inference using Java’s var keyword (Java 10+), comparing it with Scala 3’s and Kotlin’s approach to type inference.
The Problem: Reducing Boilerplate While Maintaining Readability
Java has traditionally required explicit type declarations for all variables. Java 10 introduced the var keyword for local variable type inference, reducing verbosity while maintaining static typing. Let’s explore when to use var and best practices for readable code.
Problem Statement: Refactor a data processing pipeline using appropriate var declarations while maintaining readability.
Data Model
Let’s start with our Transaction model in all three languages:
Java 21
public record Transaction(
long id,
String product,
String category,
double amount,
int quantity,
LocalDate date,
String region) {
public Transaction {
Objects.requireNonNull(product, "Product cannot be null");
Objects.requireNonNull(category, "Category cannot be null");
if (amount < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
}
public double totalValue() {
return amount * quantity;
}
}
Scala 3
case class Transaction(
id: Long,
product: String,
category: String,
amount: Double,
quantity: Int,
date: LocalDate,
region: String
):
require(product.nonEmpty, "Product cannot be empty")
require(amount >= 0, "Amount cannot be negative")
def totalValue: Double = amount * quantity
Kotlin
data class Transaction(
val id: Long,
val product: String,
val category: String,
val amount: Double,
val quantity: Int,
val date: LocalDate,
val region: String
) {
init {
require(product.isNotBlank()) { "Product cannot be blank" }
require(amount >= 0) { "Amount cannot be negative" }
}
fun totalValue(): Double = amount * quantity
}
When var Improves Readability
1. Constructor Calls with Obvious Types
When the type is obvious from the constructor, var reduces redundancy.
Java 21
// Without var: redundant type declaration
ArrayList<Transaction> transactions = new ArrayList<Transaction>();
HashMap<String, Double> categoryTotals = new HashMap<String, Double>();
// With var: cleaner, type is obvious from constructor
var transactions = new ArrayList<Transaction>();
var categoryTotals = new HashMap<String, Double>();
Scala 3
// Type inference is the default - no explicit type needed
val transactions = List.empty[Transaction]
val categoryTotals = mutable.HashMap.empty[String, Double]
Kotlin
// Type inference is the default
val transactions = mutableListOf<Transaction>()
val categoryTotals = mutableMapOf<String, Double>()
2. Complex Generic Types
var significantly reduces noise with nested generics.
Java 21
// Without var: verbose and hard to read
Map<String, List<Transaction>> byCategory = transactions.stream()
.collect(Collectors.groupingBy(Transaction::category));
// With var: cleaner, type is clear from stream operation
var byCategory = transactions.stream()
.collect(Collectors.groupingBy(Transaction::category));
// Nested generics - var is even more helpful
var categoryToRegionMap = transactions.stream()
.collect(Collectors.groupingBy(
Transaction::category,
Collectors.groupingBy(Transaction::region)));
Scala 3
// Type inferred as Map[String, List[Transaction]]
val byCategory = transactions.groupBy(_.category)
// Nested generics handled seamlessly
val categoryToRegionMap = transactions.groupBy(_.category)
.view.mapValues(_.groupBy(_.region)).toMap
Kotlin
// Type inferred as Map<String, List<Transaction>>
val byCategory = transactions.groupBy { it.category }
// Nested generics handled seamlessly
val categoryToRegionMap = transactions.groupBy { it.category }
.mapValues { (_, txns) -> txns.groupBy { it.region } }
3. Loops and Iterations
Java 21
// var works well in for-each loops
for (var transaction : transactions) {
System.out.printf("%s: $%.2f%n", transaction.product(), transaction.amount());
}
// var in traditional for loops
for (var i = 0; i < transactions.size(); i++) {
System.out.printf("Index %d: ID %d%n", i, transactions.get(i).id());
}
Scala 3
// Type inferred in for-comprehension
for transaction <- transactions do
println(f"${transaction.product}: $$${transaction.amount}%.2f")
// With index
for (transaction, i) <- transactions.zipWithIndex do
println(f"Index $i: ID ${transaction.id}")
Kotlin
// Type inferred in for loop
for (transaction in transactions) {
println("${transaction.product}: $${transaction.amount}")
}
// With index
for ((index, transaction) in transactions.withIndex()) {
println("Index $index: ID ${transaction.id}")
}
4. Try-with-Resources (Java 10+)
try (var reader = new BufferedReader(new FileReader("data.txt"))) {
var line = reader.readLine();
while (line != null) {
processLine(line);
line = reader.readLine();
}
}
When Explicit Types Are Better
1. Non-Obvious Return Types
When the return type isn’t clear from the method name, explicit types improve readability.
// AVOID: What type is result?
var result = processor.process(transactions);
// BETTER: Explicit type documents intent
List<CategorySummary> summaries = processor.process(transactions);
2. Numeric Literals with Potential Ambiguity
// Could be confusing - is this int, long, double?
var value = 100;
// BETTER: Be explicit for clarity
long transactionId = 100L;
double amount = 100.0;
3. Chained Method Calls Where Type Isn’t Clear
// AVOID: Hard to know the intermediate and final types
var x = someObject.getProcessor().process().getResult();
// BETTER: Break into steps or use explicit type
ProcessResult result = someObject.getProcessor()
.process()
.getResult();
Where var Cannot Be Used (Java)
Java’s var has specific limitations that don’t exist in Scala or Kotlin:
// ❌ Fields - NOT ALLOWED
private var fieldValue = 10; // Compilation error!
// ❌ Method parameters - NOT ALLOWED
public void process(var data) { } // Compilation error!
// ❌ Return types - NOT ALLOWED
public var getData() { return List.of(); } // Compilation error!
// ❌ Without initializer - NOT ALLOWED
var uninitializedValue; // Compilation error!
// ❌ With null initializer - NOT ALLOWED
var nullValue = null; // Compilation error!
// ❌ Lambda expressions - NOT ALLOWED
var comparator = (a, b) -> a - b; // Compilation error!
// ❌ Array initializers - NOT ALLOWED
var numbers = {1, 2, 3}; // Compilation error!
var with Diamond Operator
When using var with the diamond operator, be careful about type inference:
// ✅ Type argument specified - infers ArrayList<Transaction>
var transactions = new ArrayList<Transaction>();
// ⚠️ Diamond with no context - infers ArrayList<Object>
var list = new ArrayList<>(); // Becomes ArrayList<Object>!
// ✅ Better: specify the type parameter
var explicitList = new ArrayList<String>();
val vs var: Immutability (Scala/Kotlin)
A key difference between Java and Scala/Kotlin is how they handle mutability:
| Aspect | Java | Scala | Kotlin |
|---|---|---|---|
| Type inference | var |
Automatic with val/var |
Automatic with val/var |
| Immutable reference | final var |
val |
val |
| Mutable reference | var |
var |
var |
Scala 3
// val: immutable reference (preferred)
val immutableList = List(1, 2, 3)
// immutableList = List(4, 5, 6) // Compilation error!
// var: mutable reference (use sparingly)
var counter = 0
counter += 1 // OK
// Idiomatic: prefer val with transformation
val doubled = immutableList.map(_ * 2)
Kotlin
// val: immutable reference (preferred)
val immutableList = listOf(1, 2, 3)
// immutableList = listOf(4, 5, 6) // Compilation error!
// var: mutable reference (use sparingly)
var counter = 0
counter += 1 // OK
// Idiomatic: prefer val with transformation
val doubled = immutableList.map { it * 2 }
Data Processing Pipeline Example
Here’s a complete example showing appropriate var usage:
Java 21
public static Map<String, CategorySummary> processTransactionPipeline(
List<Transaction> transactions) {
// var is good - type obvious from stream operation
var filteredTransactions = transactions.stream()
.filter(t -> t.amount() >= 50.0)
.toList();
// var good - Map type clear from groupingBy
var byCategory = filteredTransactions.stream()
.collect(Collectors.groupingBy(Transaction::category));
// var good - type clear from stream().map() with toMap
var summaries = byCategory.entrySet().stream()
.map(entry -> {
var category = entry.getKey();
var categoryTransactions = entry.getValue();
// Explicit types for calculated values
long count = categoryTransactions.size();
double total = categoryTransactions.stream()
.mapToDouble(Transaction::amount)
.sum();
double average = count > 0 ? total / count : 0.0;
return new CategorySummary(category, count, total, average);
})
.collect(Collectors.toMap(CategorySummary::category, s -> s));
return summaries;
}
Scala 3
def processTransactionPipeline(
transactions: List[Transaction]
): Map[String, CategorySummary] =
val filteredTransactions = transactions.filter(_.amount >= 50.0)
val byCategory = filteredTransactions.groupBy(_.category)
val summaries = byCategory.map { case (category, categoryTransactions) =>
val count = categoryTransactions.size.toLong
val total = categoryTransactions.map(_.amount).sum
val average = if count > 0 then total / count else 0.0
category -> CategorySummary(category, count, total, average)
}
summaries
Kotlin
fun processTransactionPipeline(
transactions: List<Transaction>
): Map<String, CategorySummary> {
val filteredTransactions = transactions.filter { it.amount >= 50.0 }
val byCategory = filteredTransactions.groupBy { it.category }
val summaries = byCategory.map { (category, categoryTransactions) ->
val count = categoryTransactions.size.toLong()
val total = categoryTransactions.sumOf { it.amount }
val average = if (count > 0) total / count else 0.0
category to CategorySummary(category, count, total, average)
}.toMap()
return summaries
}
Feature Comparison Table
| Feature | Java 21 (var) | Scala 3 (val/var) | Kotlin (val/var) |
|---|---|---|---|
| Local variables | ✅ | ✅ | ✅ |
| Fields/Properties | ❌ | ✅ | ✅ |
| Method parameters | ❌ | ❌ | ❌ |
| Return types | ❌ | ✅ (inferred) | ✅ (single-expression) |
| Lambda parameters | ❌ | ✅ | ✅ |
| Null initializer | ❌ | ✅ | ✅ |
| Immutable by keyword | ❌ (needs final) |
✅ (val) |
✅ (val) |
| Pattern matching | ✅ (Java 21) | ✅ | ✅ (destructuring) |
Best Practices Summary
When to Use var (Java)
✅ DO use var when:
- Type is obvious from constructor:
var list = new ArrayList<String>() - Type is obvious from literal:
var count = 0 - Type is obvious from factory method:
var today = LocalDate.now() - Complex generics add noise without value
- In for-each loops where element type is clear
- In try-with-resources statements
❌ DON’T use var when:
- Type is not obvious from context
- Using methods with non-descriptive names
- Numeric literals could be ambiguous
- It would reduce code readability
- For fields, parameters, or return types (not allowed)
General Guidelines
- Readability trumps brevity - If
varmakes code harder to understand, use explicit types - Be consistent - Follow your team’s conventions
- Consider IDE support - IDEs show inferred types on hover, which helps when reading code
- Use meaningful variable names - Good names reduce the need to see the type
Code Samples
See the complete implementations in our repository:
- Java 21 LocalVariableTypeInference.java
- Scala 3 LocalVariableTypeInference.scala
- Kotlin LocalVariableTypeInference.kt
Conclusion
Java’s var keyword brings local variable type inference to Java, reducing boilerplate while maintaining static typing. While more limited than Scala’s and Kotlin’s inference capabilities (which extend to fields and return types), var is a valuable tool for writing cleaner Java code.
Key takeaways:
- Java’s var is limited to local variables with initializers
- Scala and Kotlin have more powerful inference that includes fields and return types
- All three languages maintain static typing - the compiler still knows the exact type
- Readability is paramount - use type inference when it helps, not just because you can
For Scala developers learning Java, var will feel natural but remember its limitations. The key is finding the right balance between conciseness and clarity.
This is part of our Java 21 Interview Preparation series. Check out the full preparation plan for more topics.