DEV Community

Cover image for Java Erases Your Generics. TypeScript Erases Every Type. What Survives to Runtime
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Java Erases Your Generics. TypeScript Erases Every Type. What Survives to Runtime


If you have written Java for a while, you have hit this wall at least once. You type list instanceof ArrayList<Integer> and the compiler refuses. The Oracle generics tutorial gives the reason in one line: "The runtime does not keep track of type parameters, so it cannot tell the difference between an ArrayList and an ArrayList."

You learned to live with it. You use ArrayList<?>, you pass a Class<T> around, you reach for Jackson's TypeReference when a List<User> has to come out of JSON.

Then you move to TypeScript, write if (value instanceof User) against an interface, and get 'User' only refers to a type, but is being used as a value here (a walkthrough of that error). Same family of problem, much bigger blast radius. Java erases your generics. TypeScript erases every type you write. The useful question is what is still there when the program runs, in each language, and what you do about the gap.

What javac erases, and what it quietly keeps

The Java tutorial on type erasure lists three things the compiler does: it replaces every type parameter with its bound, or Object if it has none; it inserts casts where they are needed for type safety; and it generates bridge methods so polymorphism still works in generic subclasses. The result, in the tutorial's words, is bytecode that "contains only ordinary classes, interfaces, and methods."

Take three lines:

List<String> names = new ArrayList<>();
names.add("ada");
String first = names.get(0);
Enter fullscreen mode Exit fullscreen mode

Run javap -c on the compiled class and the call to get goes to the erased List, whose get returns Object. The String you see in your source survives only as the cast the compiler inserted after the call. List<String> and List<Integer> run as the same class.

That is the part every JVM developer knows. The part people forget is how much Java keeps.

First, every object knows its own class. The TypeScript team's own guide for Java and C# programmers puts it plainly: in Java "any given value or object has one exact type", and you can call value.getClass() to ask for it.

Second, declarations keep their generics. The class file format has a Signature attribute that "stores a signature for a class, interface, constructor, method, field, or record component whose declaration in the Java programming language uses type variables or parameterized types." javap -v prints the Signature attribute, and reflection reads them: Field.getGenericType() must return a Type that "accurately reflect[s] the actual type arguments used in the source code."

Neal Gafter, who introduced super type tokens in a 2006 post, summed it up in a reply in that post's comments: "The STATIC types are recorded in the bytecodes, but not the dynamic types." A List<String> object has forgotten its String. A field declared as List<String> has not.

A balance scale on a cream background: the left pan holds a tagged JVM class box with a small generic-bracket ribbon still attached, the right pan holds a bare JavaScript object with its labels drifting away as dust
Java forgets the type arguments on values but keeps classes and declared signatures. TypeScript keeps neither.

What tsc erases: everything that is only a type

TypeScript's handbook has a section literally named Erased Types, and it ends on a note: "Type annotations never change the runtime behavior of your program."

Here is a small file with the usual suspects:

interface User { id: string; email: string }
type Users = User[];

function first<T>(xs: T[]): T | undefined {
  return xs[0];
}

class Mailer {
  send(to: User): string {
    return `mail to ${to.email}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

Strip what is TypeScript-only and this is the JavaScript that remains (with a modern target, so the template literal stays as is):

function first(xs) {
  return xs[0];
}

class Mailer {
  send(to) {
    return `mail to ${to.email}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

User is gone. Users is gone. T is gone, with no Object stand-in and no inserted cast. The parameter annotation on send is gone. What survives is what was already JavaScript: the function and the class. The guide for OOP programmers says of an interface that it "is not present in any form at runtime", and that "information about e.g. the instantiation of a generic type parameter is not available at runtime." There is no Signature attribute equivalent. Nothing is recorded on the declaration either.

There is one opt-in way to record something: with emitDecoratorMetadata, tsc records a decorated member's type as a runtime reference, and NestJS dependency injection reads it. It records Array for a User[] and Object for an interface, so the generic and the interface are still gone.

Two TypeScript features do leave runtime code. Enums "are real objects that exist at runtime". The handbook shows enum Enum { A } compiling to an object with both directions mapped:

"use strict";
var Enum;
(function (Enum) {
    Enum[Enum["A"] = 0] = "A";
})(Enum || (Enum = {}));
Enter fullscreen mode Exit fullscreen mode

const enum goes the other way: members are inlined at the use site and the declaration is "completely removed during compilation." Namespaces with values and constructor parameter properties also emit code.

That short list is shrinking in practice. Node.js now runs .ts files by stripping types by default since v22.18.0 and v23.6.0. It "will replace TypeScript syntax with whitespace, and no type checking is performed," and it rejects enums, namespaces with runtime code and parameter properties because those need a transform. TypeScript 5.8 added --erasableSyntaxOnly so the compiler flags the same constructs. TypeScript is drifting toward JavaScript plus annotations a runtime can delete.

Side by side:

At runtime Java TypeScript
Class of an object yes, getClass() only if built with new from a class
Generic args on a declaration yes, Signature attribute no
Generic args on a value no no
Interfaces yes, as class files no
Type aliases n/a no
Enums yes yes, unless const enum

Four Java habits that stop working

Overloading by type. Java lets you write describe(Card c) and describe(Iban i) as two methods and let the compiler pick. The TypeScript handbook on overloads describes something else: several overload signatures followed by a single implementation, whose signature "is not visible from the outside." There is one function body at runtime. If the two cases need different logic, that body has to look at the value and branch. (Java has its own edge here, by the way: you cannot overload print(Set<String>) and print(Set<Integer>), because the tutorial notes they "would all share the same classfile representation.")

instanceof on an interface. In Java, interfaces compile to class files and obj instanceof Payable works. In TypeScript the interface has no runtime value, so the check does not compile. And instanceof on a class only walks the prototype chain. The guide is blunt that typeof and instanceof "are still working on the values as they exist in the type-erased output code." An object from JSON.parse that has every field of Account is still not an instanceof Account.

Reflection on generic parameters. There is no getGenericType() to call. A function like load<T>(key: string): T cannot look at T, so whatever it returns, the T is only the caller's promise; nothing checks it. A super type token has nothing to capture, because the declaration carries no signature.

Deserialization by class. Jackson's model is mapper.readValue(json, User.class), plus new TypeReference<List<Integer>>() { } for the generic cases, which works because of that Signature attribute. In TypeScript, User is usually an interface, so there is no User.class to hand to anything. Even if you make it a class, a nested User[] field has no element type recorded anywhere for a library to read.

A kitchen sieve on a cream background: solid black class and enum blocks stay in the mesh while interface outlines and angle-bracket generic shapes fall through as burnt orange dust
Classes and enums make it through compilation. Interfaces, type aliases and generics do not.

What replaces them: put the type information in a value

Every replacement follows one rule. If you need to know something at runtime, it has to exist at runtime as a value. TypeScript then checks that value statically.

Discriminated unions replace overloads and class hierarchies. Give each case a literal field and switch on it. The narrowing chapter defines it: "When every type in a union contains a common property with literal types, TypeScript considers that to be a discriminated union."

type Payment =
  | { kind: "card"; last4: string }
  | { kind: "iban"; iban: string };

function describe(p: Payment): string {
  switch (p.kind) {
    case "card":
      return `card ending ${p.last4}`;
    case "iban":
      return `transfer from ${p.iban}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

The kind string is the runtime tag Java gave you for free with getClass(). You write it yourself, and the compiler narrows p in each branch.

User-defined type guards replace instanceof on interfaces. A function whose return type is a type predicate, parameterName is Type, tells the compiler what a true result proves:

function isUser(v: unknown): v is User {
  if (typeof v !== "object" || v === null) {
    return false;
  }
  const u = v as Record<string, unknown>;
  return typeof u.id === "string"
    && typeof u.email === "string";
}
Enter fullscreen mode Exit fullscreen mode

The catch: the compiler trusts the predicate. If the body checks the wrong fields, nothing warns you. Keep guards short and next to the type they guard.

Runtime schemas replace readValue(json, User.class). This flips the direction. In Java you declare the class and the deserializer reads it. In TypeScript you declare a schema, which is a real runtime value, and derive the static type from it. With Zod, now on major version 4, that looks like this:

import { z } from "zod";

const User = z.object({
  id: z.string(),
  email: z.string(),
});
type User = z.infer<typeof User>;

const result = User.safeParse(JSON.parse(body));
if (result.success) {
  console.log(result.data.email);
}
Enter fullscreen mode Exit fullscreen mode

safeParse returns either the data or an error instead of throwing, and z.infer gives you the User type for the rest of the code. You write the declaration once and get both a runtime validator and a compile-time type. That is the closest thing TypeScript has to a reified class, and it works for nested arrays and unions, which is where User.class ran out on the JVM too.

A habit to keep instead

The JVM taught you to trust declared types at the boundary because the runtime would back them up with a ClassCastException. TypeScript will not. At every boundary where data enters (HTTP bodies, queue messages, localStorage, environment variables) the static type is only as true as the check you ran. Inside the boundary, after a schema or a guard has passed, the types are as reliable as Java's.

Inside your code, write types and let them vanish. At the edges, write values that check.


If this was useful

Erasure is one of those differences that looks like trivia until it breaks a habit you have had for ten years. Kotlin and Java to TypeScript is the book I wrote for that move. It walks a JVM developer through what changes on the way over: variance, null safety, sealed classes into unions, coroutines into async/await. It is part of The TypeScript Library:

If you come from Java or Kotlin, start with book 3 in place of book 1, then go to The TypeScript Type System when the generics get deep. If you ship TypeScript at work, add TypeScript in Production.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)