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.

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 error
  • new T() — no way to construct a generic type
  • new 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.