PONYλM2Modula-2

TypeScript.CodeCompared.To/Java

An interactive executable cheatsheet comparing TypeScript and Java

TypeScript 6.0.3 Java 25
Hello World & Running
Hello, World
There is no top level. Every statement lives in a method, every method lives in a class, and the runtime starts at a method with exactly this signature — public static void main(String[] args).
console.log("Hello, World!");
class Main { public static void main(String[] args) { System.out.println("Hello, World!"); } }
None of this ceremony is optional, and all of it is load-bearing: public so the launcher can call it, static so it runs without an instance, void because the exit status comes from System.exit, and String[] args because that is the signature the launcher looks for. Getting one word wrong produces "main method not found" rather than a compile error.
Putting values into text
There are no template literals. The two options are concatenation with +, and a format string through String.format or its instance form "...".formatted(...).
const name = "Ada"; const count = 3; console.log(`${name} has ${count} items`);
class Main { public static void main(String[] args) { String name = "Ada"; int count = 3; System.out.println(name + " has " + count + " items"); System.out.println("%s has %d items".formatted(name, count)); } }
The format string brings back something a template literal never had: width and precision control, so "%.2f" and "%,d" are one call rather than a toFixed and a locale helper. It also brings back the mismatch a template literal made impossible — %d against a String throws at runtime, not at compile time.
Multi-line text
A text block opens with three quotes. Unlike a template literal, it strips the indentation common to all its lines, so the literal can sit at the indentation of the code around it without smuggling spaces into the string.
const query = `SELECT name, born FROM people WHERE born < 1900`; console.log(query);
class Main { public static void main(String[] args) { String query = """ SELECT name, born FROM people WHERE born < 1900"""; System.out.println(query); } }
The rule is that the shortest leading run of whitespace across all lines (and the closing delimiter, if it is on its own line) is removed from each. It is the one place Java's string syntax is more comfortable than TypeScript's — a template literal in a nested block either breaks the indentation or silently includes it.
The types are still there when it runs
This is the single biggest structural difference, and everything else on the page follows from it. tsc deletes the type system; javac writes it into the class file, where the program itself can read it back.
interface Person { name: string; born: number; } const person: Person = { name: "Ada", born: 1815 }; // nothing can ask what Person's members are — the interface no longer exists console.log(Object.keys(person).join(",")); console.log(typeof person.name, typeof person.born);
import java.lang.reflect.Field; class Person { String name = "Ada"; int born = 1815; } class Main { public static void main(String[] args) { for (Field field : Person.class.getDeclaredFields()) { System.out.println(field.getType().getSimpleName() + " " + field.getName()); } } }
Reflection is the capability with no TypeScript counterpart at all, and it is why the Java ecosystem looks the way it does: dependency injection, object-relational mapping, and JSON binding are all built on asking a class about itself at runtime. The TypeScript equivalents have to be given a schema, because there is nothing to ask.
Variables & Types
var, final, and where inference stops
var infers a local variable's type and final forbids reassignment — between them they cover what let and const do. The catch is that var works only for local variables.
class Configuration { host = "localhost"; // inferred as string describe(suffix: string): string { // parameters need a type const combined = this.host + suffix; return combined; } } const configuration = new Configuration(); console.log(configuration.describe(":8080"));
class Configuration { // var host = "localhost"; // will not compile: var is locals only String host = "localhost"; String describe(String suffix) { // parameters need a type var combined = host + suffix; // a local can infer return combined; } } class Main { public static void main(String[] args) { final var configuration = new Configuration(); System.out.println(configuration.describe(":8080")); } }
Fields, method parameters and return types all need their type spelled out — there is no inference for any of them, and no const-versus-let default to argue about because plain var is mutable. Note also that final, like const, forbids only rebinding: a final List can still be added to.
Primitives, wrappers, and the == trap
An int is a machine integer, and an Integer is an object wrapping one. They convert automatically, and == means something different for each: value for the primitive, reference identity for the object.
const small = 127, alsoSmall = 127; const large = 128, alsoLarge = 128; console.log(small === alsoSmall); console.log(large === alsoLarge); console.log(Object.is(large, alsoLarge));
class Main { public static void main(String[] args) { Integer small = 127, alsoSmall = 127; Integer large = 128, alsoLarge = 128; System.out.println(small == alsoSmall); System.out.println(large == alsoLarge); System.out.println(large.equals(alsoLarge)); } }
The middle line is false because the runtime caches boxed integers from -128 to 127 and allocates a fresh object above that — so the same code is correct for small numbers and wrong for large ones. This is the reason the rule is absolute: never compare boxed values with ==. Use the primitive types where you can, and equals everywhere else.
Object instead of unknown — and the cast is real
There is no any and no unknown; the type that holds anything is Object. Getting a value back out needs a cast, and unlike as, that cast is checked by the virtual machine.
function describe(value: unknown): string { if (typeof value === "string") { return `string of length ${value.length}`; } return "not a string"; } console.log(describe("Ada")); console.log(describe(42)); const number: unknown = 42; const wrong = number as string; console.log(wrong.length); // undefined — the cast checked nothing
class Main { static String describe(Object value) { if (value instanceof String text) { return "string of length " + text.length(); } return "not a string"; } public static void main(String[] args) { System.out.println(describe("Ada")); System.out.println(describe(42)); Object number = 42; try { String wrong = (String) number; System.out.println(wrong.length()); } catch (ClassCastException error) { System.out.println("ClassCastException"); } } }
The last line is the whole difference. as is a promise the compiler accepts and nothing verifies, so a wrong one leaks a undefined that fails much later somewhere unrelated; a Java cast fails immediately, at the line that was wrong, with the actual class in the message. instanceof String text is a pattern: it tests and binds in one step, so the cast usually disappears entirely.
Numbers & Integers
Integers are a separate type, and they divide differently
There are four integer widths (byte, short, int, long) and two floating widths. Dividing two integers gives an integer — the fractional part is discarded, not rounded.
console.log(Math.trunc(7 / 2)); console.log(7 / 2); const large = 9007199254740993; // silently stored as ...992 console.log(large);
class Main { public static void main(String[] args) { System.out.println(7 / 2); System.out.println(7.0 / 2); long large = 9007199254740993L; System.out.println(large); } }
The L suffix matters: without it the literal is an int and will not compile because it does not fit. That pickiness is what buys the third line — a long is exact to 2⁶³, where every whole number above 2⁵³ in TypeScript is an approximation with nothing to warn you.
Overflow wraps silently
A fixed width has an edge, and going past it wraps around rather than growing or losing precision. The Math.*Exact family is the opt-in that throws instead.
console.log(Number.MAX_SAFE_INTEGER); console.log(Number.MAX_SAFE_INTEGER + 1); console.log(1 + 2); console.log(Number.isSafeInteger(Number.MAX_SAFE_INTEGER + 1) ? "safe" : "overflow");
class Main { public static void main(String[] args) { System.out.println(Integer.MAX_VALUE); System.out.println(Integer.MAX_VALUE + 1); System.out.println(Math.addExact(1, 2)); try { Math.addExact(Integer.MAX_VALUE, 1); System.out.println("safe"); } catch (ArithmeticException error) { System.out.println("overflow"); } } }
Both languages fail quietly past their limit, but they fail differently: an int becomes hugely negative while a number starts skipping odd values. The important practical difference is that Java's limit is 2 147 483 647 for the default integer type, which real data reaches — an id column, a millisecond count, a byte total.
Exact decimals with BigDecimal
Both languages use IEEE-754 doubles and both get 0.30000000000000004. Java also ships an exact decimal type, so money does not have to be counted in cents.
console.log(0.1 + 0.2); console.log(0.1 + 0.2 === 0.3); console.log((0.1 + 0.2).toFixed(1));
import java.math.BigDecimal; class Main { public static void main(String[] args) { System.out.println(0.1 + 0.2); System.out.println(0.1 + 0.2 == 0.3); System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2"))); } }
Build a BigDecimal from a string, never from a doublenew BigDecimal(0.1) faithfully preserves the error you were trying to escape. TypeScript has no standard equivalent; the options are a decimal library or integer arithmetic in the smallest unit.
char is a number in a trench coat
Single quotes are not an alternative string syntax — 'A' is a char, a 16-bit unsigned integer that prints as a letter and does arithmetic like a number.
const letter = "A"; console.log(letter); console.log(letter.codePointAt(0)); console.log(String.fromCodePoint(letter.codePointAt(0)! + 1)); const word = "Ada"; console.log(word[0]);
class Main { public static void main(String[] args) { char letter = 'A'; System.out.println(letter); System.out.println((int) letter); System.out.println((char) (letter + 1)); String word = "Ada"; System.out.println(word.charAt(0)); } }
The trap for a TypeScript developer is writing 'A' where a String is wanted, which does not compile, and the trap after that is letter + 1, which is 66 rather than "A1". Indexing a string gives a char too, so word.charAt(0) + word.charAt(1) is 165.
Strings
Comparing strings needs equals
A String is an object, so == asks whether two references point at the same object. Identical literals are interned and share one object, which is exactly what makes the bug hide until the string is built at runtime.
const fromLiteral = "same"; const built = ["sa", "me"].join(""); console.log(fromLiteral === built); console.log(fromLiteral === "same");
class Main { public static void main(String[] args) { String fromLiteral = "same"; String built = new StringBuilder("sa").append("me").toString(); System.out.println(fromLiteral == built); System.out.println(fromLiteral == "same"); } }
Both lines are true in TypeScript and only the second is true in Java — which means == on strings appears to work perfectly until a value arrives from a file, a network response, or a StringBuilder. Always equals, and "literal".equals(value) when value might be null.
StringBuilder
Strings are immutable in both languages, but Java gives you a mutable buffer to work in. It is not only for appending — insert, reverse and deleteCharAt edit the text in place.
const original = "stressed"; const characters = original.split(""); characters.reverse(); const reversed = characters.join(""); console.log(reversed); console.log(reversed.slice(0, 3) + "|" + reversed.slice(3));
class Main { public static void main(String[] args) { String original = "stressed"; StringBuilder builder = new StringBuilder(original); builder.reverse(); System.out.println(builder); System.out.println(builder.insert(3, "|")); } }
The TypeScript column has to leave the string, work in an array, and come back, because there is nothing between "immutable string" and "array of characters". A builder is also an object you can hand to another method and keep appending to, which no TypeScript idiom offers — join works on a finished array and += works on one variable.
The everyday string methods
Most methods carry over under a different name: trim is strip, slice is substring, and join moves off the array onto String as a static method.
const text = " TypeScript "; console.log(text.trim().toUpperCase()); console.log(text.trim().slice(0, 4)); console.log(["a", "b", "c"].join("-")); console.log("a,b,c".split(",").length); console.log("ab".repeat(3));
class Main { public static void main(String[] args) { String text = " TypeScript "; System.out.println(text.strip().toUpperCase()); System.out.println(text.strip().substring(0, 4)); System.out.println(String.join("-", "a", "b", "c")); System.out.println("a,b,c".split(",").length); System.out.println("ab".repeat(3)); } }
substring is stricter than slice: it rejects negative indices and throws StringIndexOutOfBoundsException past the end, where slice quietly clamps. Prefer strip over the older trimtrim only removes characters below U+0020 and misses most Unicode whitespace.
Ordering strings
compareTo is the built-in comparison, returning a negative number, zero, or a positive one. Ready-made comparators such as String.CASE_INSENSITIVE_ORDER are passed to sort the way a callback is.
const words = ["banana", "Apple", "cherry"]; words.sort((left, right) => left.localeCompare(right, undefined, { sensitivity: "base" })); console.log(words.join(",")); console.log("a".localeCompare("b"));
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<String> words = new ArrayList<>(List.of("banana", "Apple", "cherry")); words.sort(String.CASE_INSENSITIVE_ORDER); System.out.println(String.join(",", words)); System.out.println("a".compareTo("b")); } }
Plain compareTo compares UTF-16 code units, so every uppercase letter sorts before every lowercase one and "Apple" beats "apple" — the same trap as sorting with < in TypeScript. Locale-aware ordering is a java.text.Collator, which is where localeCompare's behavior actually lives.
Arrays & Lists
An array has a length forever
An array's length is fixed when it is created and is a field, not a method. Writing past the end throws rather than growing the array.
const numbers = [1, 2, 3]; console.log(numbers.length); numbers[3] = 4; // no error — the array simply grows console.log(numbers.join(","));
import java.util.Arrays; class Main { public static void main(String[] args) { int[] numbers = { 1, 2, 3 }; System.out.println(numbers.length); try { numbers[3] = 4; } catch (ArrayIndexOutOfBoundsException error) { System.out.println("index 3 out of bounds"); } System.out.println(Arrays.toString(numbers)); } }
An array also comes pre-filled with the element type's zero value — new int[3] holds three zeros, never undefined — so there are no holes and no sparse arrays. Because the length cannot change, almost all real code uses List instead, and arrays survive mainly for primitives and for varargs.
List is the growable one
You name the interface (List) and choose the implementation (ArrayList). add is push, size() is length, and get(index) replaces bracket indexing.
const names: string[] = []; names.push("Ada"); names.push("Alan"); names.splice(names.indexOf("Ada"), 1); console.log(names.length, names[0], names.includes("Alan")); const immutable: readonly string[] = ["a", "b"]; console.log(immutable.length);
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<String> names = new ArrayList<>(); names.add("Ada"); names.add("Alan"); names.remove("Ada"); System.out.println(names.size() + " " + names.get(0) + " " + names.contains("Alan")); List<String> immutable = List.of("a", "b"); System.out.println(immutable.size()); } }
Declaring the variable as List rather than ArrayList is the convention, so the implementation can change without touching callers — LinkedList for cheap insertion at the ends, CopyOnWriteArrayList for concurrent reads. Note that List.of is immutable at runtime: calling add on it throws, where readonly is erased and cannot.
Sorting with Comparator
The comparator contract is the same — negative, zero, positive — but it is usually built rather than written, from Comparator.comparing, thenComparing and reversed.
interface Person { name: string; age: number } const people: Person[] = [ { name: "Ada", age: 36 }, { name: "Alan", age: 41 }, { name: "Grace", age: 45 }, ]; for (const person of people.toSorted((left, right) => right.age - left.age)) { console.log(person.name); }
import java.util.ArrayList; import java.util.Comparator; import java.util.List; record Person(String name, int age) {} class Main { public static void main(String[] args) { List<Person> people = new ArrayList<>(List.of( new Person("Ada", 36), new Person("Alan", 41), new Person("Grace", 45))); people.sort(Comparator.comparingInt(Person::age).reversed()); for (Person person : people) { System.out.println(person.name()); } } }
Sorting numbers needs no comparator at all here — list.sort(null) or Collections.sort uses each element's natural order, so the lexicographic surprise TypeScript's bare sort() springs on numbers cannot happen. sort mutates the list; stream().sorted(...) is the copying form, matching toSorted.
No destructuring, no spread
There is no [first, ...rest] and no [...a, ...b]. Taking a slice is Arrays.copyOfRange, and extending means copying into a longer array yourself.
const values = [1, 2, 3, 4]; const [first, second, ...rest] = values; const extended = [...values, 5]; console.log(first, second, rest.join(",")); console.log(extended.join(","));
import java.util.Arrays; class Main { public static void main(String[] args) { int[] values = { 1, 2, 3, 4 }; int first = values[0]; int second = values[1]; int[] rest = Arrays.copyOfRange(values, 2, values.length); int[] extended = Arrays.copyOf(values, values.length + 1); extended[values.length] = 5; System.out.println(first + " " + second + " " + Arrays.toString(rest)); System.out.println(Arrays.toString(extended)); } }
One kind of destructuring did arrive in Java 21, and it is limited to records: a record pattern can pull a record's components apart in a switch or an instanceof. Arrays and lists have nothing equivalent, and neither does object literal destructuring, because there are no object literals.
Maps & Sets
HashMap
HashMap is the everyday map, and its extras are worth learning early: getOrDefault for a fallback, merge for accumulate-or-insert, and computeIfAbsent for lazy initialization.
const counts = new Map<string, number>(); counts.set("apples", 1); counts.set("apples", (counts.get("apples") ?? 0) + 1); const baskets = new Map<string, string[]>(); if (!baskets.has("pears")) { baskets.set("pears", []); } baskets.get("pears")!.push("green"); console.log(counts.get("apples")); console.log(counts.get("plums") ?? 0); console.log(counts.size, baskets.get("pears")!.join(","));
import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; class Main { public static void main(String[] args) { Map<String, Integer> counts = new HashMap<>(); counts.put("apples", 1); counts.merge("apples", 1, Integer::sum); Map<String, List<String>> baskets = new HashMap<>(); baskets.computeIfAbsent("pears", key -> new ArrayList<>()).add("green"); System.out.println(counts.get("apples")); System.out.println(counts.getOrDefault("plums", 0)); System.out.println(counts.size() + " " + String.join(",", baskets.get("pears"))); } }
The awkward part coming from TypeScript is the return type: get hands back null for a missing key, and if the map holds Integer that null unboxes into a NullPointerException the moment you do arithmetic with it. getOrDefault exists precisely because nothing in the type system warns you, the way number | undefined does.
Iterating a map
A map is not iterable directly. You iterate one of its three views — entrySet(), keySet() or values() — and an entry is an object with getKey() and getValue(), since there is no destructuring.
const scores = new Map([["ada", 10], ["alan", 12]]); for (const [name, score] of scores) { console.log(`${name}=${score}`); } console.log([...scores.keys()].join(","));
import java.util.LinkedHashMap; import java.util.Map; class Main { public static void main(String[] args) { Map<String, Integer> scores = new LinkedHashMap<>(); scores.put("ada", 10); scores.put("alan", 12); for (Map.Entry<String, Integer> entry : scores.entrySet()) { System.out.println(entry.getKey() + "=" + entry.getValue()); } System.out.println(String.join(",", scores.keySet())); } }
Note the choice of class: a HashMap iterates in an unspecified order that can change between runs, so LinkedHashMap is what you want whenever insertion order matters. TypeScript's Map gives that guarantee unconditionally, which is why nobody coming from it thinks to ask.
Sets and maps can be keyed by value
This is the payoff of equals and hashCode, and it is something TypeScript cannot do at all: two separately-created objects with the same contents are the same key.
interface Point { x: number; y: number } const points = new Set<Point>(); points.add({ x: 1, y: 2 }); points.add({ x: 1, y: 2 }); console.log(points.size); console.log(points.has({ x: 1, y: 2 }));
import java.util.HashSet; import java.util.Set; record Point(int x, int y) {} class Main { public static void main(String[] args) { Set<Point> points = new HashSet<>(); points.add(new Point(1, 2)); points.add(new Point(1, 2)); System.out.println(points.size()); System.out.println(points.contains(new Point(1, 2))); } }
A Set in TypeScript compares members with ===, so a set of objects is really a set of identities: the second add stores a duplicate and the lookup with a fresh literal finds nothing. Working around it means keying by a serialized string, which is the pattern this row makes unnecessary — a record gets equals and hashCode generated for free.
Collections that stay sorted
TreeMap and TreeSet keep their contents in order as you add to them, and expose the navigation that follows from it — first, last, and the nearest key above or below any value.
const scores: Record<string, number> = { c: 3, a: 1, b: 2 }; const keys = Object.keys(scores).toSorted(); console.log(keys[0], keys[keys.length - 1]); console.log(keys.map((key) => `${key}=${scores[key]}`).join(", "));
import java.util.Map; import java.util.TreeMap; class Main { public static void main(String[] args) { TreeMap<String, Integer> scores = new TreeMap<>(Map.of("c", 3, "a", 1, "b", 2)); System.out.println(scores.firstKey() + " " + scores.lastKey()); System.out.println(scores); } }
There is no standard sorted collection in TypeScript, so keeping order means re-sorting the keys on every read — fine for a handful of entries, quadratic for a loop over many. TreeMap also answers questions an array cannot without a scan: ceilingKey, floorKey and subMap are all logarithmic.
Control Flow
There is no truthiness
An if takes a boolean and nothing else. There is no falsy list to memorize, and no if (value) shorthand — the condition has to say what it is testing.
const searchTerm = ""; if (!searchTerm) { console.log("no search term"); } const remaining = 0; if (!remaining) { console.log("nothing left"); }
class Main { public static void main(String[] args) { String searchTerm = ""; if (searchTerm.isEmpty()) { System.out.println("no search term"); } int remaining = 0; if (remaining == 0) { System.out.println("nothing left"); } // if (searchTerm) — will not compile: a String is not a boolean } }
The whole class of bug where if (count) silently rejects a legitimate 0, or if (text) rejects the empty string, cannot be written. What replaces it is a different mistake: if (value != null) when the value is a Boolean object, which compiles and then throws on unboxing if it is null.
switch is an expression
The arrow form produces a value, never falls through, and takes several labels per branch. A branch needing more than one statement opens a block and finishes with yield.
const code = 2; let label: string; switch (code) { case 1: label = "one"; break; case 2: case 3: label = "two or three"; break; default: label = `many (${code})`; } console.log(label);
class Main { public static void main(String[] args) { int code = 2; String label = switch (code) { case 1 -> "one"; case 2, 3 -> "two or three"; default -> { String computed = "many (" + code + ")"; yield computed; } }; System.out.println(label); } }
No break, no let-then-assign, and no way to forget a branch when the subject is an enum or a sealed type — the compiler requires every case to be covered and rejects the switch otherwise. The old colon form still exists and still falls through, so a mixed codebase has both.
Loops
The enhanced for reads like for...of and works over arrays and anything Iterable. When the index is needed there is no entries() — you write the counted loop.
const temperatures = [18, 21, 24]; for (const temperature of temperatures) { console.log(temperature); } for (let hour = 0; hour < temperatures.length; hour += 1) { console.log(`${hour}h: ${temperatures[hour]}`); }
import java.util.List; class Main { public static void main(String[] args) { List<Integer> temperatures = List.of(18, 21, 24); for (int temperature : temperatures) { System.out.println(temperature); } for (int hour = 0; hour < temperatures.size(); hour++) { System.out.println(hour + "h: " + temperatures.get(hour)); } } }
The counted loop over a List is fine for an ArrayList and quietly quadratic for a LinkedList, because get(index) walks from the head — a cost TypeScript arrays never have. There is no for...in equivalent and nothing to confuse it with.
No ?. and no ??
The conditional operator is the same ? :, and it is all there is — there is no optional chaining and no nullish coalescing, so every fallback is written as an explicit null test.
const supplied: string | null = null; console.log(supplied ?? "fallback"); const count = 0; console.log(count === 0 ? "zero" : String(count));
class Main { public static void main(String[] args) { String supplied = null; System.out.println(supplied != null ? supplied : "fallback"); int count = 0; System.out.println(count == 0 ? "zero" : String.valueOf(count)); } }
The library answers are Objects.requireNonNullElse(supplied, "fallback") for a single value and Optional for a chain, both covered in the Null section. Neither short-circuits a dotted path the way a?.b?.c does, so a three-deep access is either three tests or three Optional.map calls.
Methods & Overloading
Every function is a method
There are no top-level functions. A utility that belongs to no object becomes a static method on a class, and callers name that class.
function greatestCommonDivisor(first: number, second: number): number { return second === 0 ? first : greatestCommonDivisor(second, first % second); } console.log(greatestCommonDivisor(48, 18));
class MathUtilities { static int greatestCommonDivisor(int first, int second) { return second == 0 ? first : greatestCommonDivisor(second, first % second); } } class Main { public static void main(String[] args) { System.out.println(MathUtilities.greatestCommonDivisor(48, 18)); } }
This is why the standard library is a directory of noun-shaped holders — Math, Objects, Collections, Arrays, Files — where TypeScript would export loose functions from a module. A static import can strip the prefix at the call site, but the method still has to live somewhere.
Overloading actually dispatches
Several methods can share a name as long as their parameter types differ, and the compiler picks one per call site from the static types of the arguments. There is only one body per signature.
function describe(value: number): string; function describe(value: string): string; function describe(value: number[]): string; function describe(value: number | string | number[]): string { if (Array.isArray(value)) return `array of ${value.length}`; return typeof value === "number" ? `number ${value}` : `string ${value}`; } console.log(describe(1)); console.log(describe("x")); console.log(describe([1, 2]));
class Main { static String describe(int value) { return "number " + value; } static String describe(String value) { return "string " + value; } static String describe(int[] values) { return "array of " + values.length; } public static void main(String[] args) { System.out.println(describe(1)); System.out.println(describe("x")); System.out.println(describe(new int[] { 1, 2 })); } }
Three separate bodies, no union parameter, and no runtime tag-checking — the choice is made at compile time and costs nothing. The limits are the same as TypeScript's: you cannot overload on return type alone, and because the choice uses static types, an argument declared as Object selects the Object overload no matter what it actually holds.
Varargs, and no default parameters
int... values is the rest parameter, spelled after the type. What has no equivalent is a default value: an optional parameter becomes an extra overload that supplies it.
function log(message: string, level = "INFO"): string { return `[${level}] ${message}`; } function total(...amounts: number[]): number { return amounts.reduce((sum, amount) => sum + amount, 0); } console.log(log("started")); console.log(log("disk filling", "WARN")); console.log(total(10, 20, 30));
class Main { static String log(String message) { return log(message, "INFO"); } static String log(String message, String level) { return "[" + level + "] " + message; } static int total(int... amounts) { int sum = 0; for (int amount : amounts) { sum += amount; } return sum; } public static void main(String[] args) { System.out.println(log("started")); System.out.println(log("disk filling", "WARN")); System.out.println(total(10, 20, 30)); } }
There are no named arguments either, so a method with five optional settings would need thirty-two overloads. That is why the builder pattern exists: an object you configure with chained calls and finish with build(), doing the job that an options object with optional properties does in one line of TypeScript.
A lambda needs an interface to be
There is no standalone function type. A lambda's type is an interface with exactly one abstract method, and the standard library supplies a family of them — Function, BiFunction, Supplier, Consumer, Predicate.
const doubled: (value: number) => number = (value) => value * 2; const add: (left: number, right: number) => number = (left, right) => left + right; const constant: () => string = () => "constant"; console.log(doubled(21), add(1, 2), constant());
import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Supplier; class Main { public static void main(String[] args) { Function<Integer, Integer> doubled = value -> value * 2; BiFunction<Integer, Integer, Integer> add = (left, right) -> left + right; Supplier<String> constant = () -> "constant"; System.out.println(doubled.apply(21) + " " + add.apply(1, 2) + " " + constant.get()); } }
Calling one means remembering which single method its interface declares — apply, get, accept, test — and the family stops at two arguments, so a three-argument lambda needs an interface you write yourself. The compensation is Person::name, a method reference, which is shorter than any arrow function for the very common case of "just call this method".
Nominal Types
Conformance is declared, not discovered
A type is a name. Having the right members is not enough — the class must say implements Named, and a class that fits perfectly but never said so is not accepted.
interface Named { name: string; } function introduce(value: Named): void { console.log(`I am ${value.name}`); } const robot = { name: "R2", batteryLevel: 80 }; introduce({ name: "Ada" }); // nothing declared this a Named introduce(robot); // and this was never meant to be one
interface Named { String name(); } record Person(String name) implements Named {} record Robot(String name) {} // same shape, no implements clause class Main { static void introduce(Named value) { System.out.println("I am " + value.name()); } public static void main(String[] args) { introduce(new Person("Ada")); // introduce(new Robot("R2")) — will not compile: Robot is not a Named System.out.println(new Robot("R2").name() + " cannot be introduced"); } }
The upside is that two types cannot be confused just because they happen to look alike, so Meters and Feet are genuinely different types with no branding trick required. The downside is real work: when a library type fits your interface but never declared it, you cannot pass it, and something has to bridge the gap.
Bridging a type that fits but never said so
Since shape is not enough, a type you do not own needs an adapter. When the interface has a single method the adapter can be a lambda or a method reference, which keeps it to a few characters.
interface Sized { length: number; } function describe(value: Sized): string { return `size ${value.length}`; } console.log(describe(["a", "b"])); // an array already IS Sized console.log(describe("abc")); // and so is a string
import java.util.List; interface Sized { int size(); } class Main { static String describe(Sized value) { return "size " + value.size(); } public static void main(String[] args) { List<String> words = List.of("a", "b"); // describe(words) — will not compile, though List HAS size() System.out.println(describe(words::size)); System.out.println(describe("abc"::length)); } }
A method reference such as words::size is the cheapest adapter available, and it works only because Sized has exactly one abstract method. For an interface with several, the adapter is a class — which is what the adapter and wrapper patterns in every Java codebase are for, and why they barely exist in TypeScript.
Interfaces can carry code
A default method has a body, so an interface can ship behavior that every implementer inherits, and static methods on an interface give it a factory. A TypeScript interface holds no implementation at all.
interface Greeter { name(): string; } function greet(greeter: Greeter): string { return `Hello, ${greeter.name()}`; } const greeter: Greeter = { name: () => "Ada" }; console.log(greet(greeter));
interface Greeter { String name(); default String greet() { return "Hello, " + name(); } static Greeter of(String name) { return () -> name; } } class Main { public static void main(String[] args) { System.out.println(Greeter.of("Ada").greet()); } }
This is how the standard library evolves without breaking anyone: Iterable.forEach and Collection.stream were added as default methods, so every existing implementer gained them for free. The TypeScript equivalent is a free function taking the interface — which works, but does not arrive through the type.
Anonymous classes
Where a lambda cannot go — an interface with more than one method, or one where you want state — you can write a class body inline at the point of use, with no name.
const byLength = (left: string, right: string) => left.length - right.length; const words = ["ccc", "a", "bb"]; words.sort(byLength); console.log(words.join(","));
import java.util.ArrayList; import java.util.Comparator; import java.util.List; class Main { public static void main(String[] args) { Comparator<String> byLength = new Comparator<>() { @Override public int compare(String left, String right) { return left.length() - right.length(); } }; List<String> words = new ArrayList<>(List.of("ccc", "a", "bb")); words.sort(byLength); System.out.println(String.join(",", words)); } }
For this particular comparator a lambda would do, and Comparator.comparingInt(String::length) is shorter still. The anonymous class earns its keep when the interface has several methods — an event listener with onStart and onStop — where TypeScript would pass an object literal of functions.
Implementing an interface buys library behavior
Declaring implements Comparable is not paperwork — it is what makes sort, TreeSet, TreeMap, Collections.max and the sorted stream operations all work on your type without being told how.
interface Version { major: number; minor: number } function compareVersions(left: Version, right: Version): number { return left.major !== right.major ? left.major - right.major : left.minor - right.minor; } const versions: Version[] = [{ major: 2, minor: 0 }, { major: 1, minor: 9 }]; const sorted = versions.toSorted(compareVersions); // told how, every time console.log(sorted.map((version) => `${version.major}.${version.minor}`).join(","));
import java.util.ArrayList; import java.util.Collections; import java.util.List; record Version(int major, int minor) implements Comparable<Version> { @Override public int compareTo(Version other) { return major != other.major ? Integer.compare(major, other.major) : Integer.compare(minor, other.minor); } } class Main { public static void main(String[] args) { List<Version> versions = new ArrayList<>(List.of( new Version(2, 0), new Version(1, 9))); Collections.sort(versions); // told once, in the type System.out.println(versions.get(0).major() + "." + versions.get(0).minor() + "," + versions.get(1).major() + "." + versions.get(1).minor()); } }
Every call site in the TypeScript column has to remember the comparison function, and a call that forgets sorts lexicographically without complaint. Declaring it once in the type is the trade nominal typing keeps offering: more ceremony at the declaration, and no ceremony at all afterwards.
Classes & Objects
Fields and constructors
A constructor is a method with the class's name and no return type. Fields are declared separately from it — there is no parameter-property shorthand, so the assignment is written out.
class BankAccount { #balance: number; constructor(opening: number) { this.#balance = opening; } deposit(amount: number): number { this.#balance += amount; return this.#balance; } } console.log(new BankAccount(100).deposit(50));
class BankAccount { private int balance; BankAccount(int opening) { this.balance = opening; } int deposit(int amount) { this.balance += amount; return this.balance; } } class Main { public static void main(String[] args) { System.out.println(new BankAccount(100).deposit(50)); } }
A class with no constructor gets an implicit no-argument one, and writing any constructor removes it — the source of "constructor BankAccount in class BankAccount cannot be applied to given types" the first time you add one. Records, in a later section, are the answer when a class is only carrying data.
Four levels of visibility, and they are enforced
There are four, not three: public, protected, private, and the default with no keyword — package-private, visible to every class in the same package and nothing else.
class Widget { publicName = "visible to everyone"; protected protectedName = "subclasses, by convention"; private privateName = "this class, at compile time only"; #reallyPrivate = "this class, enforced at runtime"; describe(): string { return `${this.publicName} / ${this.privateName} / ${this.#reallyPrivate}`; } } const widget = new Widget(); console.log(widget.describe()); console.log((widget as any).privateName); // private is erased — readable
class Widget { public String publicName = "visible to everyone"; protected String protectedName = "subclasses and same package"; String packagePrivateName = "same package only"; private String privateName = "this class only"; String describe() { return publicName + " / " + packagePrivateName + " / " + privateName; } } class Main { public static void main(String[] args) { Widget widget = new Widget(); System.out.println(widget.describe()); // widget.privateName — will not compile, and is not readable at runtime either System.out.println(widget.packagePrivateName); } }
Package-private has no TypeScript counterpart at all: module scope is per-file, so a helper shared by three files must be exported and is then visible to everything. It is also the default here, which surprises people — leaving off the keyword does not mean public.
equals, hashCode and toString
Every object inherits these three from Object, and overriding them is what gives a type value semantics. equals and hashCode must agree: equal objects must have equal hash codes, or hash-based collections misbehave.
class Point { constructor(readonly x: number, readonly y: number) {} toString(): string { return `Point[${this.x}, ${this.y}]`; } } const first = new Point(1, 2); const second = new Point(1, 2); console.log(first === second); console.log(JSON.stringify(first) === JSON.stringify(second)); console.log(first.toString());
import java.util.Objects; class Point { private final int x; private final int y; Point(int x, int y) { this.x = x; this.y = y; } @Override public boolean equals(Object other) { return other instanceof Point point && point.x == x && point.y == y; } @Override public int hashCode() { return Objects.hash(x, y); } @Override public String toString() { return "Point[" + x + ", " + y + "]"; } } class Main { public static void main(String[] args) { Point first = new Point(1, 2); Point second = new Point(1, 2); System.out.println(first.equals(second)); System.out.println(first.hashCode() == second.hashCode()); System.out.println(first); } }
TypeScript has no hook for any of this: === cannot be overridden, there is no hash protocol, and toString is consulted only by string conversion. Comparing by content is a function you write and remember to call — which is why the JSON.stringify comparison above, fragile about key order and useless for cyclic data, keeps getting used anyway.
Inheritance, abstract, final and @Override
A class extends exactly one other class. final on a method forbids overriding it, and @Override asks the compiler to confirm that the method really does override something.
abstract class Account { constructor(private readonly balance: number) {} abstract interestRate(): number; interest(): number { return this.balance * this.interestRate(); } } class Savings extends Account { override interestRate(): number { return 0.05; } } console.log(new Savings(1000).interest().toFixed(2));
abstract class Account { private final double balance; Account(double balance) { this.balance = balance; } abstract double interestRate(); final double interest() { return balance * interestRate(); } } class Savings extends Account { Savings(double balance) { super(balance); } @Override double interestRate() { return 0.05; } } class Main { public static void main(String[] args) { System.out.println("%.2f".formatted(new Savings(1000).interest())); } }
A subclass constructor must call super(...) first if the parent has no no-argument constructor — the parameter properties that make the TypeScript version a single line have no counterpart. final has no TypeScript equivalent either: nothing there can forbid a subclass from replacing a method.
Records
A record is where your object literal lands
One line declares the fields, a constructor, an accessor per component, and equals, hashCode and toString. It is the closest thing to writing an object literal and letting its type be inferred.
interface Person { name: string; born: number; } const person: Person = { name: "Ada", born: 1815 }; const same: Person = { name: "Ada", born: 1815 }; console.log(person.name, person.born); console.log(JSON.stringify(person)); console.log(person === same);
record Person(String name, int born) {} class Main { public static void main(String[] args) { Person person = new Person("Ada", 1815); Person same = new Person("Ada", 1815); System.out.println(person.name() + " " + person.born()); System.out.println(person); System.out.println(person.equals(same)); } }
Accessors are name(), not getName() — the JavaBeans convention does not apply to records. Every component is final, so a record is shallowly immutable, and the generated toString prints Person[name=Ada, born=1815], which is why records are pleasant in logs where a plain class prints an address.
Validation that cannot be skipped
A compact constructor names no parameters and runs before the fields are assigned, so a record can refuse to exist in an invalid state. There is no way to construct one around it.
interface Percentage { value: number; } function makePercentage(value: number): Percentage { if (value < 0 || value > 100) { throw new RangeError(`out of range: ${value}`); } return { value }; } console.log(makePercentage(50).value); try { makePercentage(150); } catch (error) { console.log((error as Error).message); } const unchecked: Percentage = { value: 150 }; // nothing stops this console.log(unchecked.value);
record Percentage(int value) { Percentage { if (value < 0 || value > 100) { throw new IllegalArgumentException("out of range: " + value); } } } class Main { public static void main(String[] args) { System.out.println(new Percentage(50).value()); try { new Percentage(150); } catch (IllegalArgumentException error) { System.out.println(error.getMessage()); } // there is no other way to make a Percentage } }
The last two lines of the TypeScript column are the point: a factory function only guards the door it is standing at, and the type itself allows any object of the right shape. When a type is a name rather than a shape, the constructor is the only door, so an invalid instance cannot be built at all.
Record patterns — destructuring, at last
A record pattern matches a record's type and binds its components in one step, and it nests. This is the only destructuring Java has, and it works only on records.
interface Point { x: number; y: number } interface Line { start: Point; end: Point } function describeLine({ start: { x: startX, y: startY }, end: { x: endX, y: endY } }: Line): string { return `line from ${startX},${startY} to ${endX},${endY}`; } function describePoint({ x, y }: Point): string { return `point ${x},${y}`; } console.log(describeLine({ start: { x: 0, y: 0 }, end: { x: 1, y: 1 } })); console.log(describePoint({ x: 2, y: 3 }));
record Point(int x, int y) {} record Line(Point start, Point end) {} class Main { static String describe(Object shape) { return switch (shape) { case Line(Point(var startX, var startY), Point(var endX, var endY)) -> "line from " + startX + "," + startY + " to " + endX + "," + endY; case Point(var x, var y) -> "point " + x + "," + y; default -> "unknown"; }; } public static void main(String[] args) { System.out.println(describe(new Line(new Point(0, 0), new Point(1, 1)))); System.out.println(describe(new Point(2, 3))); } }
Notice what the Java column gets for free that the TypeScript one has to arrange: one method handles both shapes, because the pattern tests the type as well as binding the parts. TypeScript destructuring never tests anything — it assumes the shape the annotation claims and reads undefined when the claim is wrong.
Copy-with-changes, the long way
There is no spread and no with expression, so making a changed copy means calling the constructor and naming every component you are keeping.
interface Article { slug: string; title: string; authors: string[]; } const article: Article = { slug: "records", title: "Records", authors: ["Ada"] }; const retitled: Article = { ...article, title: "Records, Properly" }; const coauthored: Article = { ...article, authors: [...article.authors, "Alan"] }; console.log(retitled.title); console.log(coauthored.authors.join(","));
import java.util.ArrayList; import java.util.List; record Article(String slug, String title, List<String> authors) {} class Main { public static void main(String[] args) { Article article = new Article("records", "Records", List.of("Ada")); Article retitled = new Article(article.slug(), "Records, Properly", article.authors()); List<String> moreAuthors = new ArrayList<>(article.authors()); moreAuthors.add("Alan"); Article coauthored = new Article(article.slug(), article.title(), List.copyOf(moreAuthors)); System.out.println(retitled.title()); System.out.println(String.join(",", coauthored.authors())); } }
This is the one place records are clearly worse than an object literal: the constructor call lists components you did not touch, and adding a component to Article means finding every such call. The usual mitigation is a hand-written withTitle method on the record, or a builder — and a with expression has been proposed for the language but has not shipped.
Enums & Sealed Types
An enum is a class with a fixed set of instances
An enum constant is a real object. It can take constructor arguments, hold fields, and define methods — so behavior that would live in a lookup table sits on the constant itself.
const PLANETS = { MERCURY: { mass: 3.303e23, radius: 2.4397e6 }, EARTH: { mass: 5.976e24, radius: 6.37814e6 }, } as const; type Planet = keyof typeof PLANETS; function surfaceGravity(planet: Planet): number { const { mass, radius } = PLANETS[planet]; return 6.673e-11 * mass / (radius * radius); } for (const planet of Object.keys(PLANETS) as Planet[]) { console.log(planet, surfaceGravity(planet).toFixed(2)); }
enum Planet { MERCURY(3.303e+23, 2.4397e6), EARTH(5.976e+24, 6.37814e6); private final double mass; private final double radius; Planet(double mass, double radius) { this.mass = mass; this.radius = radius; } double surfaceGravity() { return 6.673e-11 * mass / (radius * radius); } } class Main { public static void main(String[] args) { for (Planet planet : Planet.values()) { System.out.println(planet + " " + "%.2f".formatted(planet.surfaceGravity())); } } }
Each constant is a singleton, so == is the right comparison for enums — the one place reference identity is idiomatic. They also come with values(), name(), ordinal(), and the specialized EnumMap and EnumSet, which are backed by an array indexed by ordinal and are far faster than a hash map.
Switching over an enum is exhaustive
A switch expression over an enum needs no default, and leaving a constant unhandled is a compile error. Adding a constant later breaks every incomplete switch, which is the point.
type Status = "DRAFT" | "PUBLISHED" | "ARCHIVED"; function label(status: Status): string { switch (status) { case "DRAFT": return "not ready"; case "PUBLISHED": return "live"; case "ARCHIVED": return "hidden"; default: { const unreachable: never = status; throw new Error(`unhandled ${unreachable}`); } } } console.log(label("PUBLISHED")); console.log(["DRAFT", "PUBLISHED", "ARCHIVED"].join(","));
enum Status { DRAFT, PUBLISHED, ARCHIVED } class Main { static String label(Status status) { return switch (status) { case DRAFT -> "not ready"; case PUBLISHED -> "live"; case ARCHIVED -> "hidden"; }; } public static void main(String[] args) { System.out.println(label(Status.PUBLISHED)); System.out.println(java.util.Arrays.stream(Status.values()) .map(Status::name).collect(java.util.stream.Collectors.joining(","))); } }
The never trick in the TypeScript column is doing by hand what the Java compiler does by construction, and it has a hole the Java version does not: the argument is a plain string at runtime, so a value from JSON that is not one of the three reaches the default branch. An enum constant cannot be forged.
Sealed interfaces are your discriminated union
sealed lists the types allowed to implement an interface, so the compiler knows the complete set. Combined with record patterns, that gives the same exhaustively-checked dispatch a discriminated union gives.
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number }; function area(shape: Shape): number { switch (shape.kind) { case "circle": return Math.PI * shape.radius ** 2; case "square": return shape.side ** 2; } } console.log(area({ kind: "circle", radius: 1 }).toFixed(2)); console.log(area({ kind: "square", side: 3 }).toFixed(2));
sealed interface Shape permits Circle, Square {} record Circle(double radius) implements Shape {} record Square(double side) implements Shape {} class Main { static double area(Shape shape) { return switch (shape) { case Circle(double radius) -> Math.PI * radius * radius; case Square(double side) -> side * side; }; } public static void main(String[] args) { System.out.println("%.2f".formatted(area(new Circle(1)))); System.out.println("%.2f".formatted(area(new Square(3)))); } }
The cost is three declarations where TypeScript has one type alias, and the members must be named up front in the permits clause. What you get back is a discriminant that cannot be spoofed, an area that a caller cannot pass a wrong-shaped object to, and exhaustiveness enforced by the language rather than by a never idiom you have to remember.
There are no ad-hoc unions
This is the feature you will miss most. number | string cannot be written; a parameter that accepts either widens to Object, and the compiler stops helping.
type Identifier = number | string; function describe(identifier: Identifier): string { return typeof identifier === "number" ? `#${identifier}` : identifier.toUpperCase(); } console.log(describe(7)); console.log(describe("ada")); // describe(true); // compile error: boolean is not an Identifier
class Main { static String describe(Object identifier) { return switch (identifier) { case Integer number -> "#" + number; case String text -> text.toUpperCase(); default -> throw new IllegalArgumentException("unsupported"); }; } public static void main(String[] args) { System.out.println(describe(7)); System.out.println(describe("ada")); // describe(true) compiles fine, and fails at run time } }
The default branch exists because the compiler cannot rule anything out — Object is every type, so the check moves from build time to run time, which is exactly the direction nobody wants. When the set of alternatives is fixed and yours, a sealed interface restores the guarantee; when it is String or Integer, types you do not own, there is no way to say so.
Generics
Generic methods and classes
The syntax is close, with one wrinkle: a generic method declares its type parameters before the return type, so static <T> T firstOr(...) reads a little inside-out at first.
function firstOr<T>(items: T[], fallback: T): T { return items.length === 0 ? fallback : items[0]; } class Box<T> { constructor(private readonly value: T) {} map<R>(mapper: (value: T) => R): Box<R> { return new Box(mapper(this.value)); } get(): T { return this.value; } } console.log(firstOr(["a", "b"], "z")); console.log(new Box(21).map((value) => value * 2).get());
import java.util.List; import java.util.function.Function; class Box<T> { private final T value; Box(T value) { this.value = value; } <R> Box<R> map(Function<T, R> mapper) { return new Box<>(mapper.apply(value)); } T get() { return value; } } class Main { static <T> T firstOr(List<T> items, T fallback) { return items.isEmpty() ? fallback : items.get(0); } public static void main(String[] args) { System.out.println(firstOr(List.of("a", "b"), "z")); System.out.println(new Box<>(21).map(value -> value * 2).get()); } }
The <> in new Box<>(21) is the diamond: the type argument is inferred from context, so it is rarely written out. A type parameter cannot be a primitive, so Box<int> is illegal and Box<Integer> is what you write — every value in a generic container is boxed.
A bound names a supertype
T extends Comparable<T> looks like a TypeScript constraint and means something narrower — and stronger. The bound is a named type the argument must implement, so a method can demand "a type that knows how to compare itself" and stop asking the caller.
function largest<T>(items: T[], compare: (left: T, right: T) => number): T { let best = items[0]; for (const item of items) { if (compare(item, best) > 0) { best = item; } } return best; } console.log(largest(["aa", "bbbb", "c"], (left, right) => left.localeCompare(right))); console.log(largest([3, 9, 4], (left, right) => left - right));
import java.util.List; class Main { static <T extends Comparable<T>> T largest(List<T> items) { T best = items.get(0); for (T item : items) { if (item.compareTo(best) > 0) { best = item; } } return best; } public static void main(String[] args) { System.out.println(largest(List.of("aa", "bbbb", "c"))); System.out.println(largest(List.of(3, 9, 4))); } }
Both columns print the same two answers; the only visible difference is the comparator the TypeScript version has to be handed at every call. It has to be handed one because there is no way to say "a type that implements Comparable" when conformance is never declared — a bound can only describe a shape, and "compares to itself" is not a shape. The flip side is that a Java bound cannot say "anything with a numeric length", so when no shared interface exists the method cannot be written generically at all.
Wildcards, and arrays that check at runtime
Generic types are invariant: a List<Integer> is not a List<Number>. ? extends re-opens that for reading and ? super for writing — the ceremony TypeScript skips by making arrays covariant.
function total(numbers: readonly number[]): number { return numbers.reduce((sum, value) => sum + value, 0); } console.log(total([1, 2, 3])); const texts: string[] = ["a"]; const objects: unknown[] = texts; // arrays are covariant, and unsound objects[0] = 42; // no error at all console.log(typeof texts[0]); // "number", in a string[]
import java.util.List; class Main { static double total(List<? extends Number> numbers) { double sum = 0; for (Number number : numbers) { sum += number.doubleValue(); } return sum; } public static void main(String[] args) { System.out.println(total(List.of(1, 2, 3))); // List<Number> wrong = List.of(1, 2, 3) — a List<Integer> is not a List<Number> Object[] objects = new String[] { "a" }; try { objects[0] = 42; } catch (ArrayStoreException error) { System.out.println("ArrayStoreException"); } } }
Java arrays are covariant too, and that is old and acknowledged as a mistake — but the runtime still checks every store and throws ArrayStoreException, so the corruption is caught at the moment it happens. TypeScript makes the same unsound choice and has no runtime to catch it, so the array in the anchor column now holds a number and nothing anywhere knows.
Erasure, and the Class token that works around it
Type arguments are erased, so new T() is impossible here too. The difference is that classes survive, so a Class<T> object can be passed as a stand-in for the type and used to build one.
function create<T>(factory: () => T): T { return factory(); } const made = create(() => new Map<string, number>()); made.set("a", 1); console.log(made.get("a")); console.log(made instanceof Map); // there is no Class token to pass — a factory function is the only way
import java.util.HashMap; import java.util.Map; class Main { static <T> T create(Class<T> type) throws Exception { return type.getDeclaredConstructor().newInstance(); } public static void main(String[] args) throws Exception { Map<String, Integer> made = create(HashMap.class); made.put("a", 1); System.out.println(made.get("a")); System.out.println(made instanceof Map); } }
You cannot write list instanceof List<String> — the type argument is gone — but list instanceof List works, because List itself is a real runtime type. TypeScript has neither half: an interface leaves nothing behind at all, so even the unparameterized check has nothing to test against.
Types cannot be computed
There is no Partial, Pick, Omit, keyof, no mapped type and no conditional type. Every projection of a type is a second type you declare and maintain by hand.
interface User { id: number; name: string; email: string; } type UserSummary = Pick<User, "id" | "name">; type UserPatch = Partial<User>; const user: User = { id: 1, name: "Ada", email: "ada@example.com" }; const summary: UserSummary = { id: user.id, name: user.name }; const patch: UserPatch = { name: "Ada L." }; console.log(JSON.stringify(summary), JSON.stringify(patch));
record User(int id, String name, String email) {} record UserSummary(int id, String name) {} // written out, and kept in step by hand record UserPatch(String name) {} class Main { public static void main(String[] args) { User user = new User(1, "Ada", "ada@example.com"); UserSummary summary = new UserSummary(user.id(), user.name()); UserPatch patch = new UserPatch("Ada L."); System.out.println(summary + " " + patch); } }
Rename name in the TypeScript column and all three derived types stop compiling until they are updated; rename it in the Java column and the two extra records go quietly stale. This is the one capability TypeScript has that Java has no answer to at any level — not generics, not annotations, and not reflection, which reads types but cannot create them.
Null & Optional
One absence, and it is not in the type
There is no undefined. There is also no strictNullChecks: every reference type is implicitly nullable, and nothing in a signature says whether a value may be missing.
const value: string | null = null; console.log(value); console.log(value === null); // value.length // compile error — this line cannot be written console.log(value?.length);
class Main { public static void main(String[] args) { String value = null; System.out.println(value); System.out.println(value == null); try { System.out.println(value.length()); } catch (NullPointerException error) { System.out.println("NullPointerException"); } } }
This is the single biggest safety regression coming from TypeScript: the compiler that has been refusing your possibly-null dereferences for years stops, and the failure moves to run time. Java 14 at least made the message specific — it names the expression that was null — but nothing prevents the call. Annotations such as @Nullable are convention plus a linter, not part of the language.
Optional
Optional<T> is a container that either holds a value or does not, with map, filter, orElse and ifPresent. It is a return type, meant to say "this lookup may find nothing".
function find(key: string): string | undefined { return key === "ada" ? "Ada Lovelace" : undefined; } console.log(find("ada")?.toUpperCase() ?? "unknown"); console.log(find("bob")?.toUpperCase() ?? "unknown"); console.log(find("ada") !== undefined);
import java.util.Optional; class Main { static Optional<String> find(String key) { return "ada".equals(key) ? Optional.of("Ada Lovelace") : Optional.empty(); } public static void main(String[] args) { System.out.println(find("ada").map(String::toUpperCase).orElse("unknown")); System.out.println(find("bob").map(String::toUpperCase).orElse("unknown")); System.out.println(find("ada").isPresent()); } }
The advice, which surprises people, is to use it only for return values — not for fields, not for parameters, and not in collections, where it adds an allocation and a second way to be empty. So string | undefined covers ground Optional deliberately does not, and a nullable field remains a plain nullable field.
Walking a chain that might be empty
Without ?. a two-deep access is either two null tests or two Optional steps. Both are longer than the TypeScript line, and the Optional form allocates.
interface Manager { email?: string } interface Employee { manager?: Manager } const contractor: Employee = {}; console.log(contractor.manager?.email ?? "no manager"); const staff: Employee = { manager: { email: "ada@example.com" } }; console.log(staff.manager?.email ?? "no manager");
import java.util.Optional; record Manager(String email) {} record Employee(Manager manager) {} class Main { public static void main(String[] args) { Employee contractor = new Employee(null); System.out.println(Optional.ofNullable(contractor.manager()) .map(Manager::email).orElse("no manager")); Employee staff = new Employee(new Manager("ada@example.com")); System.out.println(Optional.ofNullable(staff.manager()) .map(Manager::email).orElse("no manager")); } }
The plain alternative — contractor.manager() == null ? "no manager" : contractor.manager().email() — evaluates the accessor twice, which is fine for a record and wrong for anything with a side effect. Neither form short-circuits the way ?. does, so a four-deep path is four chained map calls.
Asserting non-null actually runs
Objects.requireNonNull is the counterpart of the ! suffix, with one difference that matters: it is a method call that executes, checks, and throws with a message you chose.
function upper(text: string | undefined): string { return text!.toUpperCase(); } console.log(upper("ada")); try { console.log(upper(undefined)); } catch (error) { console.log((error as Error).message); }
import java.util.Objects; class Main { static String upper(String text) { return Objects.requireNonNull(text, "text must not be null").toUpperCase(); } public static void main(String[] args) { System.out.println(upper("ada")); try { System.out.println(upper(null)); } catch (NullPointerException error) { System.out.println(error.getMessage()); } } }
The ! suffix is erased, so the program carries the wrong value onward and fails at the dereference, wherever that happens to be — the message names the property, not the argument. Putting requireNonNull at the top of a constructor or a public method is the standard way to make a null fail at the boundary it crossed.
Error Handling
Checked exceptions
This has no TypeScript counterpart at all. A method declares the failures it can produce with throws, and the compiler refuses to build a caller that neither catches them nor declares them in turn.
function read(fail: boolean): string { if (fail) { throw new Error("disk unavailable"); } return "contents"; } // nothing in the signature mentions failure, and no caller is required to care try { console.log(read(false)); console.log(read(true)); } catch (error) { console.log("handled " + (error as Error).message); }
import java.io.IOException; class Main { static String read(boolean fail) throws IOException { if (fail) { throw new IOException("disk unavailable"); } return "contents"; } public static void main(String[] args) { try { System.out.println(read(false)); System.out.println(read(true)); } catch (IOException error) { System.out.println("handled " + error.getMessage()); } } }
The split is between RuntimeException and its subclasses, which are unchecked and behave the way every TypeScript error does, and everything else, which is checked. Checked exceptions are the most argued-about feature in the language, and the reason lambdas are awkward around I/O: none of the standard functional interfaces declare throws, so a checked exception inside a map has to be wrapped.
catch selects by type
There can be several catch clauses, each naming the exception type it handles, and one clause can list alternatives with |. The first matching clause wins, so order from specific to general.
function run(mode: number): void { if (mode === 0) throw new RangeError("bad argument"); if (mode === 1) throw new TypeError("bad state"); throw new Error("something else"); } for (const mode of [0, 1, 2]) { try { run(mode); } catch (error) { if (error instanceof RangeError || error instanceof TypeError) { console.log("expected: " + error.message); } else { console.log("unexpected: " + (error as Error).message); } } }
class Main { static void run(int mode) { if (mode == 0) throw new IllegalArgumentException("bad argument"); if (mode == 1) throw new IllegalStateException("bad state"); throw new RuntimeException("something else"); } public static void main(String[] args) { for (int mode = 0; mode < 3; mode++) { try { run(mode); } catch (IllegalArgumentException | IllegalStateException error) { System.out.println("expected: " + error.getMessage()); } catch (RuntimeException error) { System.out.println("unexpected: " + error.getMessage()); } } } }
Because the clause is typed, the exception variable is typed too — no unknown, no instanceof ladder, and no cast to read getMessage(). It also means an exception you did not name simply propagates, where a single untyped catch swallows everything unless you remember to rethrow.
try-with-resources closes things for you
A resource declared in the try (...) header is closed automatically when the block exits, in reverse order, whether it exits normally or by exception. Any class implementing AutoCloseable qualifies.
class Resource { constructor(private readonly name: string) { console.log(`open ${name}`); } read(): string { return `${this.name} contents`; } close(): void { console.log(`close ${this.name}`); } } const resource = new Resource("data"); try { console.log(resource.read()); } finally { resource.close(); }
class Resource implements AutoCloseable { private final String name; Resource(String name) { this.name = name; System.out.println("open " + name); } String read() { return name + " contents"; } @Override public void close() { System.out.println("close " + name); } } class Main { public static void main(String[] args) { try (Resource resource = new Resource("data")) { System.out.println(resource.read()); } } }
The finally version works and is what TypeScript still needs, but it puts the cleanup a long way from the acquisition and gets forgotten when a second resource is added. JavaScript is acquiring the same idea as using declarations with Symbol.dispose; until that is everywhere, finally is the tool.
Custom exception types
Extend RuntimeException for an unchecked failure or Exception for a checked one. The choice of superclass is what decides whether callers are forced to handle it.
class InsufficientFundsError extends Error { constructor(readonly shortfall: number) { super(`short by ${shortfall}`); this.name = "InsufficientFundsError"; } } try { throw new InsufficientFundsError(25); } catch (error) { if (error instanceof InsufficientFundsError) { console.log(error.shortfall + " " + error.message); } }
class InsufficientFundsException extends RuntimeException { private final int shortfall; InsufficientFundsException(int shortfall) { super("short by " + shortfall); this.shortfall = shortfall; } int shortfall() { return shortfall; } } class Main { public static void main(String[] args) { try { throw new InsufficientFundsException(25); } catch (InsufficientFundsException error) { System.out.println(error.shortfall() + " " + error.getMessage()); } } }
Setting this.name has no counterpart and needs none — the class name is already in the stack trace, because the class is really there. The other thing you get free is a cause chain: passing a cause to super keeps the original exception and the printed trace shows both, where an Error needs the cause option and code of your own to walk it.
Only a Throwable can be thrown
A throw takes an instance of Throwable and nothing else. Throwing a string, a number, or a plain object is a compile error, so a catch block can always ask for a message and a stack trace.
try { throw "a bare string"; } catch (error) { console.log(typeof error, String(error)); } try { throw new Error("or an Error"); } catch (error) { console.log(error instanceof Error ? `Error: ${error.message}` : "not an Error"); }
class Main { public static void main(String[] args) { // throw "a bare string" — will not compile try { throw new IllegalStateException("or an Error"); } catch (Throwable error) { System.out.println(error.getClass().getSimpleName() + ": " + error.getMessage()); } } }
This is why a Java catch can be typed at all, and why the error binding in TypeScript is unknown: any value at all can arrive there, including undefined, so every handler starts by working out what it caught. The rare cost is that you cannot throw a lightweight value for control flow — an exception always allocates a stack trace unless you suppress it.
Streams
A stream is opened, chained, and closed
Array methods have no direct equivalent on List. You open a stream(), chain intermediate operations, and end with a terminal one — toList, reduce, collect — which is what makes anything run.
const numbers = [1, 2, 3, 4, 5]; const total = numbers .filter((number) => number % 2 === 1) .map((number) => number * number) .reduce((sum, value) => sum + value, 0); console.log(total); console.log(numbers.map((number) => number * 2).join(","));
import java.util.List; import java.util.stream.Collectors; class Main { public static void main(String[] args) { List<Integer> numbers = List.of(1, 2, 3, 4, 5); int total = numbers.stream() .filter(number -> number % 2 == 1) .map(number -> number * number) .reduce(0, Integer::sum); System.out.println(total); System.out.println(numbers.stream() .map(number -> String.valueOf(number * 2)) .collect(Collectors.joining(","))); } }
The extra ceremony buys laziness: nothing happens until the terminal operation asks, and the elements flow through the whole chain one at a time, so no intermediate list is ever built. A five-stage array chain in TypeScript allocates five arrays; the stream allocates none.
Collectors
A Collector describes how to gather a stream into a result, and they compose — groupingBy takes a downstream collector, so "count per group" or "join per group" is one expression.
const words = ["ant", "bee", "ape", "bat"]; const grouped = Object.groupBy(words, (word) => word[0]); console.log(JSON.stringify(grouped)); console.log("[" + words.join(", ") + "]"); console.log(words.length);
import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.stream.Collectors; class Main { public static void main(String[] args) { List<String> words = List.of("ant", "bee", "ape", "bat"); Map<String, List<String>> grouped = new TreeMap<>(words.stream() .collect(Collectors.groupingBy(word -> word.substring(0, 1)))); System.out.println(grouped); System.out.println(words.stream().collect(Collectors.joining(", ", "[", "]"))); System.out.println(words.stream().collect(Collectors.counting())); } }
Composition is what Object.groupBy lacks: counting per group means grouping first and then mapping the result, where groupingBy(first, counting()) does it in one pass. Wrapping in a TreeMap above is deliberate — groupingBy returns a HashMap, whose iteration order is unspecified.
Infinite streams
Because a stream is lazy, it can be infinite. Stream.iterate generates forever and limit stops it, with only as many elements produced as the terminal operation consumes.
function* powersOfTwo(): Generator<number> { for (let value = 1; ; value *= 2) { yield value; } } const firstSix: number[] = []; for (const power of powersOfTwo()) { if (firstSix.length === 6) { break; } firstSix.push(power); } console.log(firstSix.join(","));
import java.util.stream.Collectors; import java.util.stream.Stream; class Main { public static void main(String[] args) { String firstSix = Stream.iterate(1, value -> value * 2) .limit(6) .map(String::valueOf) .collect(Collectors.joining(",")); System.out.println(firstSix); } }
A generator is the closest TypeScript equivalent and is genuinely more flexible — it can receive values back through next(value) — but it comes with no operators, so limit, map and filter over one are written by hand or imported. Both are one-shot: consuming a stream or a generator twice fails.
Primitive streams avoid boxing
A Stream<Integer> boxes every element. IntStream, LongStream and DoubleStream carry primitives instead, and bring numeric terminals that the object stream does not have.
const numbers = [1, 2, 3]; console.log(numbers.reduce((sum, value) => sum + value, 0)); console.log([1, 2, 3, 4, 5].reduce((sum, value) => sum + value, 0)); console.log(numbers.reduce((sum, value) => sum + value, 0) / numbers.length);
import java.util.List; import java.util.stream.IntStream; class Main { public static void main(String[] args) { List<Integer> numbers = List.of(1, 2, 3); System.out.println(numbers.stream().mapToInt(Integer::intValue).sum()); System.out.println(IntStream.rangeClosed(1, 5).sum()); System.out.println(numbers.stream().mapToInt(Integer::intValue).average().orElse(0)); } }
The orElse is there because average returns an OptionalDouble — an empty stream has no average, and the type says so. IntStream.range is also the idiomatic counted loop replacement, since there is no Array.from({ length: n }, ...) trick to reach for.
Threads & Concurrency
Code really can run somewhere else
A Thread is an operating-system thread, and starting one means two pieces of your code are running at the same instant. Nothing in TypeScript does this — async only interleaves on the one thread there is.
async function work(): Promise<void> { console.log("running on the only thread"); } await work(); console.log("running on the only thread");
class Main { public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> System.out.println("running on " + Thread.currentThread().getName())); worker.start(); worker.join(); System.out.println("running on " + Thread.currentThread().getName()); } }
start() launches the thread and returns immediately; join() blocks the caller until it finishes. Blocking is the ordinary way to wait here — there is no event loop to starve — which is why the whole vocabulary of not blocking that TypeScript is built around simply does not come up.
Two threads sharing a variable is a real hazard
Because the threads run simultaneously, counter++ — read, add, write — can interleave and lose updates. This is a whole class of bug that cannot exist on a single-threaded runtime.
let counter = 0; async function increment(times: number): Promise<void> { for (let index = 0; index < times; index += 1) { counter += 1; } } await Promise.all([increment(100_000), increment(100_000)]); console.log(`${counter} (always 200000)`);
class Main { public static void main(String[] args) throws InterruptedException { final int[] counter = { 0 }; Runnable work = () -> { for (int index = 0; index < 100_000; index++) { counter[0]++; // read, add, write — not atomic } }; Thread first = new Thread(work); Thread second = new Thread(work); first.start(); second.start(); first.join(); second.join(); System.out.println(counter[0] + " (rarely 200000)"); } }
The fixes are AtomicInteger, which makes the increment a single indivisible operation, or a synchronized block, which lets only one thread into the critical section at a time. Neither has a TypeScript counterpart, because two of your statements never overlap: counter += 1 is effectively atomic by construction. This example cannot be run in the browser: the execution environment has limited thread support.
ExecutorService is the thread pool
You rarely create threads by hand. An ExecutorService owns a pool, accepts tasks with submit, and is shut down when you are finished with it.
async function square(value: number): Promise<number> { return value * value; } const results = await Promise.all([1, 2, 3, 4].map(square)); console.log(results.reduce((sum, value) => sum + value, 0));
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; class Main { public static void main(String[] args) throws InterruptedException { AtomicInteger total = new AtomicInteger(); ExecutorService pool = Executors.newSingleThreadExecutor(); for (int value = 1; value <= 4; value++) { int captured = value; pool.submit(() -> total.addAndGet(captured * captured)); } pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS); System.out.println(total.get()); } }
Note int captured = value: a lambda can only capture a variable that is effectively final, so the loop variable has to be copied — a restriction let in a for loop removed for JavaScript years ago. Forgetting shutdown() leaves non-daemon threads alive and the program never exits, which has no equivalent failure mode in Node.
CompletableFuture is the Promise
CompletableFuture<T> is the promise type, with thenApply for then, thenCompose for a flattening then, and join to wait for the answer.
async function compute(): Promise<number> { return 21; } const doubled = (await compute()) * 2; console.log(`answer ${doubled}`);
import java.util.concurrent.CompletableFuture; class Main { public static void main(String[] args) { String result = CompletableFuture.supplyAsync(() -> 21) .thenApply(value -> value * 2) .thenApply(value -> "answer " + value) .join(); System.out.println(result); } }
There is no async/await syntax, so the chain stays in callback form and a conditional in the middle of it is genuinely awkward — the readability that await bought JavaScript has no equivalent. What Java has instead is that join() can simply block: waiting costs a parked thread rather than restructuring the function.
Virtual threads make blocking cheap again
A virtual thread is scheduled by the runtime rather than the operating system, so millions can exist and blocking one costs almost nothing. It is the same insight async is built on, applied without changing how the code is written.
function delay(milliseconds: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } const workers = Array.from({ length: 10_000 }, async () => { await delay(10); return 1; }); const completed = (await Promise.all(workers)).length; console.log(completed);
import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; class Main { public static void main(String[] args) throws InterruptedException { AtomicInteger completed = new AtomicInteger(); List<Thread> workers = new ArrayList<>(); for (int index = 0; index < 10_000; index++) { workers.add(Thread.ofVirtual().start(() -> { try { Thread.sleep(10); } catch (InterruptedException ignored) { } completed.incrementAndGet(); })); } for (Thread worker : workers) { worker.join(); } System.out.println(completed.get()); } }
The point is that Thread.sleep here is an ordinary blocking call in ordinary sequential code, and it scales like await — no coloring of functions, no async spreading up the call stack, and a stack trace that shows the whole path. This example cannot be run in the browser: the execution environment has no support for virtual threads. It runs on any JDK 21 or later.
Packages & Tooling
Packages, not file paths
An import names a type by its package, not a file by its path. The package is declared in the file and must match the directory layout, and what gets searched is the classpath rather than node_modules.
// File: temperature.ts export const FREEZING_POINT = 0; export function toFahrenheit(celsius: number): number { return celsius * 9 / 5 + 32; } // File: main.ts import { FREEZING_POINT, toFahrenheit } from "./temperature.js"; console.log(toFahrenheit(FREEZING_POINT));
// File: com/example/Temperature.java package com.example; public final class Temperature { public static final int FREEZING_POINT = 0; public static double toFahrenheit(double celsius) { return celsius * 9 / 5 + 32; } } // File: Main.java import com.example.Temperature; import static com.example.Temperature.FREEZING_POINT; class Main { public static void main(String[] args) { System.out.println(Temperature.toFahrenheit(FREEZING_POINT)); } }
An import brings a type into scope, never a function or a value, which is why import static java.lang.Math.max; is a separate form. Nothing is exported explicitly either: public on the type is what makes it visible outside its package, and the default is visible only within it.
There is no JSON in the standard library
Nothing corresponds to JSON.stringify and JSON.parse. Working with JSON means a dependency — Jackson or Gson — which is a real difference in how a small program gets started.
interface Person { name: string; born: number; } const person: Person = { name: "Ada", born: 1815 }; const document = JSON.stringify(person); console.log(document); const parsed = JSON.parse(document); // the type is any console.log(parsed.nmae); // undefined, and not a compile error
record Person(String name, int born) {} class Main { public static void main(String[] args) { // With Jackson this is one call each way, and the document is bound // to the record's components and checked as the object is built. Person person = new Person("Ada", 1815); String document = "{\"name\":\"" + person.name() + "\",\"born\":" + person.born() + "}"; System.out.println(document); } }
The dependency earns its place, because it does something JSON.parse cannot: it binds the document to a record and fails on the spot when a field is missing or the wrong type, where JSON.parse returns any and every downstream type is a guess. That is reflection again — the library reads the record's components at runtime and matches them to keys.
Annotations
An annotation attaches metadata to a declaration. Some are checked by the compiler (@Override), and most are read at runtime by a framework — the same job decorators do, without changing what the code does.
class Base { describe(): string { return "base"; } } class Derived extends Base { override describe(): string { return "derived"; } } console.log(new Derived().describe()); console.log(Object.getPrototypeOf(Derived).name);
class Base { String describe() { return "base"; } } class Derived extends Base { @Override String describe() { return "derived"; } } class Main { public static void main(String[] args) { System.out.println(new Derived().describe()); System.out.println(Derived.class.getSuperclass().getSimpleName()); } }
An annotation never rewrites the thing it is attached to, which is the opposite of a decorator: @Deprecated and @Entity only record a fact, and something else — the compiler, an annotation processor, or a framework reading them reflectively — decides what it means. That is why annotations have been stable for twenty years while JavaScript decorators went through several incompatible designs.