C# Dev Exploring Java: Generics, But Not Really (Part 4)
Java’s type system was designed in 1995. C# had a second chance. This single difference affects every library, every framework, and every generic class you write.
More from C# Dev Exploring Java
- C# Dev Exploring Java: Wait, It Does That? (Part 1)
- C# Dev Exploring Java: No Safety Net (Part 2)
- C# Dev Exploring Java: How Do I Say That? (Part 3)
- C# Dev Exploring Java: Generics, But Not Really (Part 4) (Current)
- C# Dev Exploring Java: Data & Async — The Twin Shocks (Part 5)
- C# Dev Exploring Java: The Effective Java Effect (Part 6)
Type Erasure — The Foundation
In C#, generics are reified. List<string> and List<int> are different types at runtime:
var a = typeof(List<string>);
var b = typeof(List<int>);
Console.WriteLine(a == b); // False
In Java, generic type parameters disappear at runtime:
Class<?> a = new ArrayList<String>().getClass();
Class<?> b = new ArrayList<Integer>().getClass();
System.out.println(a == b); // true
Both become ArrayList. This is type erasure — a deliberate design choice for backward compatibility. Java 5 couldn’t break the JVM, so generics were compiled away.
What You Can’t Do
instanceof List<String>— compile errornew T()— no way to construct a generic typenew T[10]— generic arrays don’t exist- Overloading by generic parameter
Overload Resolution Breaks
These two methods can’t coexist:
void process(List<String> list) { }
void process(List<Integer> list) { } // doesn't compile
Both erase to process(List). In C#, List<string> and List<int> are distinct types, so this works fine.
Wildcards: ? extends and ? super
C# declares variance at the interface level using out and in:
IEnumerable<Number> nums = new List<Integer>(); // out, covariant
IComparer<Integer> cmp = new SomeComparer<Number>(); // in, contravariant
Java puts variance at the use site with wildcards:
List<? extends Number> nums = new ArrayList<Integer>();
Number n = nums.get(0); // can read
// nums.add(10); // can't write — compiler blocks it
List<? super Integer> ints = new ArrayList<Number>();
ints.add(10); // can write
Object o = ints.get(0); // can only read as Object
PECS — Producer Extends, Consumer Super
Every Java developer learns this mnemonic. C# developers never need it — the compiler determines variance from the in/out annotations on the interface itself.
| Scenario | Java | C# |
|---|---|---|
| Read from collection | ? extends T |
out T (declared on interface) |
| Write to collection | ? super T |
in T (declared on interface) |
| Both | No wildcard | No variance |
Pitfall: C# devs expect List<out Number> to work on concrete types — it doesn’t. Variance only applies to interfaces and delegates in C#. Java’s wildcards are more flexible at the use site but more complex at the mental model level.
Why Frameworks Need TypeToken
Because erasure wipes generic types, reflection-heavy frameworks can’t recover them:
// Gson can't deserialize List<Person> without help
Type type = new TypeToken<List<Person>>() {}.getType();
List<Person> result = gson.fromJson(json, type);
That anonymous {} creates a subclass that preserves the type parameter at runtime. Every serialization library in Java has its own version of this hack.
In C#, typeof(List<Person>) just works.
Pitfall: Every Java framework that needs generic type information requires extra ceremony. This isn’t a design flaw of the frameworks — it’s compensating for the language.
Enums Are Real Types
C# enums are named integers:
enum Status { Pending, Approved, Rejected }
Console.WriteLine((int)Status.Pending); // 0
Java enums are full classes:
enum Status {
PENDING("Waiting"),
APPROVED("Accepted"),
REJECTED("Denied");
private final String description;
Status(String description) { this.description = description; }
public String description() { return description; }
}
They can have fields, constructors, methods, and implement interfaces. The switch statement works with them directly.
Pitfall: ordinal() returns the declaration position — fragile for persistence. Use name() or explicit fields instead.
Records: C# Wins
C# records provide concise immutable types with non-destructive mutation:
record Person(string Name, int Age);
var p1 = new Person("Abdullah", 23);
var p2 = p1 with { Age = 24 }; // concise mutation
Java records are DTOs without mutation support:
record Person(String name, int age) {}
Person p1 = new Person("Abdullah", 23);
Person p2 = new Person("Abdullah", 24); // no `with` syntax
Java records are closer to immutable data carriers. Complex mutation patterns require traditional classes or external libraries.
Interfaces Carry More Baggage
C# interfaces are contracts — methods and properties only (though default implementations were added later):
Java interfaces can contain constants, default methods, and static methods:
interface IExample {
int MAX_COUNT = 10; // public static final by default
void doWork();
default void log(String message) {
System.out.println("Log: " + message);
}
static void helper() {
System.out.println("Helper method");
}
}
Constants in interfaces were a common pattern before enums existed. Default methods can conflict when implementing multiple interfaces — a problem C# avoids with explicit override rules.
Serializable: Marker Interface Culture
C# serialization uses attributes:
[Serializable]
public class Person { ... }
Java requires implementing Serializable:
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
// ...
}
Serializable is a marker interface — no methods, but triggers runtime serialization machinery. The serialVersionUID prevents compatibility breaks between versions.
Pitfall: Serializable is opt-out, not opt-in. Forgetting it throws NotSerializableException at runtime. All nested objects must also be serializable.
Type erasure infects everything: reflection, serialization, frameworks, overloading. It’s the one design decision that explains more about Java’s ecosystem than any other. Coming up next — two areas where the C# mental model actively hurts: data and async.
More from C# Dev Exploring Java
- C# Dev Exploring Java: Wait, It Does That? (Part 1)
- C# Dev Exploring Java: No Safety Net (Part 2)
- C# Dev Exploring Java: How Do I Say That? (Part 3)
- C# Dev Exploring Java: Generics, But Not Really (Part 4) (Current)
- C# Dev Exploring Java: Data & Async — The Twin Shocks (Part 5)
- C# Dev Exploring Java: The Effective Java Effect (Part 6)