Java 會抹除泛型,TypeScript 會抹除所有型別,哪些資訊能保留至執行時?
Java Erases Your Generics. TypeScript Erases Every Type. What Survives to Runtime
文章比較 Java 與 TypeScript 的型別抹除差異:Java 仍保留物件類別及宣告上的泛型簽名,TypeScript 則會抹除介面、型別別名、泛型參數和型別註解。作者建議以執行時值(例如 schema 或自訂型別守衞)驗證資料邊界,因為 TypeScript 靜態型別本身不會檢查輸入資料。
- Book: Kotlin and Java to TypeScript
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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);
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.

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}`;
}
}
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}`;
}
}
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 = {}));
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.

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}`;
}
}
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";
}
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);
}
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:
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — JVM developers moving to TypeScript
- PHP to TypeScript — PHP 8+ developers moving to TypeScript
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
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.
來源:DEV Community · dev.to
